For Zoho Services only:


I'm actually part of something bigger at Ascent Business Solutions recognized as the top Zoho Premium Solutions Partner in the United Kingdom.

Ascent Business Solutions offer support for smaller technical fixes and projects for larger developments, such as migrating to a ZohoCRM.  A team rather than a one-man-band is always available to ensure seamless progress and address any concerns. You'll find our competitive support rates with flexible, no-expiration bundles at https://ascentbusiness.co.uk/zoho-services/uk-zoho-support.  For larger projects, talk to our experts and receive dedicated support from our hands-on project consultants at https://ascentbusiness.co.uk/zoho-services/zoho-crm-implementation.

The team I manage specializes in coding API integrations between Zoho and third-party finance/commerce suites such as Xero, Shopify, WooCommerce, and eBay; to name but a few.  Our passion lies in creating innovative solutions where others have fallen short as well as working with new businesses, new sectors, and new ideas.  Our success is measured by the growth and ROI we deliver for clients, such as transforming a garden shed hobby into a 250k monthly turnover operation or generating a +60% return in just three days after launch through online payments and a streamlined e-commerce solution, replacing a paper-based system.

If you're looking for a partner who can help you drive growth and success, we'd love to work with you.  You can reach out to us on 0121 392 8140 (UK) or info@ascentbusiness.co.uk.  You can also visit our website at https://ascentbusiness.co.uk.
ZohoCRM & Xero: Function to pull most recent invoices

ZohoCRM & Xero: Function to pull most recent invoices

What?
Thought I already had an article on this and I know my article Zoho Deluge - Connect to Xero API covered a quick query to pull some invoices but this one documents a pull and mapping into CRM invoices.

Why?
This took me a whole afternoon so I wanted a template function I could use in future when I get this request again. The additional benefit of having this template is that it includes creating contacts, accounts, and products on-the-fly as well as recording payments and checks as to which record is more up to date between ZohoCRM and Xero.

How?
The access token is generated by a function documented in my previously mentioned article Zoho Deluge - Connect to Xero API so here's the pull of the first page of invoices by most recent first (invoice date).
There is also a table that returns the name of tax rates and then the percent that these equate to which I've copied below this invoices pull function.

the Code
copyraw
string standalone.fn_Xero_GetInvoices()
{
/* *******************************************************************************
	Function:       string standalone.fn_Xero_GetInvoices()
	Label:          Fn - Xero - Get Invoices
	Trigger:        Standalone / On-Demand / Callable
	Purpose:	 	Function to get the first page of most recent invoices from Xero and pull them into ZohoCRM
	Inputs:         -
	Outputs:        -

	Date Created:   2025-10-08 (Joel Lipman)
					- Initial release
	Date Modified:	2025-10-13 ( Joel Lipman)
					- Parsing Xero Dates and Times to include timezone of Xero instance for accurate time comparisons
					- Map Xero tax rates to Zoho tax rates (same names in Zoho)
					- output to console if debug mode enabled

	More Information:
					http://www.joellipman.com/articles/crm/zoho/zoho-deluge-sync-to-xero-api.html

	******************************************************************************* */
	//
	// init
	v_OutputMessage = "ERROR: No Access Token or Tenant Connection specified.";
	v_Count_FoundInXero = 0;
	v_Count_Created = 0;
	v_Count_Updated = 0;
	v_AccessToken = "";
	l_Pages = {1};
	v_PageSize = 20;
	b_Debug = false;
	//
	// Xero Invoice Statuses vs your CRM Invoice Statuses
	m_TranslateStatuses = Map();
	m_TranslateStatuses.put("DRAFT","Draft");
	m_TranslateStatuses.put("SUBMITTED","Pending Approval");
	m_TranslateStatuses.put("AUTHORISED","Sent to Customer");
	m_TranslateStatuses.put("PAID","Paid in Full");
	m_TranslateStatuses.put("DELETED","Cancelled");
	m_TranslateStatuses.put("VOIDED","Cancelled");
	//
	// Xero Tax Rates translated to Zoho Tax Rate (manual reference)
	r_ZohoTaxRates = standalone.fn_Xero_MapTaxRates();
	m_ZohoTaxRates = r_ZohoTaxRates.toMap();
	if(b_Debug)
	{
		info m_ZohoTaxRates;
	}
	//
	// server timezones
	v_XeroTimeZone = "Europe/London";
	v_ZohoTimeZone = "Europe/London";
	//
	// enter the CRM record ID of your integrations record (Xero Integration API)
	v_TokenCrmID = 123456000000789012;
	r_TokenDetails = zoho.crm.getRecordById("Integrations",v_TokenCrmID);
	v_DataEndpoint = ifnull(r_TokenDetails.get("Data_Endpoint"),"");
	v_TenantID = ifnull(r_TokenDetails.get("Tenant_ID"),"");
	//
	// get access token (does not need REST API url as we're calling it from within CRM)
	v_AccessToken = standalone.fn_API_GetXeroAccessToken();
	//
	// do Xero stuff
	if(v_AccessToken != "" && v_TenantID != "")
	{
		// set header
		m_Header = Map();
		m_Header.put("Authorization","Bearer " + v_AccessToken);
		m_Header.put("Accept","application/json");
		m_Header.put("Xero-tenant-id",v_TenantID);
		//
		// get Xero invoices (page 1 - first 100 - default order is updated date)
		for each  v_Page in l_Pages
		{
			m_Params = Map();
			m_Params.put("page",v_Page);
			//
			// keep the page size low as this function will be creating contacts and products if required
			m_Params.put("pageSize",v_PageSize);
			//
			// order by date descending (most recent first) - sometimes need to use Date%20DESC
			m_Params.put("order","UpdatedDateUTC DESC");
			//
			// get the first page of Xero invoices
			v_FilterReceivables = "?where=" + zoho.encryption.urlEncode("Type=\"ACCREC\"");
			r_AllXeroInvoices = invokeurl
			[
				url :v_DataEndpoint + "/Invoices" + v_FilterReceivables
				type :GET
				parameters:m_Params
				headers:m_Header
			];
			if(b_Debug)
			{
				info r_AllXeroInvoices;
			}
			if(!isnull(r_AllXeroInvoices.get("Invoices")))
			{
				for each  m_ThisInvoice in r_AllXeroInvoices.get("Invoices")
				{
					if(!isnull(m_ThisInvoice.get("InvoiceID")))
					{
						//
						// counter
						v_Count_FoundInXero = v_Count_FoundInXero + 1;
						//
						// Xero Invoice identifier
						v_XeroInvoiceID = m_ThisInvoice.get("InvoiceID");
						//
						m_UpsertCrmInvoice = Map();
						m_UpsertCrmInvoice.put("Subject",m_ThisInvoice.get("InvoiceNumber"));
						//
						// some standard CRM invoice fields we can populate
						v_CrmInvoiceStatus = m_TranslateStatuses.get(m_ThisInvoice.get("Status"));
						if(m_ThisInvoice.get("Status") == "PAID")
						{
							v_InvoiceTotal = m_ThisInvoice.get("Total");
							v_PaidTotal = 0.0;
							// we have a partially paid status in crm so let's check those payments
							for each  m_XeroPayment in m_ThisInvoice.get("Payments")
							{
								if(!isNull(m_XeroPayment.get("PaymentID")))
								{
									v_PaidTotal = v_PaidTotal + m_XeroPayment.get("Amount");
								}
							}
							v_CrmInvoiceStatus = if(v_PaidTotal == v_InvoiceTotal,"Paid in Full","Partially Paid");
						}
						m_UpsertCrmInvoice.put("Status",v_CrmInvoiceStatus);
						v_XeroInvoiceDate = m_ThisInvoice.get("Date");
						d_XeroInvoiceDate = v_XeroInvoiceDate.replaceAll("/Date\((\d+)([+-]\d{4})?\)/","$1",false).toLong().toTime("yyyy-MM-dd HH:mm:ss",v_XeroTimeZone);
						m_UpsertCrmInvoice.put("Invoice_Date",d_XeroInvoiceDate.toString("yyyy-MM-dd"));
						v_XeroInvoiceDueDate = m_ThisInvoice.get("DueDate");
						d_XeroInvoiceDueDate = v_XeroInvoiceDueDate.replaceAll("/Date\((\d+)([+-]\d{4})?\)/","$1",false).toLong().toTime("yyyy-MM-dd HH:mm:ss",v_XeroTimeZone);
						m_UpsertCrmInvoice.put("Due_Date",d_XeroInvoiceDueDate.toString("yyyy-MM-dd"));
						m_UpsertCrmInvoice.put("Currency",m_ThisInvoice.get("CurrencyCode"));
						//
						// some custom fields I created in CRM to store the data
						m_UpsertCrmInvoice.put("Xero_Ref_ID",m_ThisInvoice.get("InvoiceID"));
						m_UpsertCrmInvoice.put("Xero_Updated",zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss",v_ZohoTimeZone));
						m_UpsertCrmInvoice.put("Amount_Paid",m_ThisInvoice.get("AmountPaid"));
						m_UpsertCrmInvoice.put("Amount_Credited",m_ThisInvoice.get("AmountCredited"));
						if(!isNull(m_ThisInvoice.get("FullyPaidOnDate")))
						{
							v_XeroFullyPaidDate = m_ThisInvoice.get("FullyPaidOnDate");
							d_XeroFullyPaidDate = v_XeroFullyPaidDate.replaceAll("/Date\((\d+)([+-]\d{4})?\)/","$1",false).toLong().toTime("yyyy-MM-dd HH:mm:ss",v_XeroTimeZone);
							m_UpsertCrmInvoice.put("Date_Fully_Paid",d_XeroFullyPaidDate.toString("yyyy-MM-dd"));
						}
						m_UpsertCrmInvoice.put("Reference",m_ThisInvoice.get("Reference"));
						//
						// -------------------------------- Invoice Customer --------------------------------
						//
						// initialize
						v_CrmAccountID = "";
						v_CrmContactID = "";
						v_CrmPhone = "";
						v_CrmMobile = "";
						b_CreateAccount = true;
						b_CreateContact = true;
						//
						// set date/time of account last sync'd to Xero (100 years ago by default - so that it will be oldest)
						d_CrmAccountLastUpdated = zoho.currenttime.toString("yyyy-MM-dd HH:mm:ss").toTime().subYear(100);
						v_XeroContactID = m_ThisInvoice.get("Contact").get("ContactID");
						v_XeroContactName = m_ThisInvoice.get("Contact").get("Name");
						//
						// search CRM for this account/customer
						l_SearchAccounts = zoho.crm.searchRecords("Accounts","Xero_Ref_ID:equals:" + v_XeroContactID,1,2,{"approved":"both","converted":"both"});
						for each  m_SearchAccount in l_SearchAccounts
						{
							if(!isNull(m_SearchAccount.get("id")))
							{
								b_CreateAccount = false;
								v_CrmAccountID = m_SearchAccount.get("id");
								//
								// if sync'd before then let's use that date/time
								d_CrmAccountLastUpdated = ifnull(m_SearchAccount.get("Xero_Updated"),zoho.currenttime).toString("yyyy-MM-dd HH:mm:ss",v_ZohoTimeZone).toTime();
								if(b_Debug)
								{
									info "Found CRM Account: " + v_CrmAccountID;
								}
							}
						}
						//
						// get account/contact details from Xero (invoice doesn't necessarily hold the details: address, phone, etc)
						r_XeroContact = invokeurl
						[
							url :v_DataEndpoint + "/Contacts/" + v_XeroContactID
							type :GET
							parameters:m_Params
							headers:m_Header
						];
						l_XeroContacts = ifnull(r_XeroContact.get("Contacts"),List());
						for each  m_XeroContact in l_XeroContacts
						{
							if(!isNull(m_XeroContact.get("ContactID")))
							{
								//
								// to check if we want to update the CRM record for the account
								v_XeroTime = m_XeroContact.get("UpdatedDateUTC");
								d_XeroAccountLastUpdated = v_XeroTime.replaceAll("/Date\((\d+)([+-]\d{4})?\)/","$1",false).toLong().toTime("yyyy-MM-dd HH:mm:ss",v_XeroTimeZone);
								//
								// build upsert for CRM account
								m_CrmAccount = Map();
								m_CrmAccount.put("Account_Name",m_ThisInvoice.get("Contact").get("Name"));
								m_CrmAccount.put("Xero_Ref_ID",m_XeroContact.get("ContactID"));
								m_CrmAccount.put("Xero_Updated",zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss",v_ZohoTimeZone));
								//
								// addresses
								for each  m_XeroAddress in m_XeroContact.get("Addresses")
								{
									if(!isNull(m_XeroAddress.get("AddressLine1")))
									{
										v_XeroAddressLine1 = m_XeroAddress.get("AddressLine1");
										v_XeroAddressLine2 = m_XeroAddress.get("AddressLine2");
										v_XeroAddressCity = m_XeroAddress.get("City");
										v_XeroAddressZip = m_XeroAddress.get("PostalCode");
										v_XeroAddressAttn = m_XeroAddress.get("AttentionTo");
									}
								}
								//
								l_AddressStreet = List({v_XeroAddressLine1});
								if(!isBlank(v_XeroAddressLine2))
								{
									l_AddressStreet.add(v_XeroAddressLine2);
								}
								m_CrmAccount.put("Billing_Street",l_AddressStreet.toString(", "));
								m_CrmAccount.put("Billing_City",v_XeroAddressCity);
								m_CrmAccount.put("Billing_Code",v_XeroAddressZip);
								//
								// loop through phones
								for each  m_XeroPhone in m_XeroContact.get("Phones")
								{
									if(!isNull(m_XeroPhone.get("PhoneNumber")))
									{
										v_XeroPhoneType = m_XeroPhone.get("PhoneType");
										l_XeroFullPhoneNumberParts = List();
										if(!isNull(m_XeroPhone.get("PhoneCountryCode")))
										{
											l_XeroFullPhoneNumberParts.add(m_XeroPhone.get("PhoneCountryCode"));
										}
										if(!isNull(m_XeroPhone.get("PhoneAreaCode")))
										{
											l_XeroFullPhoneNumberParts.add(m_XeroPhone.get("PhoneAreaCode"));
										}
										if(!isNull(m_XeroPhone.get("PhoneNumber")))
										{
											l_XeroFullPhoneNumberParts.add(m_XeroPhone.get("PhoneNumber"));
										}
										v_XeroFullPhoneNumber = l_XeroFullPhoneNumberParts.toString(" ");
										if(v_XeroPhoneType == "DEFAULT" || v_XeroPhoneType == "PHONE")
										{
											v_CrmPhone = v_XeroFullPhoneNumber;
										}
										else if(v_XeroPhoneType == "MOBILE")
										{
											v_CrmMobile = v_XeroFullPhoneNumber;
										}
									}
								}
								m_CrmAccount.put("Phone",v_CrmPhone);
								//
								// balances
								v_XeroReceivables = 0.0;
								v_XeroPayables = 0.0;
								for each  m_XeroBalance in m_XeroContact.get("Balances")
								{
									if(!isNull(m_XeroBalance.get("AccountsReceivable")))
									{
										v_XeroReceivables = m_XeroBalance.get("AccountsReceivable").get("Outstanding");
										v_XeroReceivables = v_XeroReceivables + m_XeroBalance.get("AccountsReceivable").get("Overdue");
										v_XeroReceivables = v_XeroReceivables * -1;
									}
									if(!isNull(m_XeroBalance.get("AccountsPayable")))
									{
										v_XeroPayables = m_XeroBalance.get("AccountsPayable").get("Outstanding");
										v_XeroPayables = v_XeroPayables + m_XeroBalance.get("AccountsReceivable").get("Overdue");
									}
								}
								v_XeroBalance = v_XeroPayables - v_XeroReceivables;
								m_CrmAccount.put("Xero_Balance",v_XeroBalance);
								//
								// create CRM account for other contact records
								if(b_CreateAccount)
								{
									r_CreateAccount = zoho.crm.createRecord("Accounts",m_CrmAccount);
									if(b_Debug)
									{
										info "Creating CRM Account: " + r_CreateAccount;
									}
									if(!isNull(r_CreateAccount.getJSON("id")))
									{
										v_CrmAccountID = r_CreateAccount.get("id");
									}
								}
								//
								// create a contact
								v_SearchContactsCriteria = "Email:equals:" + if(isBlank(m_XeroContact.get("EmailAddress")),"Unknown",m_XeroContact.get("EmailAddress"));
								l_SearchContacts = zoho.crm.searchRecords("Contacts",v_SearchContactsCriteria);
								if(b_Debug)
								{
									info "Searching Contacts (" + v_SearchContactsCriteria + "): " + l_SearchContacts;
								}
								for each  m_SearchContact in l_SearchContacts
								{
									if(!isNull(m_SearchContact.getJSON("id")))
									{
										b_CreateContact = false;
										v_CrmContactID = m_SearchContact.get("id");
										if(b_Debug)
										{
											info "Found CRM Contact: " + v_CrmContactID;
										}
									}
								}
								//
								// build upsert for CRM contact
								m_CrmContact = Map();
								m_CrmContact.put("First_Name",m_XeroContact.get("FirstName"));
								// last name is mandatory for a CRM contact so we're going to put a placeholder one if this is not given
								v_CrmContactLastName = ifnull(m_XeroContact.get("LastName"),"-Unknown-");
								m_CrmContact.put("Last_Name",v_CrmContactLastName);
								m_CrmContact.put("Email",m_XeroContact.get("EmailAddress"));
								m_CrmContact.put("Phone",v_CrmPhone);
								m_CrmContact.put("Mobile",v_CrmMobile);
								m_CrmContact.put("Xero_Ref_ID",m_XeroContact.get("ContactID"));
								m_CrmContact.put("Xero_Updated",zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss",v_ZohoTimeZone));
								m_CrmContact.put("Mailing_Street",l_AddressStreet.toString(", "));
								m_CrmContact.put("Mailing_City",v_XeroAddressCity);
								m_CrmContact.put("Mailing_Zip",v_XeroAddressZip);
								m_CrmContact.put("Account_Name",v_CrmAccountID);
								// last name is mandatory, let's not bother if it wasn't provided
								if(b_CreateContact && v_CrmContactLastName != "-Unknown-")
								{
									r_CreateContact = zoho.crm.createRecord("Contacts",m_CrmContact);
									if(b_Debug)
									{
										info "Creating Primary Contact: " + r_CreateContact;
									}
									if(!isNull(r_CreateContact.get("id")))
									{
										v_CrmContactID = r_CreateContact.get("id");
									}
									//
									// create other contacts (retain the map and only change first name, last name, and email)
									for each  m_OtherContact in m_XeroContact.get("ContactPersons")
									{
										m_CrmContact.put("First_Name",m_OtherContact.get("FirstName"));
										m_CrmContact.put("Last_Name",m_OtherContact.get("LastName"));
										m_CrmContact.put("Email",m_OtherContact.get("EmailAddress"));
										r_CreateContact2 = zoho.crm.createRecord("Contacts",m_CrmContact);
										if(b_Debug)
										{
											info "Creating Secondary Contact: " + r_CreateContact2;
										}
									}
								}
							}
						}
						//
						// if Xero record is more recently updated than the CRM one, then update the account
						if(d_XeroAccountLastUpdated >= d_CrmAccountLastUpdated)
						{
							r_UpdateCrmAccount = zoho.crm.updateRecord("Accounts",v_CrmAccountID,m_CrmAccount);
							r_UpdateCrmContact = zoho.crm.updateRecord("Contacts",v_CrmContactID,m_CrmContact);
						}
						//
						// add account/contact to the invoice
						m_UpsertCrmInvoice.put("Account_Name",v_CrmAccountID);
						m_UpsertCrmInvoice.put("Contact_Name",v_CrmContactID);
						//
						// -------------------------------- Invoice Line Items --------------------------------
						//
						// initializing
						l_CrmLineItems = List();
						//
						// loop through line items on the Xero invoice
						for each  m_XeroLineItem in m_ThisInvoice.get("LineItems")
						{
							//
							// initialize
							v_CrmProductID = "";
							l_CrmProductAvailableTaxes = List();
							l_CrmProductAvailableTaxes.add("NONE");
							//
							// checking this is a valid line item and not an error message by it having an ItemCode
							v_CrmProductName = ifnull(m_XeroLineItem.get("ItemCode"),m_XeroLineItem.get("Description"));
							v_CrmProductName = if(isBlank(v_CrmProductName),ifnull(m_XeroLineItem.get("Item"),{"Name":"Product"}).get("Name"),"Product");
							if(!isBlank(v_CrmProductName))
							{
								v_CrmProductCode = ifnull(m_XeroLineItem.get("ItemCode"),"-Unknown-");
								v_CrmProductCodeSafe = zoho.encryption.urlEncode(v_CrmProductCode);
								v_CrmProductName = if(v_CrmProductName.length() >= 200,v_CrmProductName.subString(0,199),v_CrmProductName);
								v_CrmProductNameSafe = zoho.encryption.urlEncode(v_CrmProductName);
								v_SearchCriteria = "((Product_Code:equals:" + v_CrmProductCodeSafe + ")or(Product_Name:equals:" + v_CrmProductNameSafe + "))";
								l_SearchProducts = zoho.crm.searchRecords("Products",v_SearchCriteria,1,2,{"approved":"both"});
								if(b_Debug)
								{
									info "Searching CRM Products: " + v_SearchCriteria;
								}
								for each  m_SearchProduct in l_SearchProducts
								{
									if(!isNull(m_SearchProduct.get("id")))
									{
										v_CrmProductID = m_SearchProduct.get("id");
										l_CrmProductAvailableTaxes = ifnull(m_SearchProduct.get("Tax"),List());
										if(b_Debug)
										{
											info "Available Taxes: " + l_CrmProductAvailableTaxes;
										}
									}
								}
								//
								// couldn't find it so let's create it
								m_CrmProduct = Map();
								//
								// some companies don't use the product lookup in Xero so you would need a placeholder product from CRM.
								if(!isNull(m_XeroLineItem.get("Item")))
								{
									v_CrmProductName = m_XeroLineItem.get("Item").get("Name");
									m_CrmProduct.put("Xero_Ref_ID",m_XeroLineItem.get("Item").get("ItemID"));
									m_CrmProduct.put("Product_Code",m_XeroLineItem.get("Item").get("Code"));
								}
								m_CrmProduct.put("Product_Name",v_CrmProductName);
								m_CrmProduct.put("Product_Active",true);
								m_CrmProduct.put("Description",m_XeroLineItem.get("Description"));
								m_CrmProduct.put("Unit_Price",m_XeroLineItem.get("UnitAmount"));
								//
								// map over tax (even if it's zero)
								v_XeroLineItemTaxAmount = ifnull(m_XeroLineItem.get("TaxAmount"),0).toDecimal();
								m_CrmProduct.put("Taxable",true);
								v_CrmTaxRateString = m_XeroLineItem.get("TaxType") + " - " + ifnull(m_ZohoTaxRates.get(m_XeroLineItem.get("TaxType")),"0.0") + " %";
								if(!l_CrmProductAvailableTaxes.contains(v_CrmTaxRateString))
								{
									l_CrmProductAvailableTaxes.add(m_XeroLineItem.get("TaxType"));
								}
								m_CrmProduct.put("Tax",l_CrmProductAvailableTaxes);
								//
								m_CrmProduct.put("Xero_Updated",zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss",v_ZohoTimeZone));
								//
								if(v_CrmProductID == "")
								{
									r_CreateCrmProduct = zoho.crm.createRecord("Products",m_CrmProduct);
									if(b_Debug)
									{
										info "Creating CRM Product: " + r_CreateCrmProduct;
									}
									if(!isNull(r_CreateCrmProduct.get("id")))
									{
										v_CrmProductID = r_CreateCrmProduct.get("id");
									}
									else if(r_CreateCrmProduct.get("code").equalsIgnoreCase("DUPLICATE_DATA"))
									{
										v_CrmProductID = r_CreateCrmProduct.get("details").get("id");
										if(b_Debug)
										{
											info "Duplicate CRM Product: Re-using " + v_CrmProductID;
										}
									}
								}
								else
								{
									//
									// update the product (mainly for new applicable taxes)
									r_UpdateCrmProduct = zoho.crm.updateRecord("Products",v_CrmProductID,m_CrmProduct);
									if(b_Debug)
									{
										info "Update CRM Product";
										info m_CrmProduct;
										info r_UpdateCrmProduct;
									}
								}
								//
								// let's do the rest of the line item (note that we are going to upsert using CRM API v8)
								m_CrmLineItem = Map();
								m_CrmLineItem.put("Product_Name",v_CrmProductID);
								m_CrmLineItem.put("Description",m_XeroLineItem.get("Description"));
								m_CrmLineItem.put("List_Price",m_XeroLineItem.get("UnitAmount"));
								m_CrmLineItem.put("Quantity",m_XeroLineItem.get("Quantity"));
								v_DiscountPercent = ifnull(m_XeroLineItem.get("DiscountRate"),0.0);
								v_DiscountAmount = ifnull(m_XeroLineItem.get("DiscountAmount"),0.0);
								if(v_DiscountPercent != 0)
								{
									// just qty vs unit excluding discount and tax
									v_LineItemTotal = m_XeroLineItem.get("Quantity") * m_XeroLineItem.get("UnitAmount");
									v_DiscountFactor = v_DiscountPercent / 100;
									v_DiscountAmount = v_LineItemTotal * v_DiscountFactor;
								}
								// tax even if it's zero
								l_CrmLineItemTax = List();
								m_CrmLineItemTax = Map();
								v_ZohoLineItemTaxPercent = ifnull(m_ZohoTaxRates.get(m_XeroLineItem.get("TaxType")),0.0).toDecimal();
								v_ZohoLineItemTaxPercent = v_ZohoLineItemTaxPercent * 1;
								m_CrmLineItemTax.put("percentage",v_ZohoLineItemTaxPercent);
								m_CrmLineItemTax.put("name",ifnull(m_XeroLineItem.get("TaxType"),"NONE"));
								m_CrmLineItemTax.put("value",m_XeroLineItem.get("TaxAmount"));
								l_CrmLineItemTax.add(m_CrmLineItemTax);
								m_CrmLineItem.put("Line_Tax",l_CrmLineItemTax);
								//
								m_CrmLineItem.put("Discount",v_DiscountAmount);
								l_CrmLineItems.add(m_CrmLineItem);
							}
						}
						//
						// if the CRM invoice already exists, we are going to upsert so we need to remove the current line items in the CRM invoice
						l_SearchInvoices = zoho.crm.searchRecords("Invoices","Xero_Ref_ID:equals:" + v_XeroInvoiceID);
						for each  m_InvoiceResult in l_SearchInvoices
						{
							if(!isNull(m_InvoiceResult.get("id")))
							{
								for each  m_ExistingLineItem in m_InvoiceResult.get("Product_Details")
								{
									m_MiniDeleteMe = Map();
									m_MiniDeleteMe.put("id",m_ExistingLineItem.get("id"));
									m_MiniDeleteMe.put("_delete",null);
									l_CrmLineItems.add(m_MiniDeleteMe);
								}
							}
						}
						//
						// add line items to the invoice
						m_UpsertCrmInvoice.put("Invoiced_Items",l_CrmLineItems);
						//
						// let's add the billing address retrieved earlier to the invoice
						m_UpsertCrmInvoice.put("Billing_Street",l_AddressStreet.toString(", "));
						m_UpsertCrmInvoice.put("Billing_City",v_XeroAddressCity);
						m_UpsertCrmInvoice.put("Billing_Code",v_XeroAddressZip);
						//
						// let's upsert
						m_Data = Map();
						m_Data.put("data",List({m_UpsertCrmInvoice}));
						m_Data.put("trigger",{"workflow","approval","blueprint"});
						r_UpsertInvoice = invokeurl
						[
							url :"https://www.zohoapis.eu/crm/v8/Invoices/upsert"
							type :POST
							parameters:m_Data.toString()
							connection:"ab_crm"
						];
						if(b_Debug)
						{
							info "Upserting Invoice: " + m_ThisInvoice.get("InvoiceNumber");
							info m_UpsertCrmInvoice;
							info r_UpsertInvoice;
						}
						l_ResponseData = ifnull(r_UpsertInvoice.get("data"),List());
						for each  m_ResponseData in l_ResponseData
						{
							if(!isNull(m_ResponseData.get("code")))
							{
								v_Action = m_ResponseData.get("action");
							}
							info m_ResponseData.get("code");
							//
							// possible errors, output anyway
							b_Error = if(m_ResponseData.get("code").equalsIgnoreCase("SUCCESS"),false,true);
							if(b_Error)
							{
								info m_ThisInvoice.get("InvoiceNumber") + ": FAILED: " + m_ResponseData;
								info m_UpsertCrmInvoice;
							}
						}
						if(v_Action == "insert")
						{
							v_Count_Created = v_Count_Created + 1;
						}
						else if(v_Action == "update")
						{
							v_Count_Updated = v_Count_Updated + 1;
						}
					}
				}
			}
		}
		v_OutputMessage = "Created " + v_Count_Created + " and Updated " + v_Count_Updated + " from " + v_Count_FoundInXero;
	}
	return v_OutputMessage;
}
  1.  string standalone.fn_Xero_GetInvoices() 
  2.  { 
  3.  /* ******************************************************************************* 
  4.      Function:       string standalone.fn_Xero_GetInvoices() 
  5.      Label:          Fn - Xero - Get Invoices 
  6.      Trigger:        Standalone / On-Demand / Callable 
  7.      Purpose:         Function to get the first page of most recent invoices from Xero and pull them into ZohoCRM 
  8.      Inputs:         - 
  9.      Outputs:        - 
  10.   
  11.      Date Created:   2025-10-08 (Joel Lipman) 
  12.                      - Initial release 
  13.      Date Modified:    2025-10-13 ( Joel Lipman) 
  14.                      - Parsing Xero Dates and Times to include timezone of Xero instance for accurate time comparisons 
  15.                      - Map Xero tax rates to Zoho tax rates (same names in Zoho) 
  16.                      - output to console if debug mode enabled 
  17.   
  18.      More Information: 
  19.                      http://www.joellipman.com/articles/crm/zoho/zoho-deluge-sync-to-xero-api.html 
  20.   
  21.      ******************************************************************************* */ 
  22.      // 
  23.      // init 
  24.      v_OutputMessage = "ERROR: No Access Token or Tenant Connection specified."
  25.      v_Count_FoundInXero = 0
  26.      v_Count_Created = 0
  27.      v_Count_Updated = 0
  28.      v_AccessToken = ""
  29.      l_Pages = {1}
  30.      v_PageSize = 20
  31.      b_Debug = false
  32.      // 
  33.      // Xero Invoice Statuses vs your CRM Invoice Statuses 
  34.      m_TranslateStatuses = Map()
  35.      m_TranslateStatuses.put("DRAFT","Draft")
  36.      m_TranslateStatuses.put("SUBMITTED","Pending Approval")
  37.      m_TranslateStatuses.put("AUTHORISED","Sent to Customer")
  38.      m_TranslateStatuses.put("PAID","Paid in Full")
  39.      m_TranslateStatuses.put("DELETED","Cancelled")
  40.      m_TranslateStatuses.put("VOIDED","Cancelled")
  41.      // 
  42.      // Xero Tax Rates translated to Zoho Tax Rate (manual reference) 
  43.      r_ZohoTaxRates = standalone.fn_Xero_MapTaxRates()
  44.      m_ZohoTaxRates = r_ZohoTaxRates.toMap()
  45.      if(b_Debug) 
  46.      { 
  47.          info m_ZohoTaxRates; 
  48.      } 
  49.      // 
  50.      // server timezones 
  51.      v_XeroTimeZone = "Europe/London"
  52.      v_ZohoTimeZone = "Europe/London"
  53.      // 
  54.      // enter the CRM record ID of your integrations record (Xero Integration API) 
  55.      v_TokenCrmID = 123456000000789012
  56.      r_TokenDetails = zoho.crm.getRecordById("Integrations",v_TokenCrmID)
  57.      v_DataEndpoint = ifnull(r_TokenDetails.get("Data_Endpoint"),"")
  58.      v_TenantID = ifnull(r_TokenDetails.get("Tenant_ID"),"")
  59.      // 
  60.      // get access token (does not need REST API url as we're calling it from within CRM) 
  61.      v_AccessToken = standalone.fn_API_GetXeroAccessToken()
  62.      // 
  63.      // do Xero stuff 
  64.      if(v_AccessToken != "" && v_TenantID != "") 
  65.      { 
  66.          // set header 
  67.          m_Header = Map()
  68.          m_Header.put("Authorization","Bearer " + v_AccessToken)
  69.          m_Header.put("Accept","application/json")
  70.          m_Header.put("Xero-tenant-id",v_TenantID)
  71.          // 
  72.          // get Xero invoices (page 1 - first 100 - default order is updated date) 
  73.          for each  v_Page in l_Pages 
  74.          { 
  75.              m_Params = Map()
  76.              m_Params.put("page",v_Page)
  77.              // 
  78.              // keep the page size low as this function will be creating contacts and products if required 
  79.              m_Params.put("pageSize",v_PageSize)
  80.              // 
  81.              // order by date descending (most recent first) - sometimes need to use Date%20DESC 
  82.              m_Params.put("order","UpdatedDateUTC DESC")
  83.              // 
  84.              // get the first page of Xero invoices 
  85.              v_FilterReceivables = "?where=" + zoho.encryption.urlEncode("Type=\"ACCREC\"")
  86.              r_AllXeroInvoices = invokeUrl 
  87.              [ 
  88.                  url :v_DataEndpoint + "/Invoices" + v_FilterReceivables 
  89.                  type :GET 
  90.                  parameters:m_Params 
  91.                  headers:m_Header 
  92.              ]
  93.              if(b_Debug) 
  94.              { 
  95.                  info r_AllXeroInvoices; 
  96.              } 
  97.              if(!isnull(r_AllXeroInvoices.get("Invoices"))) 
  98.              { 
  99.                  for each  m_ThisInvoice in r_AllXeroInvoices.get("Invoices") 
  100.                  { 
  101.                      if(!isnull(m_ThisInvoice.get("InvoiceID"))) 
  102.                      { 
  103.                          // 
  104.                          // counter 
  105.                          v_Count_FoundInXero = v_Count_FoundInXero + 1
  106.                          // 
  107.                          // Xero Invoice identifier 
  108.                          v_XeroInvoiceID = m_ThisInvoice.get("InvoiceID")
  109.                          // 
  110.                          m_UpsertCrmInvoice = Map()
  111.                          m_UpsertCrmInvoice.put("Subject",m_ThisInvoice.get("InvoiceNumber"))
  112.                          // 
  113.                          // some standard CRM invoice fields we can populate 
  114.                          v_CrmInvoiceStatus = m_TranslateStatuses.get(m_ThisInvoice.get("Status"))
  115.                          if(m_ThisInvoice.get("Status") == "PAID") 
  116.                          { 
  117.                              v_InvoiceTotal = m_ThisInvoice.get("Total")
  118.                              v_PaidTotal = 0.0
  119.                              // we have a partially paid status in crm so let's check those payments 
  120.                              for each  m_XeroPayment in m_ThisInvoice.get("Payments") 
  121.                              { 
  122.                                  if(!isNull(m_XeroPayment.get("PaymentID"))) 
  123.                                  { 
  124.                                      v_PaidTotal = v_PaidTotal + m_XeroPayment.get("Amount")
  125.                                  } 
  126.                              } 
  127.                              v_CrmInvoiceStatus = if(v_PaidTotal == v_InvoiceTotal,"Paid in Full","Partially Paid")
  128.                          } 
  129.                          m_UpsertCrmInvoice.put("Status",v_CrmInvoiceStatus)
  130.                          v_XeroInvoiceDate = m_ThisInvoice.get("Date")
  131.                          d_XeroInvoiceDate = v_XeroInvoiceDate.replaceAll("/Date\((\d+)([+-]\d{4})?\)/","$1",false).toLong().toTime("yyyy-MM-dd HH:mm:ss",v_XeroTimeZone)
  132.                          m_UpsertCrmInvoice.put("Invoice_Date",d_XeroInvoiceDate.toString("yyyy-MM-dd"))
  133.                          v_XeroInvoiceDueDate = m_ThisInvoice.get("DueDate")
  134.                          d_XeroInvoiceDueDate = v_XeroInvoiceDueDate.replaceAll("/Date\((\d+)([+-]\d{4})?\)/","$1",false).toLong().toTime("yyyy-MM-dd HH:mm:ss",v_XeroTimeZone)
  135.                          m_UpsertCrmInvoice.put("Due_Date",d_XeroInvoiceDueDate.toString("yyyy-MM-dd"))
  136.                          m_UpsertCrmInvoice.put("Currency",m_ThisInvoice.get("CurrencyCode"))
  137.                          // 
  138.                          // some custom fields I created in CRM to store the data 
  139.                          m_UpsertCrmInvoice.put("Xero_Ref_ID",m_ThisInvoice.get("InvoiceID"))
  140.                          m_UpsertCrmInvoice.put("Xero_Updated",zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss",v_ZohoTimeZone))
  141.                          m_UpsertCrmInvoice.put("Amount_Paid",m_ThisInvoice.get("AmountPaid"))
  142.                          m_UpsertCrmInvoice.put("Amount_Credited",m_ThisInvoice.get("AmountCredited"))
  143.                          if(!isNull(m_ThisInvoice.get("FullyPaidOnDate"))) 
  144.                          { 
  145.                              v_XeroFullyPaidDate = m_ThisInvoice.get("FullyPaidOnDate")
  146.                              d_XeroFullyPaidDate = v_XeroFullyPaidDate.replaceAll("/Date\((\d+)([+-]\d{4})?\)/","$1",false).toLong().toTime("yyyy-MM-dd HH:mm:ss",v_XeroTimeZone)
  147.                              m_UpsertCrmInvoice.put("Date_Fully_Paid",d_XeroFullyPaidDate.toString("yyyy-MM-dd"))
  148.                          } 
  149.                          m_UpsertCrmInvoice.put("Reference",m_ThisInvoice.get("Reference"))
  150.                          // 
  151.                          // -------------------------------- Invoice Customer -------------------------------- 
  152.                          // 
  153.                          // initialize 
  154.                          v_CrmAccountID = ""
  155.                          v_CrmContactID = ""
  156.                          v_CrmPhone = ""
  157.                          v_CrmMobile = ""
  158.                          b_CreateAccount = true
  159.                          b_CreateContact = true
  160.                          // 
  161.                          // set date/time of account last sync'd to Xero (100 years ago by default - so that it will be oldest) 
  162.                          d_CrmAccountLastUpdated = zoho.currenttime.toString("yyyy-MM-dd HH:mm:ss").toTime().subYear(100)
  163.                          v_XeroContactID = m_ThisInvoice.get("Contact").get("ContactID")
  164.                          v_XeroContactName = m_ThisInvoice.get("Contact").get("Name")
  165.                          // 
  166.                          // search CRM for this account/customer 
  167.                          l_SearchAccounts = zoho.crm.searchRecords("Accounts","Xero_Ref_ID:equals:" + v_XeroContactID,1,2,{"approved":"both","converted":"both"})
  168.                          for each  m_SearchAccount in l_SearchAccounts 
  169.                          { 
  170.                              if(!isNull(m_SearchAccount.get("id"))) 
  171.                              { 
  172.                                  b_CreateAccount = false
  173.                                  v_CrmAccountID = m_SearchAccount.get("id")
  174.                                  // 
  175.                                  // if sync'd before then let's use that date/time 
  176.                                  d_CrmAccountLastUpdated = ifnull(m_SearchAccount.get("Xero_Updated"),zoho.currenttime).toString("yyyy-MM-dd HH:mm:ss",v_ZohoTimeZone).toTime()
  177.                                  if(b_Debug) 
  178.                                  { 
  179.                                      info "Found CRM Account: " + v_CrmAccountID; 
  180.                                  } 
  181.                              } 
  182.                          } 
  183.                          // 
  184.                          // get account/contact details from Xero (invoice doesn't necessarily hold the details: address, phone, etc) 
  185.                          r_XeroContact = invokeUrl 
  186.                          [ 
  187.                              url :v_DataEndpoint + "/Contacts/" + v_XeroContactID 
  188.                              type :GET 
  189.                              parameters:m_Params 
  190.                              headers:m_Header 
  191.                          ]
  192.                          l_XeroContacts = ifnull(r_XeroContact.get("Contacts"),List())
  193.                          for each  m_XeroContact in l_XeroContacts 
  194.                          { 
  195.                              if(!isNull(m_XeroContact.get("ContactID"))) 
  196.                              { 
  197.                                  // 
  198.                                  // to check if we want to update the CRM record for the account 
  199.                                  v_XeroTime = m_XeroContact.get("UpdatedDateUTC")
  200.                                  d_XeroAccountLastUpdated = v_XeroTime.replaceAll("/Date\((\d+)([+-]\d{4})?\)/","$1",false).toLong().toTime("yyyy-MM-dd HH:mm:ss",v_XeroTimeZone)
  201.                                  // 
  202.                                  // build upsert for CRM account 
  203.                                  m_CrmAccount = Map()
  204.                                  m_CrmAccount.put("Account_Name",m_ThisInvoice.get("Contact").get("Name"))
  205.                                  m_CrmAccount.put("Xero_Ref_ID",m_XeroContact.get("ContactID"))
  206.                                  m_CrmAccount.put("Xero_Updated",zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss",v_ZohoTimeZone))
  207.                                  // 
  208.                                  // addresses 
  209.                                  for each  m_XeroAddress in m_XeroContact.get("Addresses") 
  210.                                  { 
  211.                                      if(!isNull(m_XeroAddress.get("AddressLine1"))) 
  212.                                      { 
  213.                                          v_XeroAddressLine1 = m_XeroAddress.get("AddressLine1")
  214.                                          v_XeroAddressLine2 = m_XeroAddress.get("AddressLine2")
  215.                                          v_XeroAddressCity = m_XeroAddress.get("City")
  216.                                          v_XeroAddressZip = m_XeroAddress.get("PostalCode")
  217.                                          v_XeroAddressAttn = m_XeroAddress.get("AttentionTo")
  218.                                      } 
  219.                                  } 
  220.                                  // 
  221.                                  l_AddressStreet = List({v_XeroAddressLine1})
  222.                                  if(!isBlank(v_XeroAddressLine2)) 
  223.                                  { 
  224.                                      l_AddressStreet.add(v_XeroAddressLine2)
  225.                                  } 
  226.                                  m_CrmAccount.put("Billing_Street",l_AddressStreet.toString(", "))
  227.                                  m_CrmAccount.put("Billing_City",v_XeroAddressCity)
  228.                                  m_CrmAccount.put("Billing_Code",v_XeroAddressZip)
  229.                                  // 
  230.                                  // loop through phones 
  231.                                  for each  m_XeroPhone in m_XeroContact.get("Phones") 
  232.                                  { 
  233.                                      if(!isNull(m_XeroPhone.get("PhoneNumber"))) 
  234.                                      { 
  235.                                          v_XeroPhoneType = m_XeroPhone.get("PhoneType")
  236.                                          l_XeroFullPhoneNumberParts = List()
  237.                                          if(!isNull(m_XeroPhone.get("PhoneCountryCode"))) 
  238.                                          { 
  239.                                              l_XeroFullPhoneNumberParts.add(m_XeroPhone.get("PhoneCountryCode"))
  240.                                          } 
  241.                                          if(!isNull(m_XeroPhone.get("PhoneAreaCode"))) 
  242.                                          { 
  243.                                              l_XeroFullPhoneNumberParts.add(m_XeroPhone.get("PhoneAreaCode"))
  244.                                          } 
  245.                                          if(!isNull(m_XeroPhone.get("PhoneNumber"))) 
  246.                                          { 
  247.                                              l_XeroFullPhoneNumberParts.add(m_XeroPhone.get("PhoneNumber"))
  248.                                          } 
  249.                                          v_XeroFullPhoneNumber = l_XeroFullPhoneNumberParts.toString(" ")
  250.                                          if(v_XeroPhoneType == "DEFAULT" || v_XeroPhoneType == "PHONE") 
  251.                                          { 
  252.                                              v_CrmPhone = v_XeroFullPhoneNumber; 
  253.                                          } 
  254.                                          else if(v_XeroPhoneType == "MOBILE") 
  255.                                          { 
  256.                                              v_CrmMobile = v_XeroFullPhoneNumber; 
  257.                                          } 
  258.                                      } 
  259.                                  } 
  260.                                  m_CrmAccount.put("Phone",v_CrmPhone)
  261.                                  // 
  262.                                  // balances 
  263.                                  v_XeroReceivables = 0.0
  264.                                  v_XeroPayables = 0.0
  265.                                  for each  m_XeroBalance in m_XeroContact.get("Balances") 
  266.                                  { 
  267.                                      if(!isNull(m_XeroBalance.get("AccountsReceivable"))) 
  268.                                      { 
  269.                                          v_XeroReceivables = m_XeroBalance.get("AccountsReceivable").get("Outstanding")
  270.                                          v_XeroReceivables = v_XeroReceivables + m_XeroBalance.get("AccountsReceivable").get("Overdue")
  271.                                          v_XeroReceivables = v_XeroReceivables * -1
  272.                                      } 
  273.                                      if(!isNull(m_XeroBalance.get("AccountsPayable"))) 
  274.                                      { 
  275.                                          v_XeroPayables = m_XeroBalance.get("AccountsPayable").get("Outstanding")
  276.                                          v_XeroPayables = v_XeroPayables + m_XeroBalance.get("AccountsReceivable").get("Overdue")
  277.                                      } 
  278.                                  } 
  279.                                  v_XeroBalance = v_XeroPayables - v_XeroReceivables; 
  280.                                  m_CrmAccount.put("Xero_Balance",v_XeroBalance)
  281.                                  // 
  282.                                  // create CRM account for other contact records 
  283.                                  if(b_CreateAccount) 
  284.                                  { 
  285.                                      r_CreateAccount = zoho.crm.createRecord("Accounts",m_CrmAccount)
  286.                                      if(b_Debug) 
  287.                                      { 
  288.                                          info "Creating CRM Account: " + r_CreateAccount; 
  289.                                      } 
  290.                                      if(!isNull(r_CreateAccount.getJSON("id"))) 
  291.                                      { 
  292.                                          v_CrmAccountID = r_CreateAccount.get("id")
  293.                                      } 
  294.                                  } 
  295.                                  // 
  296.                                  // create a contact 
  297.                                  v_SearchContactsCriteria = "Email:equals:" + if(isBlank(m_XeroContact.get("EmailAddress")),"Unknown",m_XeroContact.get("EmailAddress"))
  298.                                  l_SearchContacts = zoho.crm.searchRecords("Contacts",v_SearchContactsCriteria)
  299.                                  if(b_Debug) 
  300.                                  { 
  301.                                      info "Searching Contacts (" + v_SearchContactsCriteria + "): " + l_SearchContacts; 
  302.                                  } 
  303.                                  for each  m_SearchContact in l_SearchContacts 
  304.                                  { 
  305.                                      if(!isNull(m_SearchContact.getJSON("id"))) 
  306.                                      { 
  307.                                          b_CreateContact = false
  308.                                          v_CrmContactID = m_SearchContact.get("id")
  309.                                          if(b_Debug) 
  310.                                          { 
  311.                                              info "Found CRM Contact: " + v_CrmContactID; 
  312.                                          } 
  313.                                      } 
  314.                                  } 
  315.                                  // 
  316.                                  // build upsert for CRM contact 
  317.                                  m_CrmContact = Map()
  318.                                  m_CrmContact.put("First_Name",m_XeroContact.get("FirstName"))
  319.                                  // last name is mandatory for a CRM contact so we're going to put a placeholder one if this is not given 
  320.                                  v_CrmContactLastName = ifnull(m_XeroContact.get("LastName"),"-Unknown-")
  321.                                  m_CrmContact.put("Last_Name",v_CrmContactLastName)
  322.                                  m_CrmContact.put("Email",m_XeroContact.get("EmailAddress"))
  323.                                  m_CrmContact.put("Phone",v_CrmPhone)
  324.                                  m_CrmContact.put("Mobile",v_CrmMobile)
  325.                                  m_CrmContact.put("Xero_Ref_ID",m_XeroContact.get("ContactID"))
  326.                                  m_CrmContact.put("Xero_Updated",zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss",v_ZohoTimeZone))
  327.                                  m_CrmContact.put("Mailing_Street",l_AddressStreet.toString(", "))
  328.                                  m_CrmContact.put("Mailing_City",v_XeroAddressCity)
  329.                                  m_CrmContact.put("Mailing_Zip",v_XeroAddressZip)
  330.                                  m_CrmContact.put("Account_Name",v_CrmAccountID)
  331.                                  // last name is mandatory, let's not bother if it wasn't provided 
  332.                                  if(b_CreateContact && v_CrmContactLastName != "-Unknown-") 
  333.                                  { 
  334.                                      r_CreateContact = zoho.crm.createRecord("Contacts",m_CrmContact)
  335.                                      if(b_Debug) 
  336.                                      { 
  337.                                          info "Creating Primary Contact: " + r_CreateContact; 
  338.                                      } 
  339.                                      if(!isNull(r_CreateContact.get("id"))) 
  340.                                      { 
  341.                                          v_CrmContactID = r_CreateContact.get("id")
  342.                                      } 
  343.                                      // 
  344.                                      // create other contacts (retain the map and only change first name, last name, and email) 
  345.                                      for each  m_OtherContact in m_XeroContact.get("ContactPersons") 
  346.                                      { 
  347.                                          m_CrmContact.put("First_Name",m_OtherContact.get("FirstName"))
  348.                                          m_CrmContact.put("Last_Name",m_OtherContact.get("LastName"))
  349.                                          m_CrmContact.put("Email",m_OtherContact.get("EmailAddress"))
  350.                                          r_CreateContact2 = zoho.crm.createRecord("Contacts",m_CrmContact)
  351.                                          if(b_Debug) 
  352.                                          { 
  353.                                              info "Creating Secondary Contact: " + r_CreateContact2; 
  354.                                          } 
  355.                                      } 
  356.                                  } 
  357.                              } 
  358.                          } 
  359.                          // 
  360.                          // if Xero record is more recently updated than the CRM one, then update the account 
  361.                          if(d_XeroAccountLastUpdated >= d_CrmAccountLastUpdated) 
  362.                          { 
  363.                              r_UpdateCrmAccount = zoho.crm.updateRecord("Accounts",v_CrmAccountID,m_CrmAccount)
  364.                              r_UpdateCrmContact = zoho.crm.updateRecord("Contacts",v_CrmContactID,m_CrmContact)
  365.                          } 
  366.                          // 
  367.                          // add account/contact to the invoice 
  368.                          m_UpsertCrmInvoice.put("Account_Name",v_CrmAccountID)
  369.                          m_UpsertCrmInvoice.put("Contact_Name",v_CrmContactID)
  370.                          // 
  371.                          // -------------------------------- Invoice Line Items -------------------------------- 
  372.                          // 
  373.                          // initializing 
  374.                          l_CrmLineItems = List()
  375.                          // 
  376.                          // loop through line items on the Xero invoice 
  377.                          for each  m_XeroLineItem in m_ThisInvoice.get("LineItems") 
  378.                          { 
  379.                              // 
  380.                              // initialize 
  381.                              v_CrmProductID = ""
  382.                              l_CrmProductAvailableTaxes = List()
  383.                              l_CrmProductAvailableTaxes.add("NONE")
  384.                              // 
  385.                              // checking this is a valid line item and not an error message by it having an ItemCode 
  386.                              v_CrmProductName = ifnull(m_XeroLineItem.get("ItemCode"),m_XeroLineItem.get("Description"))
  387.                              v_CrmProductName = if(isBlank(v_CrmProductName),ifnull(m_XeroLineItem.get("Item"),{"Name":"Product"}).get("Name"),"Product")
  388.                              if(!isBlank(v_CrmProductName)) 
  389.                              { 
  390.                                  v_CrmProductCode = ifnull(m_XeroLineItem.get("ItemCode"),"-Unknown-")
  391.                                  v_CrmProductCodeSafe = zoho.encryption.urlEncode(v_CrmProductCode)
  392.                                  v_CrmProductName = if(v_CrmProductName.length() >= 200,v_CrmProductName.subString(0,199),v_CrmProductName)
  393.                                  v_CrmProductNameSafe = zoho.encryption.urlEncode(v_CrmProductName)
  394.                                  v_SearchCriteria = "((Product_Code:equals:" + v_CrmProductCodeSafe + ")or(Product_Name:equals:" + v_CrmProductNameSafe + "))"
  395.                                  l_SearchProducts = zoho.crm.searchRecords("Products",v_SearchCriteria,1,2,{"approved":"both"})
  396.                                  if(b_Debug) 
  397.                                  { 
  398.                                      info "Searching CRM Products: " + v_SearchCriteria; 
  399.                                  } 
  400.                                  for each  m_SearchProduct in l_SearchProducts 
  401.                                  { 
  402.                                      if(!isNull(m_SearchProduct.get("id"))) 
  403.                                      { 
  404.                                          v_CrmProductID = m_SearchProduct.get("id")
  405.                                          l_CrmProductAvailableTaxes = ifnull(m_SearchProduct.get("Tax"),List())
  406.                                          if(b_Debug) 
  407.                                          { 
  408.                                              info "Available Taxes: " + l_CrmProductAvailableTaxes; 
  409.                                          } 
  410.                                      } 
  411.                                  } 
  412.                                  // 
  413.                                  // couldn't find it so let's create it 
  414.                                  m_CrmProduct = Map()
  415.                                  // 
  416.                                  // some companies don't use the product lookup in Xero so you would need a placeholder product from CRM. 
  417.                                  if(!isNull(m_XeroLineItem.get("Item"))) 
  418.                                  { 
  419.                                      v_CrmProductName = m_XeroLineItem.get("Item").get("Name")
  420.                                      m_CrmProduct.put("Xero_Ref_ID",m_XeroLineItem.get("Item").get("ItemID"))
  421.                                      m_CrmProduct.put("Product_Code",m_XeroLineItem.get("Item").get("Code"))
  422.                                  } 
  423.                                  m_CrmProduct.put("Product_Name",v_CrmProductName)
  424.                                  m_CrmProduct.put("Product_Active",true)
  425.                                  m_CrmProduct.put("Description",m_XeroLineItem.get("Description"))
  426.                                  m_CrmProduct.put("Unit_Price",m_XeroLineItem.get("UnitAmount"))
  427.                                  // 
  428.                                  // map over tax (even if it's zero) 
  429.                                  v_XeroLineItemTaxAmount = ifnull(m_XeroLineItem.get("TaxAmount"),0).toDecimal()
  430.                                  m_CrmProduct.put("Taxable",true)
  431.                                  v_CrmTaxRateString = m_XeroLineItem.get("TaxType") + " - " + ifnull(m_ZohoTaxRates.get(m_XeroLineItem.get("TaxType")),"0.0") + " %"
  432.                                  if(!l_CrmProductAvailableTaxes.contains(v_CrmTaxRateString)) 
  433.                                  { 
  434.                                      l_CrmProductAvailableTaxes.add(m_XeroLineItem.get("TaxType"))
  435.                                  } 
  436.                                  m_CrmProduct.put("Tax",l_CrmProductAvailableTaxes)
  437.                                  // 
  438.                                  m_CrmProduct.put("Xero_Updated",zoho.currenttime.toString("yyyy-MM-dd'T'HH:mm:ss",v_ZohoTimeZone))
  439.                                  // 
  440.                                  if(v_CrmProductID == "") 
  441.                                  { 
  442.                                      r_CreateCrmProduct = zoho.crm.createRecord("Products",m_CrmProduct)
  443.                                      if(b_Debug) 
  444.                                      { 
  445.                                          info "Creating CRM Product: " + r_CreateCrmProduct; 
  446.                                      } 
  447.                                      if(!isNull(r_CreateCrmProduct.get("id"))) 
  448.                                      { 
  449.                                          v_CrmProductID = r_CreateCrmProduct.get("id")
  450.                                      } 
  451.                                      else if(r_CreateCrmProduct.get("code").equalsIgnoreCase("DUPLICATE_DATA")) 
  452.                                      { 
  453.                                          v_CrmProductID = r_CreateCrmProduct.get("details").get("id")
  454.                                          if(b_Debug) 
  455.                                          { 
  456.                                              info "Duplicate CRM Product: Re-using " + v_CrmProductID; 
  457.                                          } 
  458.                                      } 
  459.                                  } 
  460.                                  else 
  461.                                  { 
  462.                                      // 
  463.                                      // update the product (mainly for new applicable taxes) 
  464.                                      r_UpdateCrmProduct = zoho.crm.updateRecord("Products",v_CrmProductID,m_CrmProduct)
  465.                                      if(b_Debug) 
  466.                                      { 
  467.                                          info "Update CRM Product"
  468.                                          info m_CrmProduct; 
  469.                                          info r_UpdateCrmProduct; 
  470.                                      } 
  471.                                  } 
  472.                                  // 
  473.                                  // let's do the rest of the line item (note that we are going to upsert using CRM API v8) 
  474.                                  m_CrmLineItem = Map()
  475.                                  m_CrmLineItem.put("Product_Name",v_CrmProductID)
  476.                                  m_CrmLineItem.put("Description",m_XeroLineItem.get("Description"))
  477.                                  m_CrmLineItem.put("List_Price",m_XeroLineItem.get("UnitAmount"))
  478.                                  m_CrmLineItem.put("Quantity",m_XeroLineItem.get("Quantity"))
  479.                                  v_DiscountPercent = ifnull(m_XeroLineItem.get("DiscountRate"),0.0)
  480.                                  v_DiscountAmount = ifnull(m_XeroLineItem.get("DiscountAmount"),0.0)
  481.                                  if(v_DiscountPercent != 0) 
  482.                                  { 
  483.                                      // just qty vs unit excluding discount and tax 
  484.                                      v_LineItemTotal = m_XeroLineItem.get("Quantity") * m_XeroLineItem.get("UnitAmount")
  485.                                      v_DiscountFactor = v_DiscountPercent / 100
  486.                                      v_DiscountAmount = v_LineItemTotal * v_DiscountFactor; 
  487.                                  } 
  488.                                  // tax even if it's zero 
  489.                                  l_CrmLineItemTax = List()
  490.                                  m_CrmLineItemTax = Map()
  491.                                  v_ZohoLineItemTaxPercent = ifnull(m_ZohoTaxRates.get(m_XeroLineItem.get("TaxType")),0.0).toDecimal()
  492.                                  v_ZohoLineItemTaxPercent = v_ZohoLineItemTaxPercent * 1
  493.                                  m_CrmLineItemTax.put("percentage",v_ZohoLineItemTaxPercent)
  494.                                  m_CrmLineItemTax.put("name",ifnull(m_XeroLineItem.get("TaxType"),"NONE"))
  495.                                  m_CrmLineItemTax.put("value",m_XeroLineItem.get("TaxAmount"))
  496.                                  l_CrmLineItemTax.add(m_CrmLineItemTax)
  497.                                  m_CrmLineItem.put("Line_Tax",l_CrmLineItemTax)
  498.                                  // 
  499.                                  m_CrmLineItem.put("Discount",v_DiscountAmount)
  500.                                  l_CrmLineItems.add(m_CrmLineItem)
  501.                              } 
  502.                          } 
  503.                          // 
  504.                          // if the CRM invoice already exists, we are going to upsert so we need to remove the current line items in the CRM invoice 
  505.                          l_SearchInvoices = zoho.crm.searchRecords("Invoices","Xero_Ref_ID:equals:" + v_XeroInvoiceID)
  506.                          for each  m_InvoiceResult in l_SearchInvoices 
  507.                          { 
  508.                              if(!isNull(m_InvoiceResult.get("id"))) 
  509.                              { 
  510.                                  for each  m_ExistingLineItem in m_InvoiceResult.get("Product_Details") 
  511.                                  { 
  512.                                      m_MiniDeleteMe = Map()
  513.                                      m_MiniDeleteMe.put("id",m_ExistingLineItem.get("id"))
  514.                                      m_MiniDeleteMe.put("_delete",null)
  515.                                      l_CrmLineItems.add(m_MiniDeleteMe)
  516.                                  } 
  517.                              } 
  518.                          } 
  519.                          // 
  520.                          // add line items to the invoice 
  521.                          m_UpsertCrmInvoice.put("Invoiced_Items",l_CrmLineItems)
  522.                          // 
  523.                          // let's add the billing address retrieved earlier to the invoice 
  524.                          m_UpsertCrmInvoice.put("Billing_Street",l_AddressStreet.toString(", "))
  525.                          m_UpsertCrmInvoice.put("Billing_City",v_XeroAddressCity)
  526.                          m_UpsertCrmInvoice.put("Billing_Code",v_XeroAddressZip)
  527.                          // 
  528.                          // let's upsert 
  529.                          m_Data = Map()
  530.                          m_Data.put("data",List({m_UpsertCrmInvoice}))
  531.                          m_Data.put("trigger",{"workflow","approval","blueprint"})
  532.                          r_UpsertInvoice = invokeUrl 
  533.                          [ 
  534.                              url :"https://www.zohoapis.eu/crm/v8/Invoices/upsert" 
  535.                              type :POST 
  536.                              parameters:m_Data.toString() 
  537.                              connection:"ab_crm" 
  538.                          ]
  539.                          if(b_Debug) 
  540.                          { 
  541.                              info "Upserting Invoice: " + m_ThisInvoice.get("InvoiceNumber")
  542.                              info m_UpsertCrmInvoice; 
  543.                              info r_UpsertInvoice; 
  544.                          } 
  545.                          l_ResponseData = ifnull(r_UpsertInvoice.get("data"),List())
  546.                          for each  m_ResponseData in l_ResponseData 
  547.                          { 
  548.                              if(!isNull(m_ResponseData.get("code"))) 
  549.                              { 
  550.                                  v_Action = m_ResponseData.get("action")
  551.                              } 
  552.                              info m_ResponseData.get("code")
  553.                              // 
  554.                              // possible errors, output anyway 
  555.                              b_Error = if(m_ResponseData.get("code").equalsIgnoreCase("SUCCESS"),false,true)
  556.                              if(b_Error) 
  557.                              { 
  558.                                  info m_ThisInvoice.get("InvoiceNumber") + ": FAILED: " + m_ResponseData; 
  559.                                  info m_UpsertCrmInvoice; 
  560.                              } 
  561.                          } 
  562.                          if(v_Action == "insert") 
  563.                          { 
  564.                              v_Count_Created = v_Count_Created + 1
  565.                          } 
  566.                          else if(v_Action == "update") 
  567.                          { 
  568.                              v_Count_Updated = v_Count_Updated + 1
  569.                          } 
  570.                      } 
  571.                  } 
  572.              } 
  573.          } 
  574.          v_OutputMessage = "Created " + v_Count_Created + " and Updated " + v_Count_Updated + " from " + v_Count_FoundInXero; 
  575.      } 
  576.      return v_OutputMessage; 
  577.  } 


The Tax Rates function
copyraw
string standalone.fn_Xero_MapTaxRates()
{
	/* *******************************************************************************
	Function:       string standalone.fn_Xero_MapTaxRates()
	Label:          Fn - Xero - Map Tax Rates
	Trigger:        Standalone / On-Demand
	Purpose:	 	Function used to map Xero tax rates to Zoho ones
	Inputs:         -
	Outputs:        -

	Date Created:   2025-10-13 (Ascent Business - Joel Lipman)
					- Initial release
	Date Modified:	???
					- ???

	More Information:
					Ensure that all possible tax rates in Xero match those in Zoho (eg. 20.000, 17.500, 15.000, 10.000, 5.000, ...)

	******************************************************************************* */
	// init
	v_Output = "";
	m_OutputTaxRates = Map();
	m_ZohoTaxRatesByName = Map();
	m_ZohoTaxRatesByRate = Map();
	l_ZohoCrmTaxRatesList = List();
	v_XeroIntegrationRecordID = "123456000000789012";
	//
	r_IntegrationRecord = zoho.crm.getRecordById("Integrations",v_XeroIntegrationRecordID);
	v_TenantID = r_IntegrationRecord.get("Tenant_ID");
	v_AccessToken = standalone.fn_API_GetXeroAccessToken();
	//
	// get Zoho tax rates
	r_TaxRates = invokeurl
	[
		url :"https://www.zohoapis.eu/crm/v8/org/taxes"
		type :GET
		connection:"ab_crm"
	];
	r_OrgTaxes = ifnull(r_TaxRates.get("org_taxes"),Map());
	l_TaxRates = ifnull(r_OrgTaxes.get("taxes"),List());
	//
	for each  m_TaxRate in l_TaxRates
	{
		if(!isNull(m_TaxRate.get("id")))
		{
			m_ZohoTaxRatesByName.put(m_TaxRate.get("name"),m_TaxRate.get("id"));
			m_ZohoTaxRatesByRate.put(m_TaxRate.get("value").toDecimal().round(3).toString(),m_TaxRate.get("id"));
		}
	}
	info m_ZohoTaxRatesByName;
	info m_ZohoTaxRatesByRate;
	//
	// do Xero stuff
	if(v_AccessToken != "")
	{
		m_Header = Map();
		m_Header.put("Authorization","Bearer " + v_AccessToken);
		m_Header.put("Accept","application/json");
		m_Header.put("Xero-tenant-id",v_TenantID);
		//
		// get CRM invoice details
		v_TaxRateEndpoint = "https://api.xero.com/api.xro/2.0/TaxRates";
		r_XeroResponse = invokeurl
		[
			url :v_TaxRateEndpoint
			type :GET
			headers:m_Header
		];
		info r_XeroResponse;
		l_TaxRates = ifnull(r_XeroResponse.get("TaxRates"),List());
		for each  m_ThisTaxRate in l_TaxRates
		{
			if(!isNull(m_ThisTaxRate.get("Name")))
			{
				v_ThisXeroTaxRateName = m_ThisTaxRate.get("Name");
				v_ThisXeroTaxRateRef = m_ThisTaxRate.get("TaxType");
				//
				v_ThisXeroTaxRateRate = 0.0;
				if(!isEmpty(m_ThisTaxRate.get("TaxComponents")))
				{
					for each  m_TaxComponent in m_ThisTaxRate.get("TaxComponents")
					{
						v_ThisXeroTaxRateRate = m_TaxComponent.get("Rate").toDecimal().round(3).toString();
					}
				}
				//
				// map
				if(!isNull(m_ZohoTaxRatesByName.get(v_ThisXeroTaxRateName)))
				{
					m_OutputTaxRates.put(v_ThisXeroTaxRateRef,v_ThisXeroTaxRateRate);
				}
				else if(!isNull(m_ZohoTaxRatesByName.get(v_ThisXeroTaxRateRef)))
				{
					m_OutputTaxRates.put(v_ThisXeroTaxRateRef,v_ThisXeroTaxRateRate);
				}
				else if(!isNull(m_ZohoTaxRatesByRate.get(v_ThisXeroTaxRateRate)))
				{
					m_OutputTaxRates.put(v_ThisXeroTaxRateRef,v_ThisXeroTaxRateRate);
				}
				//
				// list to copy in Zoho (copy and paste)
				m_ZohoCrmTaxRatesList = Map();
				m_ZohoCrmTaxRatesList.put(v_ThisXeroTaxRateRef,v_ThisXeroTaxRateRate);
				l_ZohoCrmTaxRatesList.add(m_ZohoCrmTaxRatesList);
			}
		}
		info "Copy this list: ";
		info l_ZohoCrmTaxRatesList;
		v_Output = m_OutputTaxRates.toString();
	}
	return v_Output;
}
  1.  string standalone.fn_Xero_MapTaxRates() 
  2.  { 
  3.      /* ******************************************************************************* 
  4.      Function:       string standalone.fn_Xero_MapTaxRates() 
  5.      Label:          Fn - Xero - Map Tax Rates 
  6.      Trigger:        Standalone / On-Demand 
  7.      Purpose:         Function used to map Xero tax rates to Zoho ones 
  8.      Inputs:         - 
  9.      Outputs:        - 
  10.   
  11.      Date Created:   2025-10-13 (Ascent Business - Joel Lipman) 
  12.                      - Initial release 
  13.      Date Modified:    ??? 
  14.                      - ??? 
  15.   
  16.      More Information: 
  17.                      Ensure that all possible tax rates in Xero match those in Zoho (eg. 20.000, 17.500, 15.000, 10.000, 5.000, ...) 
  18.   
  19.      ******************************************************************************* */ 
  20.      // init 
  21.      v_Output = ""
  22.      m_OutputTaxRates = Map()
  23.      m_ZohoTaxRatesByName = Map()
  24.      m_ZohoTaxRatesByRate = Map()
  25.      l_ZohoCrmTaxRatesList = List()
  26.      v_XeroIntegrationRecordID = "123456000000789012"
  27.      // 
  28.      r_IntegrationRecord = zoho.crm.getRecordById("Integrations",v_XeroIntegrationRecordID)
  29.      v_TenantID = r_IntegrationRecord.get("Tenant_ID")
  30.      v_AccessToken = standalone.fn_API_GetXeroAccessToken()
  31.      // 
  32.      // get Zoho tax rates 
  33.      r_TaxRates = invokeUrl 
  34.      [ 
  35.          url :"https://www.zohoapis.eu/crm/v8/org/taxes" 
  36.          type :GET 
  37.          connection:"ab_crm" 
  38.      ]
  39.      r_OrgTaxes = ifnull(r_TaxRates.get("org_taxes"),Map())
  40.      l_TaxRates = ifnull(r_OrgTaxes.get("taxes"),List())
  41.      // 
  42.      for each  m_TaxRate in l_TaxRates 
  43.      { 
  44.          if(!isNull(m_TaxRate.get("id"))) 
  45.          { 
  46.              m_ZohoTaxRatesByName.put(m_TaxRate.get("name"),m_TaxRate.get("id"))
  47.              m_ZohoTaxRatesByRate.put(m_TaxRate.get("value").toDecimal().round(3).toString(),m_TaxRate.get("id"))
  48.          } 
  49.      } 
  50.      info m_ZohoTaxRatesByName; 
  51.      info m_ZohoTaxRatesByRate; 
  52.      // 
  53.      // do Xero stuff 
  54.      if(v_AccessToken != "") 
  55.      { 
  56.          m_Header = Map()
  57.          m_Header.put("Authorization","Bearer " + v_AccessToken)
  58.          m_Header.put("Accept","application/json")
  59.          m_Header.put("Xero-tenant-id",v_TenantID)
  60.          // 
  61.          // get CRM invoice details 
  62.          v_TaxRateEndpoint = "https://api.xero.com/api.xro/2.0/TaxRates"
  63.          r_XeroResponse = invokeUrl 
  64.          [ 
  65.              url :v_TaxRateEndpoint 
  66.              type :GET 
  67.              headers:m_Header 
  68.          ]
  69.          info r_XeroResponse; 
  70.          l_TaxRates = ifnull(r_XeroResponse.get("TaxRates"),List())
  71.          for each  m_ThisTaxRate in l_TaxRates 
  72.          { 
  73.              if(!isNull(m_ThisTaxRate.get("Name"))) 
  74.              { 
  75.                  v_ThisXeroTaxRateName = m_ThisTaxRate.get("Name")
  76.                  v_ThisXeroTaxRateRef = m_ThisTaxRate.get("TaxType")
  77.                  // 
  78.                  v_ThisXeroTaxRateRate = 0.0
  79.                  if(!isEmpty(m_ThisTaxRate.get("TaxComponents"))) 
  80.                  { 
  81.                      for each  m_TaxComponent in m_ThisTaxRate.get("TaxComponents") 
  82.                      { 
  83.                          v_ThisXeroTaxRateRate = m_TaxComponent.get("Rate").toDecimal().round(3).toString()
  84.                      } 
  85.                  } 
  86.                  // 
  87.                  // map 
  88.                  if(!isNull(m_ZohoTaxRatesByName.get(v_ThisXeroTaxRateName))) 
  89.                  { 
  90.                      m_OutputTaxRates.put(v_ThisXeroTaxRateRef,v_ThisXeroTaxRateRate)
  91.                  } 
  92.                  else if(!isNull(m_ZohoTaxRatesByName.get(v_ThisXeroTaxRateRef))) 
  93.                  { 
  94.                      m_OutputTaxRates.put(v_ThisXeroTaxRateRef,v_ThisXeroTaxRateRate)
  95.                  } 
  96.                  else if(!isNull(m_ZohoTaxRatesByRate.get(v_ThisXeroTaxRateRate))) 
  97.                  { 
  98.                      m_OutputTaxRates.put(v_ThisXeroTaxRateRef,v_ThisXeroTaxRateRate)
  99.                  } 
  100.                  // 
  101.                  // list to copy in Zoho (copy and paste) 
  102.                  m_ZohoCrmTaxRatesList = Map()
  103.                  m_ZohoCrmTaxRatesList.put(v_ThisXeroTaxRateRef,v_ThisXeroTaxRateRate)
  104.                  l_ZohoCrmTaxRatesList.add(m_ZohoCrmTaxRatesList)
  105.              } 
  106.          } 
  107.          info "Copy this list: "
  108.          info l_ZohoCrmTaxRatesList; 
  109.          v_Output = m_OutputTaxRates.toString()
  110.      } 
  111.      return v_Output; 
  112.  } 
Category: Zoho :: Article: 913

Add comment

Your rating:

Submit

Credit where Credit is Due:


Feel free to copy, redistribute and share this information. All that we ask is that you attribute credit and possibly even a link back to this website as it really helps in our search engine rankings.

Disclaimer: Please note that the information provided on this website is intended for informational purposes only and does not represent a warranty. The opinions expressed are those of the author only. We recommend testing any solutions in a development environment before implementing them in production. The articles are based on our good faith efforts and were current at the time of writing, reflecting our practical experience in a commercial setting.

Thank you for visiting and, as always, we hope this website was of some use to you!

Kind Regards,

Joel Lipman
www.joellipman.com

Accreditation

Badge - Zoho Creator Certified Developer Associate
Badge - Zoho Deluge Certified Developer
Badge - Certified Zoho CRM Developer

Donate & Support

If you like my content, and would like to support this sharing site, feel free to donate using a method below:

Paypal:
Donate to Joel Lipman via PayPal

Bitcoin:
Donate to Joel Lipman with Bitcoin bc1qf6elrdxc968h0k673l2djc9wrpazhqtxw8qqp4

Ethereum:
Donate to Joel Lipman with Ethereum 0xb038962F3809b425D661EF5D22294Cf45E02FebF

Please publish modules in offcanvas position.