Files
acmcc/supabase/functions/zoho-books/index.ts
T
2026-06-01 20:19:26 -04:00

2703 lines
101 KiB
TypeScript

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<string, string | number | boolean | undefined>;
}
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<string> {
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<any> {
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<any> {
return zohoRequestWithBaseUrl(endpoint, options, getZohoApiBaseUrl());
}
function withInvoiceUpdateReason<T extends Record<string, unknown>>(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<typeof getServiceClient>,
expenseAccountId: string | null | undefined,
organizationId: string
): Promise<string | undefined> {
// 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<typeof getServiceClient>,
associationId: string | null | undefined,
organizationId: string,
preferredBankAccountId?: string | null,
): Promise<string | undefined> {
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<any> {
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<string | undefined> {
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<string, string[]>(); // 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<string | null> {
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<string, string> = {};
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<typeof getServiceClient>,
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<any> {
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<any[]> {
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<typeof getServiceClient>,
associationId: string,
orgId: string | undefined
): Promise<Array<{ tag_id: string; tag_option_id: string }>> {
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<string, number> = {
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<typeof getServiceClient>,
customerId: string,
paymentAmount: number,
orgId: string | undefined
): Promise<Array<{ invoice_id: string; amount_applied: number }>> {
// 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<string, string>();
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<string>();
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<string>();
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<string | null> {
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<string, string[]>();
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" },
}
);
}
});