import { serve } from "https://deno.land/std@0.168.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1"; const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version", }; const QBO_BASE_URL = "https://quickbooks.api.intuit.com"; serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } try { const authHeader = req.headers.get("authorization"); if (!authHeader) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const QBO_CLIENT_ID = Deno.env.get("QBO_CLIENT_ID"); const QBO_CLIENT_SECRET = Deno.env.get("QBO_CLIENT_SECRET"); const QBO_REALM_ID = Deno.env.get("QBO_REALM_ID"); const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; if (!QBO_CLIENT_ID || !QBO_CLIENT_SECRET || !QBO_REALM_ID) { return new Response(JSON.stringify({ error: "QBO credentials not configured" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); // Verify the user is an admin const token = authHeader.replace("Bearer ", ""); const { data: { user }, error: authError } = await createClient(SUPABASE_URL, Deno.env.get("SUPABASE_ANON_KEY")!, { global: { headers: { Authorization: `Bearer ${token}` } }, }).auth.getUser(); if (authError || !user) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const { data: roles } = await supabaseAdmin.from("user_roles").select("role").eq("user_id", user.id); const isAdmin = roles?.some((r: any) => r.role === "admin"); if (!isAdmin) { return new Response(JSON.stringify({ error: "Forbidden: admin only" }), { status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const body = await req.json(); const { action } = body; // Get or refresh QBO access token const accessToken = await getQBOAccessToken(supabaseAdmin, QBO_CLIENT_ID, QBO_CLIENT_SECRET); if (!accessToken) { return new Response(JSON.stringify({ error: "QBO authentication failed. You may need to re-authorize.", reconnectRequired: true, }), { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } let result: any = { message: "Unknown action" }; switch (action) { case "sync_all": { // Pull invoices from QBO const invoices = await fetchQBOData(accessToken, QBO_REALM_ID, "Invoice"); const payments = await fetchQBOData(accessToken, QBO_REALM_ID, "Payment"); const purchases = await fetchQBOData(accessToken, QBO_REALM_ID, "Purchase"); let synced = 0; // Sync invoices as credits (money owed to us) if (invoices?.QueryResponse?.Invoice) { for (const inv of invoices.QueryResponse.Invoice) { const existing = await supabaseAdmin.from("company_bank_transactions") .select("id").eq("qbo_id", `INV-${inv.Id}`).maybeSingle(); if (!existing.data) { await supabaseAdmin.from("company_bank_transactions").insert({ date: inv.TxnDate, transaction_type: "invoice_payment", description: `QBO Invoice #${inv.DocNumber || inv.Id} - ${inv.CustomerRef?.name || "Customer"}`, reference_number: inv.DocNumber || null, credit: inv.TotalAmt || 0, debit: 0, category: "QBO Invoice", qbo_id: `INV-${inv.Id}`, qbo_sync_status: "synced", }); synced++; } } } // Sync payments as credits if (payments?.QueryResponse?.Payment) { for (const pmt of payments.QueryResponse.Payment) { const existing = await supabaseAdmin.from("company_bank_transactions") .select("id").eq("qbo_id", `PMT-${pmt.Id}`).maybeSingle(); if (!existing.data) { await supabaseAdmin.from("company_bank_transactions").insert({ date: pmt.TxnDate, transaction_type: "deposit", description: `QBO Payment - ${pmt.CustomerRef?.name || "Customer"}`, reference_number: pmt.PaymentRefNum || null, credit: pmt.TotalAmt || 0, debit: 0, category: "QBO Payment", qbo_id: `PMT-${pmt.Id}`, qbo_sync_status: "synced", }); synced++; } } } // Sync purchases as debits if (purchases?.QueryResponse?.Purchase) { for (const pur of purchases.QueryResponse.Purchase) { const existing = await supabaseAdmin.from("company_bank_transactions") .select("id").eq("qbo_id", `PUR-${pur.Id}`).maybeSingle(); if (!existing.data) { await supabaseAdmin.from("company_bank_transactions").insert({ date: pur.TxnDate, transaction_type: "payment", description: `QBO Purchase - ${pur.EntityRef?.name || "Vendor"}`, reference_number: pur.DocNumber || null, debit: pur.TotalAmt || 0, credit: 0, category: "QBO Purchase", qbo_id: `PUR-${pur.Id}`, qbo_sync_status: "synced", }); synced++; } } } // Push unsynced local transactions to QBO const { data: unsynced } = await supabaseAdmin.from("company_bank_transactions") .select("*") .is("qbo_id", null) .eq("qbo_sync_status", "pending"); let pushed = 0; if (unsynced) { for (const txn of unsynced) { try { const qboResult = await pushToQBO(accessToken, QBO_REALM_ID, txn); if (qboResult?.Id) { await supabaseAdmin.from("company_bank_transactions") .update({ qbo_id: `LOCAL-${qboResult.Id}`, qbo_sync_status: "synced" }) .eq("id", txn.id); pushed++; } } catch (e) { console.error("Push to QBO failed for txn:", txn.id, e); } } } // Log the sync await supabaseAdmin.from("qbo_sync_log").insert({ sync_type: "full_sync", direction: "bidirectional", entity_type: "all", status: "completed", payload: { invoices_pulled: synced, transactions_pushed: pushed }, }); result = { message: `Sync complete: ${synced} transactions pulled from QBO, ${pushed} pushed to QBO`, pulled: synced, pushed, }; break; } case "test_connection": { const companyInfo = await fetchQBOData(accessToken, QBO_REALM_ID, "CompanyInfo"); result = { message: "Connection successful", company: companyInfo?.QueryResponse?.CompanyInfo?.[0]?.CompanyName || "Connected", }; break; } default: result = { error: `Unknown action: ${action}` }; } return new Response(JSON.stringify(result), { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } catch (err) { console.error("QBO sync error:", err); return new Response(JSON.stringify({ error: err instanceof Error ? err.message : "Unknown error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } }); async function getQBOAccessToken( supabaseAdmin: any, clientId: string, clientSecret: string, ): Promise { // Check for stored refresh token const { data: settings } = await supabaseAdmin .from("company_settings") .select("*") .in("key", ["qbo_access_token", "qbo_refresh_token", "qbo_token_expiry"]); const tokenMap: Record = {}; settings?.forEach((s: any) => { tokenMap[s.key] = s.value; }); const now = Date.now(); // If we have a valid access token, use it if (tokenMap.qbo_access_token && tokenMap.qbo_token_expiry && parseInt(tokenMap.qbo_token_expiry) > now) { return tokenMap.qbo_access_token; } // Try to refresh if (tokenMap.qbo_refresh_token) { try { const resp = await fetch("https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Authorization": `Basic ${btoa(`${clientId}:${clientSecret}`)}`, }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: tokenMap.qbo_refresh_token, }), }); if (resp.ok) { const tokens = await resp.json(); const expiry = (now + tokens.expires_in * 1000).toString(); // Upsert tokens for (const [key, value] of Object.entries({ qbo_access_token: tokens.access_token, qbo_refresh_token: tokens.refresh_token, qbo_token_expiry: expiry, })) { const { data: existing } = await supabaseAdmin.from("company_settings").select("id").eq("key", key).maybeSingle(); if (existing) { await supabaseAdmin.from("company_settings").update({ value }).eq("key", key); } else { await supabaseAdmin.from("company_settings").insert({ key, value }); } } return tokens.access_token; } } catch (e) { console.error("Token refresh failed:", e); } } return null; } async function fetchQBOData(accessToken: string, realmId: string, entity: string): Promise { const query = `SELECT * FROM ${entity} MAXRESULTS 1000`; const resp = await fetch( `${QBO_BASE_URL}/v3/company/${realmId}/query?query=${encodeURIComponent(query)}`, { headers: { "Authorization": `Bearer ${accessToken}`, "Accept": "application/json", "Content-Type": "application/json", }, }, ); if (!resp.ok) { const text = await resp.text(); console.error(`QBO query failed for ${entity} [${resp.status}]:`, text); return null; } return resp.json(); } async function pushToQBO(accessToken: string, realmId: string, txn: any): Promise { // Push as a JournalEntry to QBO const entry = { Line: [ { DetailType: "JournalEntryLineDetail", Amount: txn.debit > 0 ? txn.debit : txn.credit, JournalEntryLineDetail: { PostingType: txn.debit > 0 ? "Debit" : "Credit", }, Description: txn.description || "Synced from ACM", }, ], TxnDate: txn.date, }; const resp = await fetch( `${QBO_BASE_URL}/v3/company/${realmId}/journalentry?minorversion=73`, { method: "POST", headers: { "Authorization": `Bearer ${accessToken}`, "Accept": "application/json", "Content-Type": "application/json", }, body: JSON.stringify(entry), }, ); if (!resp.ok) { const text = await resp.text(); throw new Error(`QBO push failed [${resp.status}]: ${text}`); } const result = await resp.json(); return result.JournalEntry; }