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 quotes

ZohoCRM & Xero: Function to pull most recent quotes

What?
A follow-on article from my previous article ZohoCRM & Xero: Function to pull most recent invoices - along with their Contacts and Items (accounts/contact & products respectively).

Why?
This took me so much longer than I thought it would. It was meant to be based on the pull from invoices article I wrote earlier but with quotes from Xero, things panned out differently:
  • Date/Time values don't include a timezone
  • Issues with tax rates meant replicating a tax list copy from Xero to Zoho
  • the Client doesn't use the Items module in Xero and instead puts the product name in the description field

How?
Because my head is a little fried, I'm putting the two functions I used here and I'll document further if I remember. Note that the pre-amble is to generate a Xero access token which I documented in another article - it's behind a userwall because I usually charge for my Xero integration to Zoho CRM but user registration is free; you need to click on "Account" at the top of my website then login, then search for Xero.


fn_Xero_MapTaxRates
This function used to take a Xero TaxType name and return a Zoho Tax ID (64-bit unsigned 19 digit integer) but that's more use when writing to Zoho Books. Within ZohoCRM, we only need the name and in this case the percentage rate:
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 (Joel Lipman)
					- Initial release
	Date Modified:	???
					- ???

	More Information:
					Ensure that all possible tax rates in Xero match those in Zoho; the output of this function will give you a copy&paste list

	******************************************************************************* */
	// 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 (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; the output of this function will give you a copy&paste list 
  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.  } 


fn_Xero_GetQuotes
The main function that took me over half a day:
copyraw
string standalone.fn_Xero_GetQuotes()
{
/* *******************************************************************************
	Function:       string standalone.fn_Xero_GetQuotes()
	Label:          Fn - Xero - Get Quotes
	Trigger:        Standalone / On-Demand / Callable
	Purpose:	 	Function to get the first page of most recent quotes from Xero and pull them into ZohoCRM
	Inputs:         -
	Outputs:        -

	Date Created:   2025-10-13 (Joel Lipman)
					- Initial release
					- Parsing Xero Dates and Times to include timezone of Xero instance for accurate time comparisons
	Date Modified:	???
					- ???

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

Caveat(s):
  • Tax rates have to have the same name in both CRM and Xero
  • The code above is for EU / UK clients only (change the TLD as applicable)
  • The code above accounts for clients not using the Items module and only the description field per line item.
  • pageSize seems to be completely ignored by Xero in this function

Category: Zoho :: Article: 914

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.