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, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version", }; interface TokenResponse { access_token: string; token_type: string; expires_in: number; } interface ZohoRequestOptions { method?: string; body?: unknown; includeOrganizationId?: boolean; organizationId?: string; // per-association override query?: Record; } let cachedToken: string | null = null; let tokenExpiry = 0; function getZohoAccountsBaseUrl() { return Deno.env.get("ZOHO_ACCOUNTS_BASE_URL") || "https://accounts.zoho.com"; } function getZohoApiBaseUrl() { return Deno.env.get("ZOHO_API_BASE_URL") || "https://www.zohoapis.com/books/v3"; } async function getAccessToken(): Promise { if (cachedToken && Date.now() < tokenExpiry) { return cachedToken; } const clientId = Deno.env.get("ZOHO_CLIENT_ID"); const clientSecret = Deno.env.get("ZOHO_CLIENT_SECRET"); const refreshToken = Deno.env.get("ZOHO_REFRESH_TOKEN"); if (!clientId || !clientSecret || !refreshToken) { throw new Error("Zoho OAuth credentials are not configured"); } const params = new URLSearchParams({ refresh_token: refreshToken, client_id: clientId, client_secret: clientSecret, grant_type: "refresh_token", }); const maxRetries = 3; for (let attempt = 1; attempt <= maxRetries; attempt++) { const res = await fetch(`${getZohoAccountsBaseUrl()}/oauth/v2/token?${params.toString()}`, { method: "POST", }); if (res.ok) { const data: TokenResponse = await res.json(); cachedToken = data.access_token; tokenExpiry = Date.now() + Math.max(data.expires_in - 300, 60) * 1000; return cachedToken; } const text = await res.text(); const isRateLimited = text.includes("too many requests") || text.includes("Access Denied"); if (isRateLimited && attempt < maxRetries) { const delay = 2000 * attempt; // 2s, 4s console.warn(`[Zoho] Token refresh rate-limited, retrying in ${delay}ms (attempt ${attempt}/${maxRetries})`); await new Promise((r) => setTimeout(r, delay)); continue; } throw new Error(`Failed to refresh Zoho token: ${res.status} ${text}`); } throw new Error("Failed to refresh Zoho token after retries"); } /** * Resolves the Zoho organization_id to use. * Priority: explicit organizationId param > ZOHO_ORGANIZATION_ID env var (fallback). */ function resolveOrgId(options: ZohoRequestOptions = {}): string | undefined { if (options.includeOrganizationId === false) return undefined; if (options.organizationId) return options.organizationId; return Deno.env.get("ZOHO_ORGANIZATION_ID") || undefined; } function getZohoLegacyApiBaseUrl() { const configured = Deno.env.get("ZOHO_LEGACY_API_BASE_URL"); if (configured) return configured; try { const apiUrl = new URL(getZohoApiBaseUrl()); const legacyHost = apiUrl.hostname .replace(/^www\.zohoapis\./, "books.zoho.") .replace(/^zohoapis\./, "books.zoho."); return `${apiUrl.protocol}//${legacyHost}/api/v3`; } catch { return "https://books.zoho.com/api/v3"; } } function buildZohoUrl( endpoint: string, options: ZohoRequestOptions = {}, baseUrl = getZohoApiBaseUrl() ) { const normalizedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; const [pathname, existingQuery = ""] = normalizedEndpoint.split("?"); const url = new URL(`${baseUrl}${pathname}`); const params = new URLSearchParams(existingQuery); Object.entries(options.query || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== "") { params.set(key, String(value)); } }); const orgId = resolveOrgId(options); if (orgId) { params.set("organization_id", orgId); } else if (options.includeOrganizationId !== false) { console.warn("No Zoho organization_id available for this request"); } url.search = params.toString(); return url.toString(); } async function zohoRequestWithBaseUrl( endpoint: string, options: ZohoRequestOptions = {}, baseUrl = getZohoApiBaseUrl() ): Promise { const token = await getAccessToken(); const url = buildZohoUrl(endpoint, options, baseUrl); const res = await fetch(url, { method: options.method || "GET", headers: { Authorization: `Zoho-oauthtoken ${token}`, "Content-Type": "application/json", }, body: options.body && (options.method || "GET") !== "GET" ? JSON.stringify(options.body) : undefined, }); const text = await res.text(); const payload = text ? JSON.parse(text) : null; if (!res.ok) { const message = payload?.message || text || "Unknown Zoho API error"; const code = payload?.code ? ` (${payload.code})` : ""; throw new Error(`Zoho API error ${res.status}${code}: ${message}`); } // Zoho Books can return HTTP 200 with a non-zero code indicating an error if (payload && typeof payload.code === "number" && payload.code !== 0) { const message = payload.message || "Unknown Zoho validation error"; throw new Error(`Zoho API error 200 (${payload.code}): ${message}`); } return payload; } async function zohoRequest(endpoint: string, options: ZohoRequestOptions = {}): Promise { return zohoRequestWithBaseUrl(endpoint, options, getZohoApiBaseUrl()); } function withInvoiceUpdateReason>(payload: T): T & { reason: string } { return { ...payload, reason: "Updated from management system", }; } function normalizeZohoAccountMatch(value: unknown): string { return String(value ?? "") .trim() .toLowerCase() .replace(/\s+/g, " "); } async function resolveZohoBillExpenseAccountId( db: ReturnType, expenseAccountId: string | null | undefined, organizationId: string ): Promise { // Fetch Zoho chart of accounts once const coaRes = await zohoRequest("/chartofaccounts", { organizationId }); const zohoAccounts = Array.isArray(coaRes?.chartofaccounts) ? coaRes.chartofaccounts : []; // If a local expense account was specified, try to map it if (expenseAccountId) { const { data: account } = await db .from("chart_of_accounts") .select("account_number, account_name") .eq("id", expenseAccountId) .maybeSingle(); if (account) { const localNumber = normalizeZohoAccountMatch(account.account_number); const localName = normalizeZohoAccountMatch(account.account_name); const match = zohoAccounts.find((zohoAccount: any) => { const zohoId = normalizeZohoAccountMatch(zohoAccount?.account_id); const zohoNumber = normalizeZohoAccountMatch(zohoAccount?.account_number); const zohoCode = normalizeZohoAccountMatch(zohoAccount?.account_code); const zohoName = normalizeZohoAccountMatch(zohoAccount?.account_name); return ( (!!localNumber && [zohoId, zohoNumber, zohoCode].includes(localNumber)) || (!!localName && zohoName === localName) ); }); if (match?.account_id) return String(match.account_id); } } // Fallback: pick a sensible default expense account from Zoho so the bill push doesn't fail const expenseAccounts = zohoAccounts.filter((a: any) => { const type = String(a?.account_type || "").toLowerCase(); const isActive = a?.is_active !== false; return isActive && (type === "expense" || type === "other_expense" || type.includes("expense")); }); // Prefer an account literally named "Other Expenses" / "Miscellaneous" / "Uncategorized Expense" const preferredNames = ["other expenses", "miscellaneous", "uncategorized expense", "general expenses", "operating expenses"]; const preferred = expenseAccounts.find((a: any) => preferredNames.includes(String(a?.account_name || "").trim().toLowerCase()) ); const fallback = preferred || expenseAccounts[0]; if (fallback?.account_id) { console.log(`[ZohoBills] Using fallback expense account: ${fallback.account_name} (${fallback.account_id})`); return String(fallback.account_id); } throw new Error("No expense accounts found in Zoho Books for this organization. Please create an expense account in Zoho."); } // Resolves a Zoho bank/cash account_id so payments are recorded in the banking journal // rather than sitting in Undeposited Funds. Looks up the association's local bank // account (preferring the operating account) and matches it by name against Zoho's // /bankaccounts list. Falls back to the first active bank account in Zoho. async function resolveZohoBankAccountId( db: ReturnType, associationId: string | null | undefined, organizationId: string, preferredBankAccountId?: string | null, ): Promise { try { // Pull Zoho bank/cash accounts once const res = await zohoRequest("/bankaccounts", { organizationId }); const zohoBankAccounts: any[] = res?.bankaccounts || res?.bank_accounts || []; if (!zohoBankAccounts.length) return undefined; const activeAccounts = zohoBankAccounts.filter((a: any) => a?.is_active !== false); if (!activeAccounts.length) return undefined; // Try to find a local bank account hint to match by name let localName: string | null = null; if (preferredBankAccountId) { const { data } = await db .from("bank_accounts") .select("account_name") .eq("id", preferredBankAccountId) .maybeSingle(); localName = data?.account_name || null; } else if (associationId) { const { data } = await db .from("bank_accounts") .select("account_name, account_category") .eq("association_id", associationId) .eq("status", "active") .order("account_category", { ascending: true }); const operating = (data || []).find((a: any) => (a.account_category || "operating") === "operating"); localName = (operating || (data || [])[0])?.account_name || null; } if (localName) { const norm = localName.trim().toLowerCase(); const match = activeAccounts.find((a: any) => String(a?.account_name || "").trim().toLowerCase() === norm ); if (match?.account_id) return String(match.account_id); } // Fallback: prefer a bank-typed account, then any active account const bankTyped = activeAccounts.find((a: any) => String(a?.account_type || "").toLowerCase() === "bank" ); return String((bankTyped || activeAccounts[0])?.account_id || "") || undefined; } catch (e) { console.warn("[ZohoBankAccount] resolve failed:", e); return undefined; } } async function zohoLegacyRequest(endpoint: string, options: ZohoRequestOptions = {}): Promise { return zohoRequestWithBaseUrl(endpoint, options, getZohoLegacyApiBaseUrl()); } function normalizeReportingTagOption(option: any): any | null { if (!option) return null; const optionId = option.tag_option_id ?? option.option_id ?? option.id; const optionName = option.option_name ?? option.name ?? option.label; if (!optionId || !optionName) return null; return { ...option, tag_option_id: String(optionId), option_id: String(optionId), option_name: String(optionName), }; } function flattenReportingTagOptions(options: any[] = []): any[] { const flattened: any[] = []; for (const option of options) { const normalized = normalizeReportingTagOption(option); if (!normalized) continue; flattened.push(normalized); if (Array.isArray(option.children) && option.children.length > 0) { flattened.push(...flattenReportingTagOptions(option.children)); } } return flattened; } function normalizeReportingTag(tag: any): any { return { ...tag, tag_id: String(tag?.tag_id ?? tag?.id ?? ""), tag_name: tag?.tag_name ?? tag?.name ?? "", options: flattenReportingTagOptions(tag?.options || tag?.tag_options || []), }; } function getServiceClient() { return createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ); } async function getAuthenticatedUser(req: Request) { const authHeader = req.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) return null; const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY") || Deno.env.get("SUPABASE_PUBLISHABLE_KEY")!, { global: { headers: { Authorization: authHeader } } } ); const token = authHeader.replace("Bearer ", ""); const { data, error } = await supabase.auth.getClaims(token); if (error || !data?.claims?.sub) return null; return { id: data.claims.sub, email: data.claims.email }; } /** * Helper: get the Zoho org ID for an association. * Falls back to the global ZOHO_ORGANIZATION_ID env var. */ async function getOrgIdForAssociation(associationId: string): Promise { const db = getServiceClient(); const { data } = await db .from("associations") .select("zoho_organization_id") .eq("id", associationId) .single(); return data?.zoho_organization_id || Deno.env.get("ZOHO_ORGANIZATION_ID") || undefined; } // ── Zoho → Local: Pull all contacts and match/create records ── // Pulls from ALL associations that have a zoho_organization_id async function pullContactsFromZoho() { const db = getServiceClient(); const { data: assocs } = await db .from("associations") .select("id, zoho_organization_id") .not("zoho_organization_id", "is", null); // Also include global fallback org const globalOrgId = Deno.env.get("ZOHO_ORGANIZATION_ID"); const { data: assocNoOrg } = await db .from("associations") .select("id, zoho_organization_id") .is("zoho_organization_id", null); // Build set of unique org IDs to pull from const orgAssocMap = new Map(); // orgId -> [associationIds] for (const a of assocs || []) { const org = a.zoho_organization_id; if (!orgAssocMap.has(org)) orgAssocMap.set(org, []); orgAssocMap.get(org)!.push(a.id); } if (globalOrgId && (assocNoOrg || []).length > 0) { if (!orgAssocMap.has(globalOrgId)) orgAssocMap.set(globalOrgId, []); for (const a of assocNoOrg || []) { orgAssocMap.get(globalOrgId)!.push(a.id); } } let totalCreated = 0, totalUpdated = 0, totalSkipped = 0, totalContacts = 0; for (const [orgId, assocIds] of orgAssocMap.entries()) { let page = 1; let hasMore = true; const allContacts: any[] = []; while (hasMore) { const res = await zohoRequest("/contacts", { organizationId: orgId, query: { page, per_page: 200 } }); const contacts = res?.contacts || []; allContacts.push(...contacts); hasMore = res?.page_context?.has_more_page === true; page++; } totalContacts += allContacts.length; for (const contact of allContacts) { const zohoId = String(contact.contact_id); const contactType = contact.contact_type; const name = contact.contact_name || ""; const email = contact.email || null; const phone = contact.phone || null; if (contactType === "vendor") { const { data: existing } = await db .from("vendors") .select("id") .eq("zoho_contact_id", zohoId) .maybeSingle(); if (existing) { await db.from("vendors").update({ name, email, phone, contact_name: contact.contact_persons?.[0]?.first_name || null, }).eq("id", existing.id); totalUpdated++; } else { const { data: match } = await db .from("vendors") .select("id") .ilike("name", name) .is("zoho_contact_id", null) .maybeSingle(); if (match) { await db.from("vendors").update({ zoho_contact_id: zohoId, email, phone }).eq("id", match.id); totalUpdated++; } else { totalSkipped++; } } } else { // Try matching association — scope to associations tied to this org const { data: assocMatch } = await db .from("associations") .select("id") .in("id", assocIds) .or(`zoho_contact_id.eq.${zohoId},name.ilike.${name}`) .maybeSingle(); if (assocMatch) { await db.from("associations").update({ zoho_contact_id: zohoId, email: email || undefined, phone: phone || undefined, }).eq("id", assocMatch.id); totalUpdated++; } else { // Try matching owner by zoho_contact_id first — scoped to this org's associations const { data: ownerMatch } = await db .from("owners") .select("id, unit_id") .in("association_id", assocIds) .eq("zoho_contact_id", zohoId) .maybeSingle(); if (ownerMatch) { await db.from("owners").update({ email, phone }).eq("id", ownerMatch.id); if (ownerMatch.unit_id && contact.contact_number) { await db.from("units").update({ zoho_customer_number: String(contact.contact_number) }).eq("id", ownerMatch.unit_id); } totalUpdated++; } else { // Try matching by street_address or property_address — scoped to this org's associations let matched = false; if (name) { const { data: addrMatch } = await db .from("owners") .select("id, unit_id") .in("association_id", assocIds) .is("zoho_contact_id", null) .or(`street_address.ilike.${name},property_address.ilike.${name}`) .maybeSingle(); if (addrMatch) { await db.from("owners").update({ zoho_contact_id: zohoId, email, phone }).eq("id", addrMatch.id); if (addrMatch.unit_id && contact.contact_number) { await db.from("units").update({ zoho_customer_number: String(contact.contact_number) }).eq("id", addrMatch.unit_id); } totalUpdated++; matched = true; } } if (!matched) { // Fall back to name-based matching — scoped to this org's associations const nameParts = name.split(" "); const firstName = nameParts[0] || ""; const lastName = nameParts.slice(1).join(" ") || ""; if (firstName) { const { data: nameMatch } = await db .from("owners") .select("id, unit_id") .in("association_id", assocIds) .ilike("first_name", firstName) .ilike("last_name", lastName || "%") .is("zoho_contact_id", null) .maybeSingle(); if (nameMatch) { await db.from("owners").update({ zoho_contact_id: zohoId, email, phone }).eq("id", nameMatch.id); if (nameMatch.unit_id && contact.contact_number) { await db.from("units").update({ zoho_customer_number: String(contact.contact_number) }).eq("id", nameMatch.unit_id); } totalUpdated++; } else { totalSkipped++; } } else { totalSkipped++; } } } } } } } return { total: totalContacts, created: totalCreated, updated: totalUpdated, skipped: totalSkipped }; } // ── Local → Zoho: Push local records as contacts ── // ── Push associations as contacts to the COMPANY Zoho org only ── async function pushContactsToZohoCompany() { const companyOrgId = Deno.env.get("ZOHO_ORGANIZATION_ID"); if (!companyOrgId) throw new Error("ZOHO_ORGANIZATION_ID (company) not configured"); const db = getServiceClient(); let created = 0, updated = 0, skipped = 0, errors = 0; const { data: associations } = await db .from("associations") .select("id, name, email, phone, address, city, state, zip, zoho_contact_id") .eq("status", "active"); for (const assoc of associations || []) { try { if (!assoc.name) { skipped++; continue; } const contactData = { contact_name: assoc.name, contact_type: "customer", email: assoc.email || undefined, phone: assoc.phone || undefined, billing_address: assoc.address ? { address: assoc.address, city: assoc.city || "", state: assoc.state || "", zip: assoc.zip || "", } : undefined, }; if (assoc.zoho_contact_id) { try { await zohoRequest(`/contacts/${assoc.zoho_contact_id}`, { method: "PUT", organizationId: companyOrgId, body: contactData, }); updated++; } catch (updateErr: any) { const msg = updateErr?.message || String(updateErr); if (msg.includes("404") || msg.includes("1002")) { // Stale ID — search and re-link or create const newId = await findOrCreateContact(assoc.name, contactData, companyOrgId); if (newId) { await db.from("associations").update({ zoho_contact_id: String(newId) }).eq("id", assoc.id); updated++; } else { errors++; } } else { throw updateErr; } } } else { const newId = await findOrCreateContact(assoc.name, contactData, companyOrgId); if (newId) { await db.from("associations").update({ zoho_contact_id: String(newId) }).eq("id", assoc.id); created++; } else { errors++; } } // Stagger to avoid rate limits await new Promise(r => setTimeout(r, 500)); } catch (e) { console.error(`Error pushing association ${assoc.id} to company Zoho:`, e); errors++; } } return { created, updated, skipped, errors, total: (associations || []).length }; } async function findOrCreateContact(name: string, contactData: any, orgId: string): Promise { try { const searchRes = await zohoRequest("/contacts", { organizationId: orgId, query: { contact_name: name } }); const match = (searchRes?.contacts || []).find( (c: any) => c.contact_name === name && c.contact_type === "customer" ); if (match) return String(match.contact_id); } catch (_) { /* search failed, try creating */ } try { const res = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: contactData }); return res?.contact?.contact_id ? String(res.contact.contact_id) : null; } catch (e) { console.error("Failed to create contact:", e); return null; } } async function pushContactsToZoho() { const db = getServiceClient(); let created = 0, updated = 0, errors = 0; const { data: associations } = await db .from("associations") .select("id, name, email, phone, address, city, state, zip, zoho_contact_id, zoho_organization_id"); for (const assoc of associations || []) { try { if (!assoc.name) continue; const orgId = assoc.zoho_organization_id || Deno.env.get("ZOHO_ORGANIZATION_ID"); if (!orgId) { errors++; continue; } const contactData = { contact_name: assoc.name, contact_type: "customer", email: assoc.email || undefined, phone: assoc.phone || undefined, billing_address: assoc.address ? { address: assoc.address, city: assoc.city || "", state: assoc.state || "", zip: assoc.zip || "", } : undefined, }; if (assoc.zoho_contact_id) { try { await zohoRequest(`/contacts/${assoc.zoho_contact_id}`, { method: "PUT", organizationId: orgId, body: { ...contactData }, }); updated++; } catch (updateErr: any) { if (updateErr?.message?.includes("404") || updateErr?.message?.includes("1002")) { console.warn(`Stale zoho_contact_id for association ${assoc.id}, recovering...`); try { const searchRes = await zohoRequest("/contacts", { organizationId: orgId, query: { contact_name: assoc.name }, }); const existingMatch = (searchRes?.contacts || []).find( (c: any) => c.contact_name === assoc.name && c.contact_type === "customer" ); if (existingMatch) { await db.from("associations").update({ zoho_contact_id: String(existingMatch.contact_id) }).eq("id", assoc.id); updated++; } else { const createRes = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: { ...contactData }, }); const newId = createRes?.contact?.contact_id; if (newId) { await db.from("associations").update({ zoho_contact_id: String(newId) }).eq("id", assoc.id); created++; } } } catch (recoveryErr) { console.error(`Recovery failed for association ${assoc.id}:`, recoveryErr); await db.from("associations").update({ zoho_contact_id: null }).eq("id", assoc.id); errors++; } } else { throw updateErr; } } } else { // Search first to avoid "already exists" errors try { const searchRes = await zohoRequest("/contacts", { organizationId: orgId, query: { contact_name: assoc.name }, }); const existingMatch = (searchRes?.contacts || []).find( (c: any) => c.contact_name === assoc.name && c.contact_type === "customer" ); if (existingMatch) { await db.from("associations").update({ zoho_contact_id: String(existingMatch.contact_id) }).eq("id", assoc.id); updated++; continue; } } catch (_) { /* search failed, try creating */ } const res = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: { ...contactData }, }); const newId = res?.contact?.contact_id; if (newId) { await db.from("associations").update({ zoho_contact_id: String(newId) }).eq("id", assoc.id); created++; } } } catch (e) { console.error(`Error pushing association ${assoc.id}:`, e); errors++; } } // Push owners — use street address as Zoho customer name; link units automatically // IMPORTANT: Only push owners whose association has its own zoho_organization_id // to prevent owners from being created in the management company's Zoho org const { data: owners } = await db .from("owners") .select("id, first_name, last_name, email, phone, street_address, property_address, mailing_address, unit_id, zoho_contact_id, association_id, associations!inner(zoho_organization_id, zoho_contact_id), units(unit_number, zoho_customer_number, account_number)") .not("associations.zoho_organization_id", "is", null); for (const owner of owners || []) { try { const fullName = `${owner.first_name} ${owner.last_name}`.trim(); if (!fullName) continue; const assocData = owner.associations as any; const unitData = owner.units as any; const orgId = assocData?.zoho_organization_id; // Never fall back to management company's org for owner contacts if (!orgId) { errors++; continue; } // Use street/property address as Zoho customer name (matches Zoho convention) const zohoCustomerName = owner.street_address || owner.property_address || fullName; // Build custom fields for unit ID mapping const customFields: Record = {}; if (unitData?.unit_number) customFields.cf_unit_id = unitData.unit_number; if ((owner.units as any)?.account_number) customFields.cf_customer_number = (owner.units as any)?.account_number; const contactData: any = { contact_name: zohoCustomerName, contact_type: "customer", email: owner.email || undefined, phone: owner.phone || undefined, company_name: fullName !== zohoCustomerName ? fullName : undefined, cf_designated_association: assocData?.zoho_contact_id || undefined, ...customFields, }; if (owner.zoho_contact_id) { try { await zohoRequest(`/contacts/${owner.zoho_contact_id}`, { method: "PUT", organizationId: orgId, body: { ...contactData }, }); updated++; // Auto-link unit's zoho_customer_number if missing if (owner.unit_id && unitData && !unitData.zoho_customer_number) { try { const contactRes = await zohoRequest(`/contacts/${owner.zoho_contact_id}`, { organizationId: orgId }); const customerNumber = contactRes?.contact?.contact_number; if (customerNumber) { await db.from("units").update({ zoho_customer_number: String(customerNumber) }).eq("id", owner.unit_id); } } catch (_) {} } } catch (updateErr: any) { if (updateErr?.message?.includes("404") || updateErr?.message?.includes("1002")) { console.warn(`Stale zoho_contact_id for owner ${owner.id}, attempting recovery...`); try { const searchRes = await zohoRequest("/contacts", { organizationId: orgId, query: { contact_name: zohoCustomerName }, }); const existingMatch = (searchRes?.contacts || []).find( (c: any) => c.contact_name === zohoCustomerName && c.contact_type === "customer" ); if (existingMatch) { await db.from("owners").update({ zoho_contact_id: String(existingMatch.contact_id) }).eq("id", owner.id); if (owner.unit_id && unitData && !unitData.zoho_customer_number && existingMatch.contact_number) { await db.from("units").update({ zoho_customer_number: String(existingMatch.contact_number) }).eq("id", owner.unit_id); } updated++; } else { const createRes = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: { ...contactData }, }); const newId = createRes?.contact?.contact_id; if (newId) { await db.from("owners").update({ zoho_contact_id: String(newId) }).eq("id", owner.id); const customerNumber = createRes?.contact?.contact_number; if (owner.unit_id && customerNumber) { await db.from("units").update({ zoho_customer_number: String(customerNumber) }).eq("id", owner.unit_id); } created++; } } } catch (recoveryErr) { console.error(`Recovery failed for owner ${owner.id}:`, recoveryErr); await db.from("owners").update({ zoho_contact_id: null }).eq("id", owner.id); errors++; } } else { throw updateErr; } } } else { // Search first to avoid "already exists" errors — try by address name, then by full name let existingMatch: any = null; try { const searchRes = await zohoRequest("/contacts", { organizationId: orgId, query: { contact_name: zohoCustomerName }, }); existingMatch = (searchRes?.contacts || []).find( (c: any) => c.contact_name === zohoCustomerName && c.contact_type === "customer" ); if (!existingMatch && zohoCustomerName !== fullName) { const searchRes2 = await zohoRequest("/contacts", { organizationId: orgId, query: { contact_name: fullName }, }); existingMatch = (searchRes2?.contacts || []).find( (c: any) => c.contact_name === fullName && c.contact_type === "customer" ); } } catch (_) { /* search failed */ } if (existingMatch) { await db.from("owners").update({ zoho_contact_id: String(existingMatch.contact_id) }).eq("id", owner.id); if (owner.unit_id && unitData && !unitData.zoho_customer_number && existingMatch.contact_number) { await db.from("units").update({ zoho_customer_number: String(existingMatch.contact_number) }).eq("id", owner.unit_id); } updated++; continue; } const res = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: { ...contactData }, }); const newId = res?.contact?.contact_id; if (newId) { await db.from("owners").update({ zoho_contact_id: String(newId) }).eq("id", owner.id); const customerNumber = res?.contact?.contact_number; if (owner.unit_id && customerNumber) { await db.from("units").update({ zoho_customer_number: String(customerNumber) }).eq("id", owner.unit_id); } created++; } } } catch (e) { console.error(`Error pushing owner ${owner.id}:`, e); errors++; } } // Push vendors const { data: vendors } = await db .from("vendors") .select("id, name, email, phone, contact_name, address, zoho_contact_id"); for (const vendor of vendors || []) { try { if (!vendor.name) continue; // Vendors use global org ID (no association link) const orgId = Deno.env.get("ZOHO_ORGANIZATION_ID"); const contactData = { contact_name: vendor.name, contact_type: "vendor", email: vendor.email || undefined, phone: vendor.phone || undefined, }; if (vendor.zoho_contact_id) { await zohoRequest(`/contacts/${vendor.zoho_contact_id}`, { method: "PUT", organizationId: orgId, body: { ...contactData }, }); updated++; } else { const res = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: { ...contactData }, }); const newId = res?.contact?.contact_id; if (newId) { await db.from("vendors").update({ zoho_contact_id: String(newId) }).eq("id", vendor.id); created++; } } } catch (e) { console.error(`Error pushing vendor ${vendor.id}:`, e); errors++; } } return { created, updated, errors }; } // ── Resolve Zoho customer ID from unit's zoho_customer_number or owner's zoho_contact_id ── async function resolveZohoCustomer( db: ReturnType, ownerId: string | null, unitId: string | null, orgId: string | undefined ): Promise<{ customerId: string; method: string }> { // 1. Try unit's zoho_customer_number first if (unitId) { const { data: unit } = await db .from("units") .select("zoho_customer_number, unit_number") .eq("id", unitId) .maybeSingle(); if (unit?.zoho_customer_number) { // Search Zoho for contact by customer number try { const res = await zohoRequest("/contacts", { organizationId: orgId, query: { contact_number: unit.zoho_customer_number }, }); const match = (res?.contacts || []).find( (c: any) => String(c.contact_number) === String(unit.zoho_customer_number) ); if (match) { return { customerId: String(match.contact_id), method: "zoho_customer_number" }; } } catch (e) { console.warn("Zoho customer number lookup failed:", e); } } } // 2. Fall back to owner's zoho_contact_id if (ownerId) { const { data: owner } = await db .from("owners") .select("zoho_contact_id, first_name, last_name") .eq("id", ownerId) .maybeSingle(); if (owner?.zoho_contact_id) { return { customerId: owner.zoho_contact_id, method: "zoho_contact_id" }; } // 3. Auto-create contact in Zoho const fullName = `${owner?.first_name || ""} ${owner?.last_name || ""}`.trim(); if (fullName) { const res = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: { contact_name: fullName, contact_type: "customer" }, }); const newId = res?.contact?.contact_id; if (newId) { await db.from("owners").update({ zoho_contact_id: String(newId) }).eq("id", ownerId); return { customerId: String(newId), method: "auto_created" }; } } } throw new Error("Could not resolve Zoho customer for this transaction"); } // ── Reporting Tags: Fetch and resolve for association ── async function hydrateReportingTag(tag: any, orgId?: string, useSettingsApi = false): Promise { const normalizedTag = normalizeReportingTag(tag); const tagId = normalizedTag.tag_id; const detailEndpoint = useSettingsApi ? `/settings/tags/${tagId}` : `/reportingtags/${tagId}`; if (!tagId) { return normalizedTag; } try { const detail = await zohoRequest(detailEndpoint, { organizationId: orgId }); const fullTag = detail?.reporting_tag || detail?.tag || detail; const options = flattenReportingTagOptions(fullTag?.options || fullTag?.tag_options || normalizedTag.options || []); if (options.length > 0) { console.log(`[ReportingTags] ${useSettingsApi ? "/settings/tags" : "/reportingtags"} tag "${normalizedTag.tag_name}" has ${options.length} options from detail`); return { ...normalizeReportingTag({ ...normalizedTag, ...fullTag }), options, }; } } catch (e) { console.warn(`[ReportingTags] Failed to fetch ${useSettingsApi ? "/settings/tags" : "/reportingtags"} details for tag ${tagId}:`, e); } if (!useSettingsApi) { try { const optionsRes = await zohoRequest(`/reportingtags/${tagId}/options`, { organizationId: orgId }); const options = flattenReportingTagOptions(optionsRes?.options || optionsRes?.tag_options || []); console.log(`[ReportingTags] Tag "${normalizedTag.tag_name}" has ${options.length} options from /options`); if (options.length > 0) { return { ...normalizedTag, options }; } } catch (e) { console.warn(`[ReportingTags] Failed to fetch /options for tag ${tagId}:`, e); } } console.log(`[ReportingTags] Tag "${normalizedTag.tag_name}" has ${normalizedTag.options.length} options`); return normalizedTag; } async function listReportingTags(orgId?: string): Promise { console.log("[ReportingTags] Fetching tags for orgId:", orgId); let tags: any[] = []; try { const res = await zohoRequest("/reportingtags", { organizationId: orgId }); tags = (res?.reporting_tags || res?.tags || []).map(normalizeReportingTag); console.log("[ReportingTags] Found", tags.length, "tags from /reportingtags"); } catch (e) { console.warn("[ReportingTags] Failed /reportingtags:", e); } if (tags.length > 0) { const enriched = await Promise.all(tags.map((tag: any) => hydrateReportingTag(tag, orgId))); if (enriched.some((tag: any) => (tag.options || []).length > 0)) { return enriched; } } console.log("[ReportingTags] No options returned from /reportingtags; trying /settings/tags endpoints"); try { const settingsRes = await zohoRequest("/settings/tags", { organizationId: orgId }); const settingsTags = (settingsRes?.reporting_tags || settingsRes?.tags || []).map(normalizeReportingTag); console.log("[ReportingTags] Found", settingsTags.length, "tags from /settings/tags"); if (settingsTags.length > 0) { return await Promise.all(settingsTags.map((tag: any) => hydrateReportingTag(tag, orgId, true))); } } catch (e) { console.warn("[ReportingTags] Failed /settings/tags:", e); } return tags; } /** * Resolves reporting tag options for a given association. * Looks for a "Designated Association" tag and matches the association name. * Also checks zoho_reporting_tag_mappings table for custom mappings. */ async function resolveReportingTags( db: ReturnType, associationId: string, orgId: string | undefined ): Promise> { const tags: Array<{ tag_id: string; tag_option_id: string }> = []; // Check for stored tag mappings first const { data: storedMappings } = await db .from("zoho_reporting_tag_mappings") .select("zoho_tag_id, zoho_tag_option_id") .eq("association_id", associationId); console.log(`[ReportingTags] Association ${associationId}: ${storedMappings?.length || 0} stored mappings found`); if (storedMappings && storedMappings.length > 0) { const result = storedMappings.map((m: any) => ({ tag_id: String(m.zoho_tag_id), tag_option_id: String(m.zoho_tag_option_id), })); console.log("[ReportingTags] Using stored mappings:", JSON.stringify(result)); return result; } // Auto-resolve: look for "Designated Association" tag and match by association name try { const { data: assoc } = await db .from("associations") .select("name") .eq("id", associationId) .single(); if (assoc?.name) { const allTags = await listReportingTags(orgId); console.log(`[ReportingTags] Fetched ${allTags.length} tags from Zoho for auto-resolve. Association name: "${assoc.name}"`); for (const tag of allTags) { const tagName = (tag.tag_name || "").toLowerCase(); console.log(`[ReportingTags] Checking tag: "${tag.tag_name}" with ${(tag.options || []).length} options`); if (tagName.includes("association") || tagName.includes("designated")) { const options = tag.options || []; const match = options.find( (opt: any) => (opt.option_name || "").toLowerCase() === assoc.name.toLowerCase() ); if (match) { tags.push({ tag_id: String(tag.tag_id), tag_option_id: String(match.tag_option_id), }); console.log(`[ReportingTags] Auto-matched: tag="${tag.tag_name}" option="${match.option_name}"`); } else { console.log(`[ReportingTags] No match found in tag "${tag.tag_name}" options: ${options.map((o: any) => o.option_name).join(", ")}`); } } } } } catch (e) { console.warn("Could not auto-resolve reporting tags:", e); } console.log(`[ReportingTags] Final resolved tags: ${tags.length}`, JSON.stringify(tags)); return tags; } // ── Push a single owner ledger charge as a Zoho invoice ── async function pushInvoiceToZoho(params: { ledger_entry_id: string }) { const db = getServiceClient(); const { data: entry, error } = await db .from("owner_ledger_entries") .select("*, owners(id, first_name, last_name, zoho_contact_id, association_id, unit_id, associations(zoho_contact_id, zoho_organization_id))") .eq("id", params.ledger_entry_id) .single(); if (error || !entry) throw new Error("Ledger entry not found"); if (entry.zoho_invoice_id) return { status: "already_synced", zoho_invoice_id: entry.zoho_invoice_id }; const ownerData = entry.owners as any; const orgId = ownerData?.associations?.zoho_organization_id || Deno.env.get("ZOHO_ORGANIZATION_ID"); const { customerId } = await resolveZohoCustomer(db, entry.owner_id, entry.unit_id, orgId); // Resolve reporting tags for this association const reportingTags = await resolveReportingTags(db, entry.association_id, orgId); const lineItem: any = { name: entry.description || entry.transaction_type || "Charge", description: entry.description || entry.transaction_type || "Charge", rate: Number(entry.debit) || 0, quantity: 1, }; // Apply reporting tags to the line item if (reportingTags.length > 0) { lineItem.tags = reportingTags; } const invoiceData: any = { customer_id: String(customerId), date: entry.date, line_items: [lineItem], notes: `Synced from local ledger entry ${entry.id}`, }; const res = await zohoRequest("/invoices", { method: "POST", organizationId: orgId, body: invoiceData }); const zohoInvoiceId = res?.invoice?.invoice_id; if (zohoInvoiceId) { await db.from("owner_ledger_entries").update({ zoho_invoice_id: String(zohoInvoiceId) }).eq("id", entry.id); } return { status: "created", zoho_invoice_id: zohoInvoiceId }; } // ── Push a single client invoice to Zoho Books ── async function pushClientInvoiceToZoho(params: { invoice_id: string; force_resync?: boolean }) { console.log(`[pushClientInvoice] Starting for invoice_id: ${params.invoice_id}`); const db = getServiceClient(); const { data: invoice, error } = await db .from("client_invoices") .select("*, associations(id, name, zoho_contact_id, zoho_organization_id), client_invoice_items(*)") .eq("id", params.invoice_id) .single(); if (error || !invoice) { console.error(`[pushClientInvoice] Invoice not found. Error: ${error?.message}`); throw new Error("Client invoice not found"); } console.log(`[pushClientInvoice] Invoice found: ${(invoice as any).invoice_number}, assoc: ${JSON.stringify((invoice as any).associations?.name)}`); const existingZohoId = (invoice as any).zoho_invoice_id; if (existingZohoId && !params.force_resync) { return { status: "already_synced", zoho_invoice_id: existingZohoId }; } const assoc = (invoice as any).associations; if (!assoc) { console.error(`[pushClientInvoice] No association data found for invoice ${params.invoice_id}`); throw new Error("Invoice has no associated association record"); } const orgId = assoc.zoho_organization_id || Deno.env.get("ZOHO_ORGANIZATION_ID"); console.log(`[pushClientInvoice] Using orgId: ${orgId}, customerId: ${assoc.zoho_contact_id}`); let customerId = assoc?.zoho_contact_id; if (!customerId) { // Search for existing customer first before creating const contactName = assoc?.name || "Unknown Association"; try { const searchRes = await zohoRequest("/contacts", { method: "GET", organizationId: orgId, query: { contact_name: contactName, contact_type: "customer" }, }); const existingContact = (searchRes?.contacts || []).find( (c: any) => (c.contact_name || "").toLowerCase() === contactName.toLowerCase() ); if (existingContact) { customerId = existingContact.contact_id; console.log(`[pushClientInvoice] Found existing Zoho customer: ${customerId}`); } } catch (searchErr) { console.log(`[pushClientInvoice] Customer search failed, will try to create: ${searchErr}`); } if (!customerId) { try { const res = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: { contact_name: contactName, contact_type: "customer" }, }); customerId = res?.contact?.contact_id; } catch (createErr: any) { // If duplicate error (3062), search again if (createErr?.message?.includes("3062")) { const retrySearch = await zohoRequest("/contacts", { method: "GET", organizationId: orgId, query: { contact_name: contactName }, }); const match = (retrySearch?.contacts || []).find( (c: any) => (c.contact_name || "").toLowerCase() === contactName.toLowerCase() ); customerId = match?.contact_id; } else { throw createErr; } } } if (customerId) { await db.from("associations").update({ zoho_contact_id: String(customerId) }).eq("id", assoc.id); } } if (!customerId) throw new Error("Could not resolve Zoho customer for this association"); const items = ((invoice as any).client_invoice_items || []) as any[]; const lineItems = items.length > 0 ? items.map((li: any) => { const qty = Number(li.quantity || 1); const totalAmt = Number(li.amount || 0); // Use unit_price if available, otherwise derive rate from amount/quantity const unitRate = li.unit_price != null ? Number(li.unit_price) : (qty > 0 ? totalAmt / qty : totalAmt); return { name: li.description || li.name || "Invoice item", description: li.description || "Invoice item", rate: li.is_credit ? -Math.abs(unitRate) : unitRate, quantity: qty, }; }) : [{ name: "Invoice Total", description: "Invoice Total", rate: Number((invoice as any).total || 0), quantity: 1, }]; const invoiceData: any = { customer_id: String(customerId), date: (invoice as any).issue_date || new Date().toISOString().split("T")[0], due_date: (invoice as any).due_date || undefined, reference_number: (invoice as any).invoice_number, line_items: lineItems, notes: (invoice as any).notes || `Synced from client invoice ${(invoice as any).invoice_number}`, }; const attemptInvoiceSync = async (custId: string): Promise<{ zohoInvoiceId: string; wasUpdated: boolean }> => { const payload = { ...invoiceData, customer_id: String(custId) }; let zohoInvoiceId: string; let wasUpdated = false; if (existingZohoId && params.force_resync) { try { const res = await zohoRequest(`/invoices/${existingZohoId}`, { method: "PUT", organizationId: orgId, body: withInvoiceUpdateReason(payload), query: { reason: "Updated from management system" }, }); zohoInvoiceId = res?.invoice?.invoice_id || existingZohoId; wasUpdated = true; } catch (updateErr: any) { if (updateErr?.message?.includes("1002") || updateErr?.message?.includes("404")) { console.log(`[pushClientInvoice] Zoho invoice ${existingZohoId} not found, creating new one`); const res = await zohoRequest("/invoices", { method: "POST", organizationId: orgId, body: payload }); zohoInvoiceId = res?.invoice?.invoice_id; } else { throw updateErr; } } } else { console.log(`[pushClientInvoice] Creating invoice with payload: ${JSON.stringify({ customer_id: payload.customer_id, date: payload.date, line_items_count: payload.line_items?.length, orgId })}`); const res = await zohoRequest("/invoices", { method: "POST", organizationId: orgId, body: payload }); console.log(`[pushClientInvoice] Zoho invoice created: ${res?.invoice?.invoice_id}`); zohoInvoiceId = res?.invoice?.invoice_id; if (!zohoInvoiceId) throw new Error(`Zoho returned success but no invoice_id. Response: ${JSON.stringify(res)?.slice(0, 500)}`); } return { zohoInvoiceId: zohoInvoiceId!, wasUpdated }; }; try { let result: { zohoInvoiceId: string; wasUpdated: boolean }; try { result = await attemptInvoiceSync(customerId); } catch (firstErr: any) { // If customer is not accessible (deleted/permission issue), re-resolve the customer if (firstErr?.message?.includes("1002") || firstErr?.message?.includes("not accessible")) { console.log(`[pushClientInvoice] Customer ${customerId} not accessible in Zoho, re-resolving...`); const contactName = assoc?.name || "Unknown Association"; let newCustomerId: string | null = null; // Search for existing customer try { const searchRes = await zohoRequest("/contacts", { method: "GET", organizationId: orgId, query: { contact_name: contactName, contact_type: "customer" }, }); const match = (searchRes?.contacts || []).find( (c: any) => (c.contact_name || "").toLowerCase() === contactName.toLowerCase() ); newCustomerId = match?.contact_id || null; } catch (_) { /* ignore */ } // Create if not found if (!newCustomerId) { try { const res = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: { contact_name: contactName, contact_type: "customer" }, }); newCustomerId = res?.contact?.contact_id; } catch (createErr: any) { if (createErr?.message?.includes("3062")) { const retrySearch = await zohoRequest("/contacts", { method: "GET", organizationId: orgId, query: { contact_name: contactName }, }); const m = (retrySearch?.contacts || []).find( (c: any) => (c.contact_name || "").toLowerCase() === contactName.toLowerCase() ); newCustomerId = m?.contact_id || null; } else { throw createErr; } } } if (!newCustomerId) throw new Error("Could not re-resolve Zoho customer after stale ID"); // Update stored contact id await db.from("associations").update({ zoho_contact_id: String(newCustomerId) }).eq("id", assoc.id); customerId = newCustomerId; result = await attemptInvoiceSync(newCustomerId); } else { throw firstErr; } } if (result.zohoInvoiceId) { await db.from("client_invoices").update({ zoho_invoice_id: String(result.zohoInvoiceId), zoho_sync_status: "synced", zoho_sync_error: null, }).eq("id", params.invoice_id); } return { status: result.wasUpdated ? "updated" : "created", zoho_invoice_id: result.zohoInvoiceId }; } catch (e: any) { await db.from("client_invoices").update({ zoho_sync_status: "error", zoho_sync_error: e?.message || String(e), }).eq("id", params.invoice_id); throw e; } } // ── Push all unsynced client invoices to Zoho ── async function pushAllClientInvoicesToZoho(params?: { force_resync?: boolean }) { const force = params?.force_resync || false; const db = getServiceClient(); let query = db.from("client_invoices").select("id").neq("status", "void"); if (!force) { query = query.is("zoho_invoice_id", null); } const { data: invoices } = await query; let synced = 0; let errors = 0; for (let i = 0; i < (invoices || []).length; i++) { if (i > 0) await new Promise((r) => setTimeout(r, 500)); // stagger to avoid rate limits try { await pushClientInvoiceToZoho({ invoice_id: invoices![i].id, force_resync: force }); synced++; } catch (e) { console.warn("Client invoice sync error:", invoices![i].id, e); errors++; } } return { synced, errors, total: (invoices || []).length }; } // ── Payment application priority waterfall ── const CHARGE_TYPE_PRIORITY: Record = { bank_fee: 1, interest: 2, late_fee: 3, legal_fee: 4, admin_fee: 5, violation: 6, assessment: 7, charge: 8, // generic charges last }; function getChargePriority(transactionType: string | null): number { if (!transactionType) return 99; const normalized = transactionType.toLowerCase().replace(/[\s-]+/g, "_"); return CHARGE_TYPE_PRIORITY[normalized] ?? 99; } /** * Fetches unpaid Zoho invoices for a customer and allocates a payment * amount across them following the waterfall priority. * Returns the `invoices` array for Zoho's customerpayments API. */ async function allocatePaymentToInvoices( db: ReturnType, customerId: string, paymentAmount: number, orgId: string | undefined ): Promise> { // 1. Fetch unpaid invoices for this customer from Zoho let unpaidInvoices: any[] = []; try { const res = await zohoRequest("/invoices", { organizationId: orgId, query: { customer_id: customerId, status: "unpaid", per_page: 200 }, }); unpaidInvoices = res?.invoices || []; } catch (e) { console.warn("Could not fetch unpaid invoices for allocation:", e); return []; } if (unpaidInvoices.length === 0) return []; // 2. Match Zoho invoices to local ledger entries to get transaction_type const zohoInvoiceIds = unpaidInvoices.map((inv: any) => String(inv.invoice_id)); const { data: localEntries } = await db .from("owner_ledger_entries") .select("zoho_invoice_id, transaction_type") .in("zoho_invoice_id", zohoInvoiceIds); const typeMap = new Map(); for (const entry of localEntries || []) { if (entry.zoho_invoice_id) { typeMap.set(entry.zoho_invoice_id, entry.transaction_type || "charge"); } } // 3. Sort invoices by waterfall priority const sortedInvoices = unpaidInvoices .map((inv: any) => ({ invoice_id: String(inv.invoice_id), balance: Number(inv.balance) || 0, transaction_type: typeMap.get(String(inv.invoice_id)) || "charge", date: inv.date || "", })) .filter((inv: any) => inv.balance > 0) .sort((a: any, b: any) => { const priorityDiff = getChargePriority(a.transaction_type) - getChargePriority(b.transaction_type); if (priorityDiff !== 0) return priorityDiff; return a.date.localeCompare(b.date); // oldest first within same type }); // 4. Allocate payment across invoices in priority order let remaining = paymentAmount; const allocations: Array<{ invoice_id: string; amount_applied: number }> = []; for (const inv of sortedInvoices) { if (remaining <= 0) break; const applied = Math.min(remaining, inv.balance); allocations.push({ invoice_id: inv.invoice_id, amount_applied: Math.round(applied * 100) / 100 }); remaining -= applied; } return allocations; } // ── Push a single admin payment as a Zoho customer payment ── async function pushPaymentToZoho(params: { payment_id: string }) { const db = getServiceClient(); const { data: payment, error } = await db .from("admin_payments") .select("*, owners(id, first_name, last_name, zoho_contact_id, unit_id, associations(zoho_organization_id))") .eq("id", params.payment_id) .single(); if (error || !payment) throw new Error("Payment not found"); if (payment.zoho_payment_id) return { status: "already_synced", zoho_payment_id: payment.zoho_payment_id }; const ownerData = payment.owners as any; const orgId = ownerData?.associations?.zoho_organization_id || await getOrgIdForAssociation(payment.association_id); const { customerId } = await resolveZohoCustomer(db, payment.owner_id, ownerData?.unit_id, orgId); // Allocate payment to invoices using waterfall priority const invoiceAllocations = await allocatePaymentToInvoices(db, customerId, Number(payment.amount), orgId); const paymentData: any = { customer_id: String(customerId), date: payment.payment_date || new Date().toISOString().slice(0, 10), amount: Number(payment.amount), payment_mode: payment.payment_method || "check", description: payment.description || "Owner payment", reference_number: payment.reference_number || undefined, }; if (invoiceAllocations.length > 0) { paymentData.invoices = invoiceAllocations; } // Record into the banking journal by depositing into the association's bank account const depositAccountId = await resolveZohoBankAccountId(db, payment.association_id, orgId); if (depositAccountId) paymentData.account_id = depositAccountId; const res = await zohoRequest("/customerpayments", { method: "POST", organizationId: orgId, body: paymentData }); const zohoPaymentId = res?.payment?.payment_id; if (zohoPaymentId) { await db.from("admin_payments").update({ zoho_payment_id: String(zohoPaymentId) }).eq("id", payment.id); } return { status: "created", zoho_payment_id: zohoPaymentId, allocations: invoiceAllocations }; } // ── Push a ledger credit entry (payment) as a Zoho customer payment ── async function pushLedgerPaymentToZoho(params: { ledger_entry_id: string }) { const db = getServiceClient(); const { data: entry, error } = await db .from("owner_ledger_entries") .select("*, owners(id, first_name, last_name, zoho_contact_id, unit_id, associations(zoho_organization_id))") .eq("id", params.ledger_entry_id) .single(); if (error || !entry) throw new Error("Ledger entry not found"); const ownerData = entry.owners as any; const orgId = ownerData?.associations?.zoho_organization_id || Deno.env.get("ZOHO_ORGANIZATION_ID"); const { customerId } = await resolveZohoCustomer(db, entry.owner_id, entry.unit_id, orgId); const paymentAmount = Number(entry.credit) || 0; // Allocate payment to invoices using waterfall priority const invoiceAllocations = await allocatePaymentToInvoices(db, customerId, paymentAmount, orgId); const paymentData: any = { customer_id: String(customerId), date: entry.date, amount: paymentAmount, payment_mode: "other", description: entry.description || "Owner payment", reference_number: entry.reference_id || undefined, }; if (invoiceAllocations.length > 0) { paymentData.invoices = invoiceAllocations; } const depositAccountId = await resolveZohoBankAccountId(db, entry.association_id, orgId); if (depositAccountId) paymentData.account_id = depositAccountId; const res = await zohoRequest("/customerpayments", { method: "POST", organizationId: orgId, body: paymentData }); const zohoPaymentId = res?.payment?.payment_id; return { status: "created", zoho_payment_id: zohoPaymentId, allocations: invoiceAllocations }; } // Pulls from each association's Zoho org async function pullInvoicesFromZoho() { const db = getServiceClient(); const { data: assocs } = await db .from("associations") .select("id, zoho_organization_id"); const globalOrgId = Deno.env.get("ZOHO_ORGANIZATION_ID"); const orgSet = new Set(); for (const a of assocs || []) { orgSet.add(a.zoho_organization_id || globalOrgId || ""); } if (globalOrgId) orgSet.add(globalOrgId); orgSet.delete(""); let totalCreated = 0, totalUpdated = 0, totalSkipped = 0, totalInvoices = 0; for (const orgId of orgSet) { let page = 1; let hasMore = true; while (hasMore) { const res = await zohoRequest("/invoices", { organizationId: orgId, query: { page, per_page: 200 } }); const invoices = res?.invoices || []; totalInvoices += invoices.length; for (const inv of invoices) { const zohoInvId = String(inv.invoice_id); const { data: existing } = await db .from("owner_ledger_entries") .select("id") .eq("zoho_invoice_id", zohoInvId) .maybeSingle(); if (existing) { totalUpdated++; continue; } const customerId = String(inv.customer_id); const customerNumber = inv.customer_number ? String(inv.customer_number) : null; // Try matching by zoho_contact_id on owner first let owner: any = null; const { data: ownerByContact } = await db .from("owners") .select("id, association_id") .eq("zoho_contact_id", customerId) .maybeSingle(); owner = ownerByContact; // If not found, try matching via unit's zoho_customer_number if (!owner && customerNumber) { const { data: unit } = await db .from("units") .select("id, association_id, owners(id, association_id)") .eq("zoho_customer_number", customerNumber) .maybeSingle(); if (unit && (unit as any).owners?.[0]) { owner = (unit as any).owners[0]; } } if (!owner) { totalSkipped++; continue; } const totalAmount = Number(inv.total) || 0; if (totalAmount <= 0) { totalSkipped++; continue; } await db.from("owner_ledger_entries").insert({ owner_id: owner.id, association_id: owner.association_id, date: inv.date || new Date().toISOString().slice(0, 10), transaction_type: "charge", description: `Zoho Invoice #${inv.invoice_number || zohoInvId}`, debit: totalAmount, credit: 0, zoho_invoice_id: zohoInvId, }); totalCreated++; } hasMore = res?.page_context?.has_more_page === true; page++; } } return { total: totalInvoices, created: totalCreated, updated: totalUpdated, skipped: totalSkipped }; } // ── Pull payments from Zoho → local admin_payments ── async function pullPaymentsFromZoho() { const db = getServiceClient(); const { data: assocs } = await db .from("associations") .select("id, zoho_organization_id"); const globalOrgId = Deno.env.get("ZOHO_ORGANIZATION_ID"); const orgSet = new Set(); for (const a of assocs || []) { orgSet.add(a.zoho_organization_id || globalOrgId || ""); } if (globalOrgId) orgSet.add(globalOrgId); orgSet.delete(""); let totalCreated = 0, totalUpdated = 0, totalSkipped = 0, totalPayments = 0; for (const orgId of orgSet) { let page = 1; let hasMore = true; while (hasMore) { const res = await zohoRequest("/customerpayments", { organizationId: orgId, query: { page, per_page: 200 } }); const payments = res?.customerpayments || res?.payments || []; totalPayments += payments.length; for (const pmt of payments) { const zohoPmtId = String(pmt.payment_id); const { data: existing } = await db .from("admin_payments") .select("id") .eq("zoho_payment_id", zohoPmtId) .maybeSingle(); if (existing) { totalUpdated++; continue; } const customerId = String(pmt.customer_id); const customerNumber = pmt.customer_number ? String(pmt.customer_number) : null; let owner: any = null; const { data: ownerByContact } = await db .from("owners") .select("id, association_id") .eq("zoho_contact_id", customerId) .maybeSingle(); owner = ownerByContact; if (!owner && customerNumber) { const { data: unit } = await db .from("units") .select("id, association_id, owners(id, association_id)") .eq("zoho_customer_number", customerNumber) .maybeSingle(); if (unit && (unit as any).owners?.[0]) { owner = (unit as any).owners[0]; } } if (!owner) { totalSkipped++; continue; } const amount = Number(pmt.amount) || 0; if (amount <= 0) { totalSkipped++; continue; } await db.from("admin_payments").insert({ owner_id: owner.id, association_id: owner.association_id, amount, payment_date: pmt.date || new Date().toISOString().slice(0, 10), payment_method: pmt.payment_mode || "other", description: `Zoho Payment #${pmt.payment_number || zohoPmtId}`, reference_number: pmt.reference_number || null, status: "completed", zoho_payment_id: zohoPmtId, }); totalCreated++; } hasMore = res?.page_context?.has_more_page === true; page++; } } return { total: totalPayments, created: totalCreated, updated: totalUpdated, skipped: totalSkipped }; } // ── Push a single local bill as a Zoho bill ── async function pushBillToZoho(params: { bill_id: string; force_company_org?: boolean }) { const db = getServiceClient(); const { data: bill, error } = await db .from("bills") .select("*, vendors(id, name, zoho_contact_id), associations(zoho_organization_id), invoices:source_invoice_id(id, vendor_name, invoice_number, issue_date, due_date, amount, description, raw_pdf_url, line_items)") .eq("id", params.bill_id) .single(); if (error || !bill) throw new Error("Bill not found"); if ((bill as any).zoho_bill_id) return { status: "already_synced", zoho_bill_id: (bill as any).zoho_bill_id }; const assocData = (bill as any).associations as any; const sourceInvoice = (bill as any).invoices as any; // When force_company_org is set, always use the company-level ZOHO_ORGANIZATION_ID const orgId = params.force_company_org ? (Deno.env.get("ZOHO_ORGANIZATION_ID") || null) : (assocData?.zoho_organization_id || await getOrgIdForAssociation(bill.association_id)); if (!orgId) throw new Error("No Zoho organization configured" + (params.force_company_org ? " (company)" : " for this association")); const vendorData = (bill as any).vendors as any; const vendorName = vendorData?.name || sourceInvoice?.vendor_name || bill.notes || "Unknown Vendor"; async function resolveOrCreateVendorId(): Promise { try { const searchRes = await zohoRequest("/contacts", { organizationId: orgId, query: { contact_name: vendorName, contact_type: "vendor" }, }); const match = (searchRes?.contacts || []).find( (c: any) => c.contact_name === vendorName && c.contact_type === "vendor" ); if (match) { const id = String(match.contact_id); if (vendorData?.id) await db.from("vendors").update({ zoho_contact_id: id }).eq("id", vendorData.id); return id; } const createRes = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: { contact_name: vendorName, contact_type: "vendor" }, }); const id = createRes?.contact?.contact_id ? String(createRes.contact.contact_id) : null; if (id && vendorData?.id) await db.from("vendors").update({ zoho_contact_id: id }).eq("id", vendorData.id); return id; } catch (e) { console.warn("Could not resolve vendor for bill:", e); return null; } } let vendorId: string | null = null; if (vendorData?.zoho_contact_id) { // Verify the stored vendor still exists in this Zoho org try { await zohoRequest(`/contacts/${vendorData.zoho_contact_id}`, { organizationId: orgId }); vendorId = vendorData.zoho_contact_id; } catch (e: any) { const msg = e?.message || String(e); if (msg.includes("1002") || msg.includes("not accessible") || msg.includes("404")) { console.warn(`Stale zoho_contact_id ${vendorData.zoho_contact_id} for vendor ${vendorData?.id}; clearing and re-resolving.`); if (vendorData?.id) await db.from("vendors").update({ zoho_contact_id: null }).eq("id", vendorData.id); vendorId = await resolveOrCreateVendorId(); } else { throw e; } } } else { vendorId = await resolveOrCreateVendorId(); } if (!vendorId) throw new Error("Could not resolve Zoho vendor for this bill"); const accountId = await resolveZohoBillExpenseAccountId(db, bill.expense_account_id, orgId); const rawInvoiceLineItems = Array.isArray(sourceInvoice?.line_items) ? sourceInvoice.line_items : []; const invoiceLineItems = rawInvoiceLineItems .map((item: any) => { const quantity = Number(item?.quantity ?? 1); const amount = Number(item?.amount ?? item?.unit_price ?? 0); const rate = item?.unit_price != null ? Number(item.unit_price) : (quantity > 0 ? amount / quantity : amount); const description = item?.description || item?.name || sourceInvoice?.description || bill.description || "Bill"; return { description, quantity: Number.isFinite(quantity) && quantity > 0 ? quantity : 1, rate: Number.isFinite(rate) ? rate : 0, account_id: accountId || undefined, }; }) .filter((item: any) => item.description || item.rate); const fallbackLineItems = [{ description: sourceInvoice?.description || bill.description || "Bill", rate: Number(sourceInvoice?.amount ?? bill.amount) || 0, quantity: 1, account_id: accountId || undefined, }]; // Zoho rejects bill_number with disallowed characters, empty strings, or values that are too long. // Sanitize: trim, replace whitespace and unsupported chars with "-", cap at 100 chars. const rawBillNumber = sourceInvoice?.invoice_number || bill.invoice_number || ""; const sanitizedBillNumber = String(rawBillNumber) .trim() .replace(/[^A-Za-z0-9\-_/.]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 100); const finalBillNumber = sanitizedBillNumber || `BILL-${bill.id.slice(0, 8)}`; const billPayload: any = { vendor_id: vendorId, bill_number: finalBillNumber, date: sourceInvoice?.issue_date || bill.bill_date || new Date().toISOString().slice(0, 10), due_date: sourceInvoice?.due_date || bill.due_date || undefined, line_items: invoiceLineItems.length > 0 ? invoiceLineItems : fallbackLineItems, notes: sourceInvoice?.vendor_name || bill.notes || undefined, }; const reportingTags = await resolveReportingTags(db, bill.association_id, orgId); if (reportingTags.length > 0) { billPayload.line_items = billPayload.line_items.map((item: any) => ({ ...item, tags: reportingTags })); } try { const res = await zohoRequest("/bills", { method: "POST", organizationId: orgId, body: billPayload }); const zohoBillId = res?.bill?.bill_id; if (zohoBillId) await db.from("bills").update({ zoho_bill_id: String(zohoBillId) } as any).eq("id", bill.id); return { status: "created", zoho_bill_id: zohoBillId }; } catch (e: any) { const msg = e?.message || String(e); // Handle duplicate bill number error from Zoho — auto-suffix and retry if (msg.includes("13011") || msg.includes("already been created")) { console.warn("Zoho duplicate bill — retrying with suffix:", msg); const rawBase = String(billPayload.bill_number || `BILL-${bill.id.slice(0, 8)}`).trim(); // Strip any prior "-N" suffix so we don't compound them, and cap length so Zoho accepts it const baseNumber = (rawBase.replace(/-\d+$/, "") || `BILL-${bill.id.slice(0, 8)}`).slice(0, 90); for (let attempt = 2; attempt <= 10; attempt++) { const candidate = `${baseNumber}-${attempt}`; try { const retryRes = await zohoRequest("/bills", { method: "POST", organizationId: orgId, body: { ...billPayload, bill_number: candidate }, }); const retryBillId = retryRes?.bill?.bill_id; if (retryBillId) await db.from("bills").update({ zoho_bill_id: String(retryBillId) } as any).eq("id", bill.id); console.log(`Zoho bill created with suffixed number ${candidate}`); return { status: "created", zoho_bill_id: retryBillId, used_bill_number: candidate }; } catch (retryErr: any) { const retryMsg = retryErr?.message || String(retryErr); if (retryMsg.includes("13011") || retryMsg.includes("already been created")) continue; throw retryErr; } } return { status: "duplicate_in_zoho", message: `Could not find unused bill number after retries (base: ${baseNumber})` }; } // Vendor 404 from /bills — clear stale ID, re-resolve, retry once if ((msg.includes("1002") || msg.includes("not accessible")) && msg.toLowerCase().includes("vendor")) { console.warn(`Vendor ${vendorId} rejected by /bills; clearing and re-resolving.`); if (vendorData?.id) await db.from("vendors").update({ zoho_contact_id: null }).eq("id", vendorData.id); const newVendorId = await resolveOrCreateVendorId(); if (newVendorId && newVendorId !== vendorId) { billPayload.vendor_id = newVendorId; const res2 = await zohoRequest("/bills", { method: "POST", organizationId: orgId, body: billPayload }); const zohoBillId2 = res2?.bill?.bill_id; if (zohoBillId2) await db.from("bills").update({ zoho_bill_id: String(zohoBillId2) } as any).eq("id", bill.id); return { status: "created", zoho_bill_id: zohoBillId2 }; } } throw e; } } // ── Push a vendor payment for a bill (records check info on the bill in Zoho) ── async function pushBillPaymentToZoho(params: { bill_id: string }) { const db = getServiceClient(); const { data: bill, error } = await db .from("bills") .select("*, vendors(id, name, zoho_contact_id), associations(zoho_organization_id), checks:check_id(id, check_number, check_date, payee, memo, bank_account_id, bank_accounts:bank_account_id(account_name))") .eq("id", params.bill_id) .single(); if (error || !bill) throw new Error("Bill not found"); if ((bill as any).zoho_payment_id) { return { status: "already_synced", zoho_payment_id: (bill as any).zoho_payment_id }; } if (!(bill as any).zoho_bill_id) { // Push the bill first so we have a Zoho bill id to attach the payment to await pushBillToZoho({ bill_id: bill.id }); const { data: refreshed } = await db.from("bills").select("zoho_bill_id").eq("id", bill.id).single(); (bill as any).zoho_bill_id = refreshed?.zoho_bill_id; if (!(bill as any).zoho_bill_id) throw new Error("Could not push bill to Zoho before recording payment"); } const assocData = (bill as any).associations as any; const orgId = assocData?.zoho_organization_id || await getOrgIdForAssociation(bill.association_id); if (!orgId) throw new Error("No Zoho organization configured for this association"); const vendorData = (bill as any).vendors as any; const vendorZohoId = vendorData?.zoho_contact_id; if (!vendorZohoId) throw new Error("Vendor is not linked to a Zoho contact yet"); const checkData = (bill as any).checks as any; const paymentMode = checkData ? "check" : (bill as any).payment_method || "check"; const referenceNumber = checkData?.check_number || (bill as any).invoice_number || undefined; const paymentDate = checkData?.check_date || (bill as any).paid_date || new Date().toISOString().slice(0, 10); const description = checkData ? `Check #${checkData.check_number || ""}${checkData.bank_accounts?.account_name ? ` (${checkData.bank_accounts.account_name})` : ""}${checkData.memo ? ` — ${checkData.memo}` : ""}`.trim() : (bill as any).description || "Bill payment"; const amount = Number((bill as any).amount) || 0; const paymentPayload: any = { vendor_id: String(vendorZohoId), bills: [{ bill_id: String((bill as any).zoho_bill_id), amount_applied: amount }], payment_mode: paymentMode, amount, date: paymentDate, description, }; if (referenceNumber) paymentPayload.reference_number = String(referenceNumber); // Record the vendor payment in the banking journal by paying out of the bank account used for the check const paidThroughAccountId = await resolveZohoBankAccountId( db, (bill as any).association_id, orgId, checkData?.bank_account_id || null, ); if (paidThroughAccountId) paymentPayload.paid_through_account_id = paidThroughAccountId; const res = await zohoRequest("/vendorpayments", { method: "POST", organizationId: orgId, body: paymentPayload, }); const zohoPaymentId = res?.vendorpayment?.payment_id || res?.payment?.payment_id; if (zohoPaymentId) { await db .from("bills") .update({ zoho_payment_id: String(zohoPaymentId) } as any) .eq("id", (bill as any).id); } } // ── Delete a vendor payment in Zoho when a bill is reverted to unpaid ── async function deleteBillPaymentFromZoho(params: { bill_id: string }) { const db = getServiceClient(); const { data: bill, error } = await db .from("bills") .select("id, association_id, zoho_bill_id, zoho_payment_id, associations(zoho_organization_id)") .eq("id", params.bill_id) .single(); if (error || !bill) throw new Error("Bill not found"); const zohoPaymentId = (bill as any).zoho_payment_id; if (!zohoPaymentId) { return { status: "not_synced", message: "No Zoho payment recorded for this bill" }; } const assocData = (bill as any).associations as any; const orgId = assocData?.zoho_organization_id || await getOrgIdForAssociation(bill.association_id); if (!orgId) throw new Error("No Zoho organization configured for this association"); try { await zohoRequest(`/vendorpayments/${zohoPaymentId}`, { method: "DELETE", organizationId: orgId, }); } catch (e: any) { const msg = e?.message || String(e); // Treat "not found" errors as already deleted if (msg.includes("not found") || msg.includes("invalid") || msg.includes("404")) { await db.from("bills").update({ zoho_payment_id: null } as any).eq("id", bill.id); return { status: "already_deleted", message: msg }; } throw e; } await db.from("bills").update({ zoho_payment_id: null } as any).eq("id", bill.id); return { status: "deleted", zoho_payment_id: zohoPaymentId }; } // ── Pull bills from Zoho → local bills table ── async function pullBillsFromZoho() { const db = getServiceClient(); const { data: assocs } = await db.from("associations").select("id, zoho_organization_id"); const globalOrgId = Deno.env.get("ZOHO_ORGANIZATION_ID"); const orgAssocMap = new Map(); for (const a of assocs || []) { const org = a.zoho_organization_id || globalOrgId || ""; if (!org) continue; if (!orgAssocMap.has(org)) orgAssocMap.set(org, []); orgAssocMap.get(org)!.push(a.id); } let totalCreated = 0, totalUpdated = 0, totalSkipped = 0, totalBills = 0; for (const [orgId, assocIds] of orgAssocMap.entries()) { let page = 1, hasMore = true; while (hasMore) { const res = await zohoRequest("/bills", { organizationId: orgId, query: { page, per_page: 200 } }); const bills = res?.bills || []; totalBills += bills.length; for (const zBill of bills) { const zohoBillId = String(zBill.bill_id); const { data: existing } = await (db.from("bills") as any).select("id").eq("zoho_bill_id", zohoBillId).maybeSingle(); if (existing) { // Backfill bill_approvals if missing for this existing bill const { data: existingApproval } = await db.from("bill_approvals").select("id").eq("invoice_id", existing.id).maybeSingle(); if (!existingApproval) { const associationId = assocIds[0] || null; if (associationId) { await db.from("bill_approvals").insert({ association_id: associationId, vendor_name: zBill.vendor_name || "Unknown Vendor", amount: Number(zBill.total) || 0, invoice_id: existing.id, status: zBill.status === "paid" ? "approved" : "pending", notes: `Pulled from Zoho Books (Bill #${zBill.bill_number || zohoBillId})`, }); } } totalUpdated++; continue; } let vendorId: string | null = null; const vendorZohoId = String(zBill.vendor_id || ""); if (vendorZohoId) { const { data: vendor } = await db.from("vendors").select("id").eq("zoho_contact_id", vendorZohoId).maybeSingle(); if (vendor) vendorId = vendor.id; } const associationId = assocIds[0] || null; if (!associationId) { totalSkipped++; continue; } const amount = Number(zBill.total) || 0; if (amount <= 0) { totalSkipped++; continue; } const { data: newBill } = await db.from("bills").insert({ association_id: associationId, vendor_id: vendorId, invoice_number: zBill.bill_number || null, bill_date: zBill.date || new Date().toISOString().slice(0, 10), due_date: zBill.due_date || null, amount, description: zBill.line_items?.[0]?.description || `Zoho Bill #${zBill.bill_number || zohoBillId}`, status: zBill.status === "paid" ? "paid" : "pending", notes: zBill.vendor_name || null, zoho_bill_id: zohoBillId, } as any).select("id").single(); // Auto-create a bill_approvals record so it appears in Bill Approvals if (newBill) { await db.from("bill_approvals").insert({ association_id: associationId, vendor_name: zBill.vendor_name || "Unknown Vendor", amount, invoice_id: newBill.id, status: zBill.status === "paid" ? "approved" : "pending", notes: `Pulled from Zoho Books (Bill #${zBill.bill_number || zohoBillId})`, }); } totalCreated++; } hasMore = res?.page_context?.has_more_page === true; page++; } } return { total: totalBills, created: totalCreated, updated: totalUpdated, skipped: totalSkipped }; } // ── Bulk push all unsynced bills ── async function pushAllBillsToZoho() { const db = getServiceClient(); const { data: bills } = await db.from("bills").select("id").is("zoho_bill_id" as any, null); let pushed = 0, errors = 0; for (const bill of bills || []) { try { await pushBillToZoho({ bill_id: bill.id }); pushed++; } catch (e) { console.error(`Failed to push bill ${bill.id}:`, e); errors++; } } return { pushed, errors }; } // ── Bulk push all unsynced charges and payments ── async function pushAllInvoicesToZoho() { const db = getServiceClient(); const { data: entries } = await db .from("owner_ledger_entries") .select("id") .gt("debit", 0) .is("zoho_invoice_id", null); let pushed = 0, errors = 0; for (const entry of entries || []) { try { await pushInvoiceToZoho({ ledger_entry_id: entry.id }); pushed++; } catch (e) { console.error(`Failed to push invoice for entry ${entry.id}:`, e); errors++; } } return { pushed, errors }; } async function pushAllPaymentsToZoho() { const db = getServiceClient(); const { data: payments } = await db .from("admin_payments") .select("id") .is("zoho_payment_id", null); let pushed = 0, errors = 0; for (const pmt of payments || []) { try { await pushPaymentToZoho({ payment_id: pmt.id }); pushed++; } catch (e) { console.error(`Failed to push payment ${pmt.id}:`, e); errors++; } } return { pushed, errors }; } // ── List available Zoho orgs (for mapping UI) ── async function listZohoOrganizations() { const res = await zohoRequest("/organizations", { includeOrganizationId: false }); return res?.organizations || []; } Deno.serve(async (req) => { if (req.method === "OPTIONS") { return new Response(null, { headers: corsHeaders }); } try { const user = await getAuthenticatedUser(req); if (!user) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } const { action, params } = await req.json(); let result: unknown; // For passthrough actions that need an org, resolve from params.association_id const resolveParamsOrgId = async () => { if (params?.organization_id) return params.organization_id; if (params?.association_id) return await getOrgIdForAssociation(params.association_id); return Deno.env.get("ZOHO_ORGANIZATION_ID"); }; switch (action) { case "test_connection": { const organizations = await listZohoOrganizations(); const configuredOrgId = Deno.env.get("ZOHO_ORGANIZATION_ID"); const matchedOrganization = organizations.find( (org: { organization_id?: string | number }) => String(org.organization_id) === String(configuredOrgId) ); if (configuredOrgId && organizations.length > 0 && !matchedOrganization) { return new Response( JSON.stringify({ error: "Zoho connected, but the configured Organization ID does not match any organization available to this token.", details: organizations.map((org: { name?: string; organization_id?: string | number }) => ({ name: org.name, organization_id: org.organization_id, })), }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, } ); } result = { organizations, organization: matchedOrganization || organizations[0] || null, }; break; } case "list_organizations": result = await listZohoOrganizations(); break; case "pull_contacts": result = await pullContactsFromZoho(); break; case "push_contacts": result = await pushContactsToZoho(); break; case "push_contacts_company": result = await pushContactsToZohoCompany(); break; case "push_invoice": result = await pushInvoiceToZoho(params); break; case "push_client_invoice": result = await pushClientInvoiceToZoho(params); break; case "push_all_client_invoices": result = await pushAllClientInvoicesToZoho(params); break; case "push_payment": result = await pushPaymentToZoho(params); break; case "push_ledger_payment": result = await pushLedgerPaymentToZoho(params); break; case "push_bill": result = await pushBillToZoho(params); break; case "push_bill_company": result = await pushBillToZoho({ ...params, force_company_org: true }); break; case "push_bill_payment": result = await pushBillPaymentToZoho(params); break; case "delete_bill_payment": result = await deleteBillPaymentFromZoho(params); break; case "pull_bills": result = await pullBillsFromZoho(); break; case "push_all_bills": result = await pushAllBillsToZoho(); break; case "pull_invoices": result = await pullInvoicesFromZoho(); break; case "pull_payments": result = await pullPaymentsFromZoho(); break; case "push_all_invoices": result = await pushAllInvoicesToZoho(); break; case "push_all_payments": result = await pushAllPaymentsToZoho(); break; case "sync_financials": { const pullInv = await pullInvoicesFromZoho(); const pullPmt = await pullPaymentsFromZoho(); const pullBills = await pullBillsFromZoho(); const pushInv = await pushAllInvoicesToZoho(); const pushPmt = await pushAllPaymentsToZoho(); const pushBills = await pushAllBillsToZoho(); result = { pull_invoices: pullInv, pull_payments: pullPmt, pull_bills: pullBills, push_invoices: pushInv, push_payments: pushPmt, push_bills: pushBills }; break; } // ── Passthrough API calls (use per-association org when available) ── case "list_contacts": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/contacts", { organizationId: orgId, query: params?.query }); break; } case "get_contact": { const orgId = await resolveParamsOrgId(); result = await zohoRequest(`/contacts/${params.contact_id}`, { organizationId: orgId }); break; } case "list_invoices": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/invoices", { organizationId: orgId, query: params?.query }); break; } case "get_invoice": { const orgId = await resolveParamsOrgId(); result = await zohoRequest(`/invoices/${params.invoice_id}`, { organizationId: orgId }); break; } case "list_bills": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/bills", { organizationId: orgId, query: params?.query }); break; } case "get_bill": { const orgId = await resolveParamsOrgId(); result = await zohoRequest(`/bills/${params.bill_id}`, { organizationId: orgId }); break; } case "list_chartofaccounts": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/chartofaccounts", { organizationId: orgId }); break; } case "list_bank_transactions": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/banktransactions", { organizationId: orgId, query: params?.query }); break; } case "list_bank_accounts": { // List bank/credit-card accounts from Zoho Books for the resolved org const orgId = await resolveParamsOrgId(); const res = await zohoRequest("/bankaccounts", { organizationId: orgId }); result = res?.bankaccounts || res?.bank_accounts || []; break; } case "list_journals": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/journals", { organizationId: orgId, query: params?.query }); break; } case "create_contact": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/contacts", { method: "POST", organizationId: orgId, body: params.data }); break; } case "create_invoice": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/invoices", { method: "POST", organizationId: orgId, body: params.data }); break; } case "create_bill": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/bills", { method: "POST", organizationId: orgId, body: params.data }); break; } case "list_reporting_tags": { const orgId = await resolveParamsOrgId(); result = await listReportingTags(orgId); break; } case "save_reporting_tag_mapping": { const db2 = getServiceClient(); const { association_id, zoho_tag_id, zoho_tag_option_id, zoho_tag_name, zoho_option_name } = params; // Upsert: delete existing for this association+tag, then insert await db2.from("zoho_reporting_tag_mappings") .delete() .eq("association_id", association_id) .eq("zoho_tag_id", zoho_tag_id); const { error: insertErr } = await db2.from("zoho_reporting_tag_mappings").insert({ association_id, zoho_tag_id, zoho_tag_option_id, zoho_tag_name: zoho_tag_name || null, zoho_option_name: zoho_option_name || null, }); if (insertErr) throw new Error(insertErr.message); result = { status: "saved" }; break; } case "get_reporting_tag_mappings": { const db3 = getServiceClient(); const { data: mappings } = await db3.from("zoho_reporting_tag_mappings") .select("*") .eq("association_id", params.association_id); result = mappings || []; break; } // ── Financial Reports from Zoho ── case "get_profit_and_loss": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/reports/profitandloss", { organizationId: orgId, query: { from_date: params.from_date, to_date: params.to_date, ...(params.cash_based !== undefined ? { cash_based: params.cash_based } : {}), }, }); break; } case "get_balance_sheet": { const orgId = await resolveParamsOrgId(); result = await zohoRequest("/reports/balancesheet", { organizationId: orgId, query: { date: params.date || params.to_date }, }); break; } case "get_ar_aging": { const orgId = await resolveParamsOrgId(); const reportDate = params.date || params.to_date || new Date().toISOString().split("T")[0]; // Zoho's /reports/receivabledetails requires an entity_list ("module") parameter // identifying which transaction modules to include in the aging report. // Valid modules: invoices, creditnotes, customer_payments, retainerinvoices. // Try multiple parameter combinations to handle Zoho API variations across editions const attempts = [ { entity_list: "invoice,creditnote,customer_payment", aging_by: "due_date", interval_range: 30, number_of_columns: 4, group_by: "none", show_by: "customers" }, { entity_list: "invoice", aging_by: "due_date" }, { entity_list: "invoice,creditnote", aging_by: "due_date" }, { aging_by: "due_date" }, {}, ]; let lastErr: unknown = null; result = null; for (const extra of attempts) { try { result = await zohoRequest("/reports/aragingsummary", { organizationId: orgId, query: { to_date: reportDate, ...extra, ...(params.customer_id ? { contact_id: params.customer_id } : {}), }, }); break; } catch (e) { lastErr = e; console.warn("[get_ar_aging] attempt failed:", JSON.stringify(extra), e instanceof Error ? e.message : e); } } if (!result) throw lastErr ?? new Error("All A/R aging attempts failed"); break; } case "pull_budgets": { // Pull budgets from Zoho Books → upsert into local budgets table. // Strategy: pull P&L by account for the requested fiscal year(s) — Zoho Books // does not expose a stable public Budgets API endpoint across all editions, so // we derive budget actuals from the per-account P&L report, and pull budgeted // amounts from Zoho /budgets when available. const db = getServiceClient(); const associationId: string | undefined = params?.association_id; if (!associationId) throw new Error("association_id is required"); const orgId = await getOrgIdForAssociation(associationId); if (!orgId) throw new Error("Association is not linked to a Zoho organization"); const fiscalYear: number = Number(params?.fiscal_year) || new Date().getFullYear(); const fromDate = `${fiscalYear}-01-01`; const toDate = `${fiscalYear}-12-31`; let imported = 0; let updated = 0; let skippedNoAmount = 0; // Try Zoho's Budgets API first let zohoBudgetLines: Array<{ account_name: string; budgeted: number; actual: number }> = []; try { const budgetsRes = await zohoRequest("/budgets", { organizationId: orgId }); const budgets = budgetsRes?.budgets || []; // Find a budget matching fiscal year if any const matching = budgets.find((b: any) => String(b?.from_date || "").startsWith(String(fiscalYear)) || Number(b?.fiscal_year) === fiscalYear ) || budgets[0]; if (matching?.budget_id) { const detail = await zohoRequest(`/budgets/${matching.budget_id}`, { organizationId: orgId }); const accounts = detail?.budget?.accounts || detail?.accounts || []; zohoBudgetLines = accounts.map((a: any) => ({ account_name: String(a.account_name || a.name || "Uncategorized"), budgeted: Number(a.total_budget_amount || a.budget_amount || a.amount || 0), actual: Number(a.total_actual_amount || a.actual_amount || 0), })); } } catch (e) { console.warn("[pull_budgets] /budgets endpoint not available:", (e as Error).message); } // Fallback / enrichment: derive actuals from P&L if (zohoBudgetLines.length === 0) { try { const pl = await zohoRequest("/reports/profitandloss", { organizationId: orgId, query: { from_date: fromDate, to_date: toDate }, }); const sections = pl?.profit_and_loss || pl?.sections || []; const flatten = (nodes: any[]): any[] => { const out: any[] = []; for (const n of nodes || []) { if (n?.account_name) out.push(n); if (Array.isArray(n?.account_transactions)) out.push(...flatten(n.account_transactions)); if (Array.isArray(n?.accounts)) out.push(...flatten(n.accounts)); } return out; }; const flat = flatten(sections); zohoBudgetLines = flat .filter((a: any) => a.account_name) .map((a: any) => ({ account_name: String(a.account_name), budgeted: 0, actual: Math.abs(Number(a.total || a.amount || 0)), })); } catch (e) { console.warn("[pull_budgets] P&L fallback failed:", (e as Error).message); } } // Upsert into budgets table by (association_id, fiscal_year, category) for (const line of zohoBudgetLines) { if (!line.account_name) continue; if (line.budgeted === 0 && line.actual === 0) { skippedNoAmount++; continue; } const { data: existing } = await db .from("budgets") .select("id") .eq("association_id", associationId) .eq("fiscal_year", fiscalYear) .eq("category", line.account_name) .maybeSingle(); if (existing?.id) { await db.from("budgets").update({ budgeted_amount: line.budgeted, actual_amount: line.actual, updated_at: new Date().toISOString(), }).eq("id", existing.id); updated++; } else { await db.from("budgets").insert({ association_id: associationId, fiscal_year: fiscalYear, category: line.account_name, budgeted_amount: line.budgeted, actual_amount: line.actual, notes: "Imported from Zoho Books", }); imported++; } } result = { source: "zoho", fiscal_year: fiscalYear, fetched: zohoBudgetLines.length, imported, updated, skipped: skippedNoAmount, }; break; } default: return new Response(JSON.stringify({ error: `Unknown action: ${action}` }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } return new Response(JSON.stringify({ data: result }), { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } catch (err) { console.error("Zoho Books edge function error:", err); return new Response( JSON.stringify({ error: err instanceof Error ? err.message : "Internal server error" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, } ); } });