using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Text; using System.Threading.Tasks; using System.Net.Http; using System.Net.Http.Headers; using Newtonsoft.Json.Linq; using Interop.QBFC15; using System.Text.RegularExpressions; using System.Windows.Forms; namespace AyaNovaQBI { internal class util { #region API stuff public static Guid QBI_INTEGRATION_ID { get { return new Guid("{82CD3609-4601-4C1A-9633-7836F92D2D06}"); } } public const string TEST_ROUTE = "notify/hello"; public const string API_BASE_ROUTE = "api/v8/"; private const int MAX_TRIES = 3;//max times to retry an api call before giving up private const int API_RETRY_DELAY = 3000;//pause in ms before retrying api call public static int HTTPCLIENT_TIMEOUT_SECONDS = 100;//changed by the setting in ops anyway, just a in-case sensible default here public static HttpClient client = null; //url once known to be good internal static string ApiBaseUrl { get; set; } //auth processes url for api and this is the best guess as to the client url to use for notification / help links etc internal static string GuessClientUrl { get; set; } internal static string JWT { get; set; } // internal static long AyaNovaUserId { get; set; } //probably don't need this, if I do then some code will need to be added to decode the JWT or at the server to get my currently logged in USER ID internal static string AyaNovaUserName { get; set; } internal static AuthorizationRoles AyaNovaUserRoles { get; set; } internal static UserType AyaNovaUserType { get; set; } internal static AyaNovaLicense ALicense { get; set; } = null; internal static Integration QBIntegration { get; set; } = null; internal static bool LOG_AVAILABLE { get; set; } = false; public static void InitClient() { if (client != null) { client.Dispose(); client = null; } client = new HttpClient(); client.Timeout = TimeSpan.FromSeconds(HTTPCLIENT_TIMEOUT_SECONDS); } /// /// Only a return value of "OK" is ok /// /// /// public static async Task InitAndConfirmAddressAsync(string serverUrl) { ApiBaseUrl = serverUrl; InitClient(); try { // TimeSpan tsDefault = client.Timeout; // client.Timeout = new TimeSpan(0, 0, 20); HttpResponseMessage response = await client.GetAsync(serverUrl + TEST_ROUTE); // client.Timeout = tsDefault; if (response.IsSuccessStatusCode) return "OK"; else return "Failed: " + response.StatusCode.ToString(); } catch (Exception ex) { while (ex.InnerException != null) ex = ex.InnerException; return "Failed exception: \r\n" + ex.Message; } } public async static Task AuthenticateAsync(string login, string password = null) { InitClient(); if (password == null) password = login; dynamic creds = new JObject(); creds.login = login; creds.password = password; var requestMessage = new HttpRequestMessage(HttpMethod.Post, ApiBaseUrl + "auth"); requestMessage.Content = new StringContent(creds.ToString(), System.Text.Encoding.UTF8, "application/json"); HttpResponseMessage response; try { response = await client.SendAsync(requestMessage); } catch (HttpRequestException ex) { var Err = ex.Message; var InnerErr = ""; if (ex.InnerException != null) InnerErr = ex.InnerException.Message; throw new Exception("Authentication error, route: AUTH\r\nError:" + Err + "\r\nInner error:" + InnerErr); } var a = new ApiResponse() { HttpResponse = response, ObjectResponse = Parse(await response.Content.ReadAsStringAsync()) }; if (response.IsSuccessStatusCode) { bool tfa = a.ObjectResponse["data"]["tfa"].Value(); if (tfa == true) { //Get temp token from response var tempToken = a.ObjectResponse["data"]["tt"].Value(); //get 2fa code and send it in do { tfa t = new tfa(); if (t.ShowDialog() == System.Windows.Forms.DialogResult.Cancel) return false; string tfaPin = t.TFAPin; dynamic tfaCreds = new JObject(); tfaCreds.pin = tfaPin; tfaCreds.tempToken = tempToken; try { var tfaResponse = await TryPostAsync("auth/tfa-authenticate", tfaCreds.ToString(Newtonsoft.Json.Formatting.None));//trypost is no delay if (ProcessLoginResponse(tfaResponse)) return true; } catch (Exception ex) { if (!ex.Message.Contains("2003"))//if not an authentication error (bad pin) then throw it back up for display throw ex; //otherwise eat it and let them re-enter the pin again to mirror how ayanova web client works } } while (true); } else { return ProcessLoginResponse(a); } } else { if (a.ObjectResponse != null && a.ObjectResponse.ContainsKey("error")) { var errCode = a.ObjectResponse["error"]["code"].Value(); if (errCode.Contains("2003")) return false;//simple authentication error //some other error, possibly expired ayanova license etc, show it so it's clear why the login failed so they known it's not a creds issue var errMessage = a.ObjectResponse["error"]["message"].Value(); throw new Exception($"Code: {errCode} - {errMessage}"); } return false; } } private static bool ProcessLoginResponse(ApiResponse a) { if (a.ObjectResponse == null) return false; if (!a.HttpResponse.IsSuccessStatusCode) { return false; } if (a.ObjectResponse["data"]["l"].Value())//license lockout { throw new Exception("Server login from QBI is disabled due to AyaNova license issue"); } JWT = a.ObjectResponse["data"]["token"].Value(); AyaNovaUserName = a.ObjectResponse["data"]["name"].Value(); AyaNovaUserRoles = (AuthorizationRoles)(int.Parse(a.ObjectResponse["data"]["roles"].Value())); AyaNovaUserType = (UserType)(int.Parse(a.ObjectResponse["data"]["usertype"].Value())); return true; } public async static Task GetAsync(string route) { Exception FirstException = null; for (int x = 0; x < MAX_TRIES; x++) { try { return await TryGetAsync(route); } catch (Exception ex) { if (FirstException == null) FirstException = ex; } await Task.Delay(API_RETRY_DELAY); } //no luck re-throw the exception throw new Exception("API call failed after " + MAX_TRIES.ToString() + " attempts", FirstException); } private async static Task TryGetAsync(string route) { var requestMessage = new HttpRequestMessage(HttpMethod.Get, ApiBaseUrl + route); if (!string.IsNullOrWhiteSpace(JWT)) requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", JWT); HttpResponseMessage response = null; try { response = await client.SendAsync(requestMessage); } catch (HttpRequestException ex) { var Err = ex.Message; var InnerErr = ""; if (ex.InnerException != null) InnerErr = ex.InnerException.Message; throw new Exception("GET error, route: " + route + "\r\nError:" + Err + "\r\nInner error:" + InnerErr + "\r\nStack:" + ex.StackTrace); } var responseAsString = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { throw new Exception("GET error, code: " + (int)response.StatusCode + ", route: " + route + "\r\n" + responseAsString + "\r\n" + response.ReasonPhrase); } else return new ApiResponse() { HttpResponse = response, ObjectResponse = Parse(responseAsString) }; } public async static Task PostAsync(string route, dynamic d) { Exception FirstException = null; for (int x = 0; x < MAX_TRIES; x++) { try { return await TryPostAsync(route, d.ToString(Newtonsoft.Json.Formatting.None)); } catch (Exception ex) { if (FirstException == null) FirstException = ex; } await Task.Delay(API_RETRY_DELAY); } //no luck re-throw the exception throw new Exception("API call failed after " + MAX_TRIES.ToString() + " attempts", FirstException); } public async static Task PostAsync(string route, string s = null) { Exception FirstException = null; for (int x = 0; x < MAX_TRIES; x++) { try { return await TryPostAsync(route, s); } catch (Exception ex) { if (FirstException == null) FirstException = ex; } await Task.Delay(API_RETRY_DELAY); } //no luck re-throw the exception throw new Exception("API call failed after " + MAX_TRIES.ToString() + " attempts", FirstException); } internal async static Task TryPostAsync(string route, string postJson = null) { var requestMessage = new HttpRequestMessage(HttpMethod.Post, ApiBaseUrl + route); if (!string.IsNullOrWhiteSpace(JWT)) requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", JWT); if (!string.IsNullOrWhiteSpace(postJson)) requestMessage.Content = new StringContent(postJson, System.Text.Encoding.UTF8, "application/json"); HttpResponseMessage response = null; try { response = await client.SendAsync(requestMessage); } catch (HttpRequestException ex) { var Err = ex.Message; var InnerErr = ""; if (ex.InnerException != null) InnerErr = ex.InnerException.Message; throw new Exception("POST error, route: " + route + "\r\nError:" + Err + "\r\nInner error:" + InnerErr + "\r\nStack:" + ex.StackTrace + "\r\nPOSTED OBJECT:\r\n" + postJson); } var responseAsString = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { if (string.IsNullOrWhiteSpace(postJson)) postJson = "n/a"; throw new Exception("POST error, code: " + (int)response.StatusCode + ", route: " + route + "\r\n" + responseAsString + "\r\n" + response.ReasonPhrase + "\r\nPOSTED OBJECT:\r\n" + postJson); } else return new ApiResponse() { HttpResponse = response, ObjectResponse = Parse(responseAsString) }; } /// /// /// /// /// public static JObject Parse(string jsonString) { if (string.IsNullOrWhiteSpace(jsonString)) { return null; } return JObject.Parse(jsonString); } public static long IdFromResponse(ApiResponse a) { return a.ObjectResponse["data"]["id"].Value(); } public static uint CTokenFromResponse(ApiResponse a) { return a.ObjectResponse["data"]["concurrency"].Value(); } public async static Task PutAsync(string route, dynamic d) { Exception FirstException = null; for (int x = 0; x < MAX_TRIES; x++) { try { return await TryPutAsync(route, d.ToString(Newtonsoft.Json.Formatting.None)); } catch (Exception ex) { if (FirstException == null) FirstException = ex; } await Task.Delay(API_RETRY_DELAY); } //no luck re-throw the exception throw new Exception("API call failed after " + MAX_TRIES.ToString() + " attempts", FirstException); } public async static Task PutAsync(string route) { Exception FirstException = null; for (int x = 0; x < MAX_TRIES; x++) { try { return await TryPutAsync(route); } catch (Exception ex) { if (FirstException == null) FirstException = ex; } await Task.Delay(API_RETRY_DELAY); } //no luck re-throw the exception throw new Exception("API call failed after " + MAX_TRIES.ToString() + " attempts", FirstException); } public async static Task TryPutAsync(string route, string putJson = null) { var requestMessage = new HttpRequestMessage(HttpMethod.Put, ApiBaseUrl + route); if (!string.IsNullOrWhiteSpace(JWT)) requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", JWT); if (!string.IsNullOrWhiteSpace(putJson)) requestMessage.Content = new StringContent(putJson, System.Text.Encoding.UTF8, "application/json"); HttpResponseMessage response = null; try { response = await client.SendAsync(requestMessage); } catch (HttpRequestException ex) { var Err = ex.Message; var InnerErr = ""; if (ex.InnerException != null) InnerErr = ex.InnerException.Message; throw new Exception("PUT error, route: " + route + "\r\nError:" + Err + "\r\nInner error:" + InnerErr + "\r\nStack:" + ex.StackTrace + "\r\nPOSTED OBJECT:\r\n" + putJson); } var responseAsString = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { if (string.IsNullOrWhiteSpace(putJson)) putJson = "n/a"; throw new Exception("PUT error, code: " + (int)response.StatusCode + ", route: " + route + "\r\n" + responseAsString + "\r\n" + response.ReasonPhrase + "\r\nPUT OBJECT:\r\n" + putJson); } else return new ApiResponse() { HttpResponse = response, ObjectResponse = Parse(responseAsString) }; } public class ApiResponse { public HttpResponseMessage HttpResponse { get; set; } public JObject ObjectResponse { get; set; } public string CompactResponse { get { return ObjectResponse.ToString(Newtonsoft.Json.Formatting.None); } } } #endregion #region QB STUFF public static string QCountry = "US"; public static double QVersion = 1.1; public static string QCompanyFile = ""; public static string QCompanyName = ""; public static string sLastRequestXML = ""; public enum pfstat { OK = 0, Failed = 1, Cancel = 2 } public static List GetInvoiceableItems() { var random = new Random(); var l = new List(); for (int i = 1; i < random.Next(25, 100); i++) l.Add(new InvoiceableItem { Customer = $"Customer {random.Next(1, 5)}", Linked = random.Next(2) == 1, Project = $"project {i}", ServiceDate = DateTime.Now.ToString("g"), ServiceNumber = (40 + i).ToString(), Status = $"Waiting to be invoiced", StatusColor = "FF00FFAA", WorkorderId = 4 }); return l.OrderBy(x => x.Customer) .ThenBy(x => x.ServiceNumber) .ThenBy(x => x.ServiceDate) .ToList(); } public static async Task InitializeQBI(StringBuilder initErrors) { //COPY most of this code from qbi v7 becuase it has a lot of edge cases in it and it's complex and thorough, but break it into abstracted bits so can be replicated in other accounting add-on's more easily //This is pre-pfc block of stuff that doesn't map well from v7 qbi plugin to here so replicate it in spirit here but not much is copyable just the concepts //LOGIN to v8 first auth d = new auth(); if (d.ShowDialog() == System.Windows.Forms.DialogResult.Cancel) return false; //ACCOUNTING ROLE? if (!AyaNovaUserRoles.HasFlag(AuthorizationRoles.Accounting)) { initErrors.AppendLine($"User must have the \"Accounting\" Role to use QBI\r\n"); return false; } //Get license var r = await GetAsync("license"); ALicense = r.ObjectResponse["data"]["license"].ToObject(); //UNEXPIRED AYANOVA LICENSE? if (ALicense.licenseWillExpire && ALicense.licenseExpiration < DateTime.UtcNow) { initErrors.AppendLine($"AyaNova license has expired {ALicense.licenseExpiration.ToLocalTime().ToString("g")}"); return false; } //QBI LICENSED? if (ALicense.features.FirstOrDefault(z => z.Feature == "QBI") == null) { initErrors.AppendLine("QBI not licensed"); return false; } //BUILD DATE VERSION ALLOWED? if (ALicense.maintenanceExpiration < Timestamp.BuildAt) { initErrors.AppendLine("NOT LICENSED!\r\n\r\nThis QBI plugin was built " + Timestamp.BuildAt.ToString("g") + "\r\n" + "but the licensed support and updates subscription has ended on " + ALicense.maintenanceExpiration.ToLocalTime().ToString("g") + "\r\n" + "\r\nDowngrade back to your previous licensed QBI version or\r\npurchase a support and updates subscription to continue using this version of QBI."); return false; } //PFC - integration object check (fetch or create if not present) //Need this early so can log any issues with other aspects if (!await IntegrationCheck(initErrors)) return false; LOG_AVAILABLE = true; //Validate QB connection can be made and open connection and start session with QB (see Util.QBValidate in v7 try { var pfstatus = await QBValidate(); } catch(Exception ex) { initErrors.AppendLine($"QuickBooks connection validation failed before connecting\r\n{ex.Message}"); return false; } //once connected collect the country, version we are dealing with (Util.qbValidate) //confirm qb is 2008 or newer and bail if not (util.qbvalidate) //cache company name and other qb info //PFC - PopulateQBListCache() //PFC - PopulateAyaListCache() //PFC - Validate settings, create if necessary (Util.ValidateSettings()) and save //TODO: if QBIntegration.IntegrationData==null then nothing is set yet and it's fresh so trigger the setup stuff //PFC - verify integration mapped objects still exist at each end (Util.PreFlightCheck() line 199) //DONE return true; } /// /// Ensure existance of QBI Integration object /// /// /// public static async Task IntegrationCheck(StringBuilder initErrors) { ApiResponse r = null; try { r = await GetAsync($"integration/exists/{QBI_INTEGRATION_ID}"); if (r.ObjectResponse["data"].Value() == false) { //doesn't exist, need to create it now QBIntegration = new Integration(); QBIntegration.IntegrationAppId = QBI_INTEGRATION_ID; QBIntegration.Active = true; QBIntegration.Name = "QBI - QuickBooks Desktop integration"; r = await PostAsync("integration", Newtonsoft.Json.JsonConvert.SerializeObject(QBIntegration)); var id = IdFromResponse(r); } else { //Exists, fetch it check if active then we're done here r = await GetAsync($"integration/{QBI_INTEGRATION_ID}"); QBIntegration = r.ObjectResponse["data"].ToObject(); if (!QBIntegration.Active) { initErrors.AppendLine("QBI Integration is currently deactivated and can not be used\r\nThis setting can be changed in AyaNova in the Administration section -> Integrations -> QuickBooks Desktop integration record\r\nSet to active and save to enable QBI"); return false; } } return true; } catch (Exception ex) { initErrors.AppendLine("Error fetching QBI Integration object"); initErrors.AppendLine(ex.Message); initErrors.AppendLine(r.CompactResponse); return false; } } public static async Task IntegrationLog(string logLine) { await PostAsync("integration/log", Newtonsoft.Json.JsonConvert.SerializeObject(new NameIdItem { Id = QBIntegration.Id, Name = logLine })); } #region PFC QB side /// /// Open QB connection /// gather info required for future /// transactions /// public async static Task QBValidate() { // We want to know if we begun a session so we can end it if an // error happens bool booSessionBegun = false; bool bConnected = false; // Create the session manager object using QBFC QBSessionManager sessionManager = new QBSessionManager(); while (!booSessionBegun) { try { sessionManager.OpenConnection2("", "AyaNova QBI", ENConnectionType.ctLocalQBDLaunchUI); bConnected = true; sessionManager.BeginSession("", ENOpenMode.omDontCare); booSessionBegun = true; } catch (System.Runtime.InteropServices.COMException ex) { if (bConnected) sessionManager.CloseConnection(); if (ex.ErrorCode == -2147220458 || ex.ErrorCode == -2147220472 || ex.Message.Contains("Could not start")) { if (MessageBox.Show( "QuickBooks doesn't appear to be running on this computer.\r\n" + "Start QuickBooks and open your company file now before proceeding.", "AyaNova QBI: Pre flight check", MessageBoxButtons.RetryCancel, MessageBoxIcon.Information) == DialogResult.Cancel) return pfstat.Cancel; } else { await IntegrationLog("PFC: QBValidate connect unanticipated exception: " + ex.Message + "\r\nError code:" + ex.ErrorCode.ToString()); MessageBox.Show(ex.Message + "\r\nError code:" + string.Format("(HRESULT:0x{0:X8})", ex.ErrorCode)); return pfstat.Cancel; } } } try { //Get the country and latest version supported QVersion = 0; QCountry = "US";//default string[] versions = sessionManager.QBXMLVersionsForSession; double vers = 0; Regex rxVersion = new Regex("[0-9.,]+", RegexOptions.Multiline | RegexOptions.Compiled); foreach (string s in versions) { if (s.StartsWith("CA") || s.StartsWith("ca")) { QCountry = "CA"; } else if (s.StartsWith("UK") || s.StartsWith("uk")) { QCountry = "UK"; } //case 262 //strip out only numeric bit regardless of what text is in there //including commas if it's french canadian and using a comma instead of a decimal point //(the safe to double will handle the comma if present) string strVersionNumber = rxVersion.Match(s).Value; vers = SafeToDouble(strVersionNumber); if (vers > QVersion) { QVersion = vers; } } if (QVersion < 6.0) { await IntegrationLog("PFC: Failed, QuickBooks found is too old, 2008 or higher is required, prompted user to run QB update utility to be able to proceed"); CopyableMessageBox cp = new CopyableMessageBox( "You seem to be running QuickBooks older than 2008\r\n" + "You must update to 2008 or higher before you can use AyaNova QBI.\r\n\r\n" + "(If you are running QuickBooks 2008 or higher and still getting this error, ensure that you are also using QBFC7 or higher)\r\n\r\n" + "VERSION FOUND = " + QVersion ); cp.ShowDialog(); return pfstat.Failed; } //Get the company file to open QCompanyFile = sessionManager.GetCurrentCompanyFileName(); // if(QCountry=="US") // { //Get company data // IY: Get the RequestMsgSet based on the correct QB Version IMsgSetRequest requestSet = getLatestMsgSetRequest(sessionManager); // IY: Initialize the message set request object requestSet.Attributes.OnError = ENRqOnError.roeStop; // IY: Add the request to the message set request object ICompanyQuery cq = requestSet.AppendCompanyQueryRq(); IMsgSetResponse responseSet = sessionManager.DoRequests(requestSet); IResponse response = responseSet.ResponseList.GetAt(0); if (response.StatusCode != 0) { await IntegrationLog("PFC: Failed Company query:" + response.StatusMessage + ", " + response.StatusCode.ToString()); throw new ApplicationException("PFC: Failed Company query:" + response.StatusMessage + ", " + response.StatusCode.ToString()); } ICompanyRet cl = response.Detail as ICompanyRet; QCompanyName = ProcessQBString(cl.CompanyName); requestSet.ClearRequests(); //---------------- // } // else // QCompanyName=QCompanyFile; // Close the session and connection with QuickBooks sessionManager.EndSession(); booSessionBegun = false; sessionManager.CloseConnection(); return pfstat.OK; } catch (Exception ex) { //MessageBox.Show(ex.Message.ToString() + "\nStack Trace: \n" + ex.StackTrace + "\nExiting the application"); await IntegrationLog("PFC: Failed with exception:" + ex.Message); if (booSessionBegun) { sessionManager.EndSession(); sessionManager.CloseConnection(); } throw; } } #endregion pfc qb side #region QB Specific utils /// /// /// /// /// public static IMsgSetRequest getLatestMsgSetRequest(QBSessionManager sessionManager) { // Find and adapt to supported version of QuickBooks short qbXMLMajorVer = 0; short qbXMLMinorVer = 0; if (QVersion >= 5.0) { qbXMLMajorVer = 5; qbXMLMinorVer = 0; } else if (QVersion >= 4.0) { qbXMLMajorVer = 4; qbXMLMinorVer = 0; } else if (QVersion >= 3.0) { qbXMLMajorVer = 3; qbXMLMinorVer = 0; } else if (QVersion >= 2.0) { qbXMLMajorVer = 2; qbXMLMinorVer = 0; } else if (QVersion >= 1.1) { qbXMLMajorVer = 1; qbXMLMinorVer = 1; } else { qbXMLMajorVer = 1; qbXMLMinorVer = 0; throw new System.NotSupportedException("QuickBooks 1.0 (2002 initial release) is not supported, use QuickBooks online update feature now."); } // Create the message set request object IMsgSetRequest requestMsgSet = sessionManager.CreateMsgSetRequest(QCountry, qbXMLMajorVer, qbXMLMinorVer); return requestMsgSet; } /// /// Handle null qb string types with "aplomb" :) /// /// /// private static string ProcessQBString(IQBStringType qs) { if (qs == null) return ""; return qs.GetValue(); } /// /// Case 262 addition /// Convert a string to a double handling french canadian locale /// , since qb is not locale aware in it's api /// conversion can fail because .net expects a comma that isn't there /// /// case 262 redux, changed to use tryparse and explicitly replace comma if present /// for french canadian versions /// /// /// /// public static double SafeToDouble(string s) { if (string.IsNullOrEmpty(s)) return 0; if (s.Contains(",")) s = s.Replace(",", "."); double retvalue = 0; if (!double.TryParse(s, out retvalue)) { try { retvalue = Convert.ToDouble(s, System.Globalization.CultureInfo.InvariantCulture); } catch (System.FormatException) { CopyableMessageBox cp = new CopyableMessageBox("SafeToDouble: Can't parse QB string double version number:\r\n[" + s + "]\r\nPlease copy and send this message to AyaNova tech support at support@ayanova.com"); cp.ShowDialog(); throw new System.ApplicationException("SafeToDouble: Can't parse QB string double value number: \"" + s + "\""); } } return retvalue; } #endregion qb specific utils #endregion qb stuff } }