mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user