mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
346 lines
12 KiB
TypeScript
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;
|
|
}
|