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