Files
2026-06-01 20:19:26 -04:00

346 lines
12 KiB
TypeScript

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<string | null> {
// 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<string, string> = {};
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<any> {
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<any> {
// 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;
}