diff --git a/src/AplosConnector.Common/Const/TimeZoneConst.cs b/src/AplosConnector.Common/Const/TimeZoneConst.cs deleted file mode 100644 index 38371db..0000000 --- a/src/AplosConnector.Common/Const/TimeZoneConst.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; - -namespace AplosConnector.Common.Const -{ - public static class TimeZoneConst - { - public static TimeZoneInfo Est - { - get { - TimeZoneInfo easternTime; - try - { - easternTime = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time"); //This fails on Ubuntu, so have to look for it by Id. - } - catch - { - easternTime = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern"); - } - return easternTime; - } - } - } -} diff --git a/src/AplosConnector.Common/Entities/Pex2AplosMappingEntity.cs b/src/AplosConnector.Common/Entities/Pex2AplosMappingEntity.cs index 85f05e0..e13cb6d 100644 --- a/src/AplosConnector.Common/Entities/Pex2AplosMappingEntity.cs +++ b/src/AplosConnector.Common/Entities/Pex2AplosMappingEntity.cs @@ -15,6 +15,7 @@ public Pex2AplosMappingEntity() } public bool AutomaticSync { get; set; } + public bool IsSyncing { get; set; } public bool IsManualSync { get; set; } public string PEXExternalAPIToken { get; set; } public int PEXBusinessAcctId { get; set; } diff --git a/src/AplosConnector.Common/Extensions/DateTimeExtension.cs b/src/AplosConnector.Common/Extensions/DateTimeExtension.cs deleted file mode 100644 index a5c2ee6..0000000 --- a/src/AplosConnector.Common/Extensions/DateTimeExtension.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using AplosConnector.Common.Const; - -namespace AplosConnector.Common.Extensions -{ - public static class DateTimeExtension - { - public static DateTime ToTimeZone(this DateTime _this, TimeZoneInfo tz) - { - return TimeZoneInfo.ConvertTime(_this, tz); - } - - public static DateTime ToEst(this DateTime _this) - { - return _this.ToTimeZone(TimeZoneConst.Est); - } - } -} diff --git a/src/AplosConnector.Common/Models/MappingSettingsModel.cs b/src/AplosConnector.Common/Models/MappingSettingsModel.cs index 084455a..d063be7 100644 --- a/src/AplosConnector.Common/Models/MappingSettingsModel.cs +++ b/src/AplosConnector.Common/Models/MappingSettingsModel.cs @@ -6,6 +6,7 @@ namespace AplosConnector.Common.Models public class MappingSettingsModel { public bool AutomaticSync { get; set; } + public bool IsSyncing { get; set; } public bool IsManualSync { get; set; } public DateTime ConnectedOn { get; set; } public DateTime? LastSync { get; set; } diff --git a/src/AplosConnector.Common/Models/Pex2AplosMappingModel.cs b/src/AplosConnector.Common/Models/Pex2AplosMappingModel.cs index 2fea2e2..853bdd0 100644 --- a/src/AplosConnector.Common/Models/Pex2AplosMappingModel.cs +++ b/src/AplosConnector.Common/Models/Pex2AplosMappingModel.cs @@ -15,6 +15,7 @@ public Pex2AplosMappingModel() public void UpdateFromSettings(MappingSettingsModel mapping) { AutomaticSync = mapping.AutomaticSync; + IsSyncing = mapping.IsSyncing; IsManualSync = mapping.IsManualSync; CreatedUtc = mapping.ConnectedOn; SyncApprovedOnly = mapping.SyncApprovedOnly; @@ -86,6 +87,7 @@ public MappingSettingsModel ToStorageModel() return new MappingSettingsModel { AutomaticSync = AutomaticSync, + IsSyncing = IsSyncing, IsManualSync = IsManualSync, ConnectedOn = CreatedUtc, SyncApprovedOnly = SyncApprovedOnly, @@ -149,6 +151,7 @@ public MappingSettingsModel ToStorageModel() } public bool AutomaticSync { get; set; } + public bool IsSyncing { get; set; } public bool IsManualSync { get; set; } public string PEXExternalAPIToken { get; set; } public int PEXBusinessAcctId { get; set; } diff --git a/src/AplosConnector.Common/Models/PexConnectionDetailModel.cs b/src/AplosConnector.Common/Models/PexConnectionDetailModel.cs index d3a3b97..b3a808a 100644 --- a/src/AplosConnector.Common/Models/PexConnectionDetailModel.cs +++ b/src/AplosConnector.Common/Models/PexConnectionDetailModel.cs @@ -10,6 +10,7 @@ public class PexConnectionDetailModel public bool AplosConnection { get; set; } public bool SyncingSetup { get; set; } public bool VendorsSetup { get; set; } + public bool IsSyncing { get; set; } public DateTime? LastSync { get; set; } public decimal? AccountBalance { get; set; } public bool UseBusinessBalanceEnabled { get; set; } diff --git a/src/AplosConnector.Common/Models/TimePeriod.cs b/src/AplosConnector.Common/Models/TimePeriod.cs deleted file mode 100644 index 7ca3585..0000000 --- a/src/AplosConnector.Common/Models/TimePeriod.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; - -namespace AplosConnector.Common.Models -{ - public struct TimePeriod - : IEquatable - { - public TimePeriod(DateTime start, DateTime end) - { - if (start > end) - { - throw new ArgumentOutOfRangeException(nameof(start), "The start date cannot be after the end date."); - } - - Start = start; - End = end; - } - - public DateTime Start { get; } - - public DateTime End { get; } - - public static bool operator ==(TimePeriod? first, TimePeriod? second) => AreEqual(first, second); - - public static bool operator !=(TimePeriod? first, TimePeriod? second) => !AreEqual(first, second); - - public static bool AreEqual(TimePeriod? first, TimePeriod? second) - { - if (first is null && second is null) - { - return true; - } - if (first is null || second is null) - { - return false; - } - - return first.Equals(second); - } - - public bool Equals(TimePeriod other) - { - // equality by "value" - return Start == other.Start && End == other.End; - } - - public override bool Equals(object? obj) => (obj is TimePeriod other) && Equals(other); - - public override int GetHashCode() => HashCode.Combine(Start, End); - - public override string ToString() => $"{Start:u} - {End:u}"; - } - - public static class TimePeriodExtensions - { - public static IEnumerable Batch(this TimePeriod timePeriod, TimeSpan batchSizes) - { - if (batchSizes < TimeSpan.Zero) - { - throw new ArgumentOutOfRangeException(nameof(batchSizes), "Batch size must be a positive TimeSpan."); - } - - IEnumerable Batch() - { - if ((timePeriod.End - timePeriod.Start) <= batchSizes) - { - yield return timePeriod; - } - else - { - var start = timePeriod.Start; - DateTime? end; - - do - { - end = start.Add(batchSizes); - - if (end > timePeriod.End) - { - end = timePeriod.End; - } - - yield return new TimePeriod(start, end.Value); - - start = end.Value.Add(TimeSpan.FromMilliseconds(1)); - } - while (start < timePeriod.End && end < timePeriod.End); - } - } - - return Batch(); - } - } -} diff --git a/src/AplosConnector.Common/Services/AplosIntegrationService.cs b/src/AplosConnector.Common/Services/AplosIntegrationService.cs index fc15eb5..fda1fa0 100644 --- a/src/AplosConnector.Common/Services/AplosIntegrationService.cs +++ b/src/AplosConnector.Common/Services/AplosIntegrationService.cs @@ -6,7 +6,6 @@ using Aplos.Api.Client.Models.Response; using AplosConnector.Common.Const; using AplosConnector.Common.Enums; -using AplosConnector.Common.Extensions; using AplosConnector.Common.Models; using AplosConnector.Common.Models.Aplos; using AplosConnector.Common.Models.Settings; @@ -454,89 +453,98 @@ public async Task ValidateAplosApiCredentials(Pex2AplosMappingModel mappin public async Task Sync(Pex2AplosMappingModel mapping, CancellationToken cancellationToken) { - _logger.LogInformation("C# Queue trigger function processing."); + var utcNow = DateTime.UtcNow; - if (mapping.PEXBusinessAcctId == default) - { - _logger.LogWarning($"C# Queue trigger function completed. Business account ID is {mapping.PEXBusinessAcctId}"); - return; - } - - using (_logger.BeginScope(GetLoggingScopeForSync(mapping))) + try { + _logger.LogInformation("C# Queue trigger function processing."); - // Let's refresh Aplos API tokens before sync start and interrupt sync processor in case of invalidity - var aplosAccessToken = await GetAplosAccessToken(mapping, cancellationToken); - if (string.IsNullOrEmpty(aplosAccessToken)) + if (mapping.PEXBusinessAcctId == default) { - _logger.LogCritical($"Integration for business {mapping.PEXBusinessAcctId} is not working. Aplos API token is invalid."); + _logger.LogWarning($"C# Queue trigger function completed. Business account ID is {mapping.PEXBusinessAcctId}"); return; } - await EnsurePartnerInfoPopulated(mapping, cancellationToken); - - var utcNow = DateTime.UtcNow; - List additionalFees = default; - try + using (_logger.BeginScope(GetLoggingScopeForSync(mapping))) { - additionalFees = await SyncTransactions(_logger, mapping, utcNow, cancellationToken); - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"Exception during transactions sync for business: {mapping.PEXBusinessAcctId}"); - } - try - { - await SyncBusinessAccountTransactions(_logger, mapping, utcNow, additionalFees, cancellationToken); - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"Exception during business account transactions sync for business: {mapping.PEXBusinessAcctId}."); - } + // Let's refresh Aplos API tokens before sync start and interrupt sync processor in case of invalidity + var aplosAccessToken = await GetAplosAccessToken(mapping, cancellationToken); + if (string.IsNullOrEmpty(aplosAccessToken)) + { + _logger.LogCritical($"Integration for business {mapping.PEXBusinessAcctId} is not working. Aplos API token is invalid."); + return; + } - try - { - await SyncFundsToPex(_logger, mapping, cancellationToken); - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"Exception during {nameof(SyncFundsToPex)} for business: {mapping.PEXBusinessAcctId}."); - } + await EnsurePartnerInfoPopulated(mapping, cancellationToken); - try - { - await SyncExpenseAccountsToPex(_logger, mapping, cancellationToken); - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"Exception during {nameof(SyncExpenseAccountsToPex)} for business: {mapping.PEXBusinessAcctId}."); - } + mapping.IsSyncing = true; + await _mappingStorage.UpdateAsync(mapping, cancellationToken); - try - { - await SyncAplosTagsToPex(_logger, mapping, cancellationToken); - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"Exception during {nameof(SyncAplosTagsToPex)} for business: {mapping.PEXBusinessAcctId}."); - } + List additionalFees = default; + try + { + additionalFees = await SyncTransactions(_logger, mapping, utcNow, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Exception during transactions sync for business: {mapping.PEXBusinessAcctId}"); + } - try - { - await SyncAplosTaxTagsToPex(_logger, mapping, cancellationToken); - } - catch (Exception ex) - { - _logger.LogWarning(ex, $"Exception during {nameof(SyncAplosTaxTagsToPex)} for business: {mapping.PEXBusinessAcctId}."); + try + { + await SyncBusinessAccountTransactions(_logger, mapping, utcNow, additionalFees, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Exception during business account transactions sync for business: {mapping.PEXBusinessAcctId}."); + } + + try + { + await SyncFundsToPex(_logger, mapping, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Exception during {nameof(SyncFundsToPex)} for business: {mapping.PEXBusinessAcctId}."); + } + + try + { + await SyncExpenseAccountsToPex(_logger, mapping, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Exception during {nameof(SyncExpenseAccountsToPex)} for business: {mapping.PEXBusinessAcctId}."); + } + + try + { + await SyncAplosTagsToPex(_logger, mapping, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Exception during {nameof(SyncAplosTagsToPex)} for business: {mapping.PEXBusinessAcctId}."); + } + + try + { + await SyncAplosTaxTagsToPex(_logger, mapping, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, $"Exception during {nameof(SyncAplosTaxTagsToPex)} for business: {mapping.PEXBusinessAcctId}."); + } } + _logger.LogInformation("C# Queue trigger function completed."); + } + finally + { mapping.LastSyncUtc = utcNow; + mapping.IsSyncing = false; await _mappingStorage.UpdateAsync(mapping, cancellationToken); - } - - _logger.LogInformation("C# Queue trigger function completed."); } private async Task SyncAplosTagsToPex(ILogger _logger, Pex2AplosMappingModel mapping, CancellationToken cancellationToken) @@ -901,9 +909,9 @@ private async Task> SyncTransactions( if (!mapping.SyncTransactions && !mapping.SyncInvoices) return default; var startDateUtc = GetStartDateUtc(mapping, utcNow, _syncSettings); - var startDate = startDateUtc.ToEst(); + var startDate = startDateUtc.ToStartOfDay(TimeZones.EST); var endDateUtc = GetEndDateUtc(mapping.EndDateUtc, utcNow); - var endDate = endDateUtc.ToEst(); + var endDate = endDateUtc.ToEndOfDay(TimeZones.EST); if (startDate.Date >= endDate.Date) { @@ -1200,7 +1208,7 @@ private async Task> SyncTransactions( { syncCount++; _logger.LogInformation($"Synced transaction {transaction.TransactionId}"); - var syncedNoteText = $"{PexCardConst.SyncedWithAplosNote} on {DateTime.UtcNow.ToEst():MM/dd/yyyy h:mm tt}"; + var syncedNoteText = $"{PexCardConst.SyncedWithAplosNote} on {DateTime.UtcNow:O}"; await _pexApiClient.AddTransactionNote(mapping.PEXExternalAPIToken, transaction, syncedNoteText, cancellationToken); } else if (transactionSyncResult == TransactionSyncResult.Failed) @@ -1275,9 +1283,9 @@ private async Task SyncBusinessAccountTransactions( if (!mapping.SyncTransfers && !mapping.SyncPexFees && !mapping.SyncRebates && !mapping.SyncInvoices) return; var startDateUtc = GetStartDateUtc(mapping, utcNow, _syncSettings); - var startDate = startDateUtc.ToEst(); + var startDate = startDateUtc.ToStartOfDay(TimeZones.EST); var endDateUtc = GetEndDateUtc(mapping.EndDateUtc, utcNow); - var endDate = endDateUtc.ToEst(); + var endDate = endDateUtc.ToEndOfDay(TimeZones.EST); if (startDate.Date >= endDate.Date) { @@ -1293,6 +1301,7 @@ private async Task SyncBusinessAccountTransactions( var allBusinessAccountTransactions = await _pexApiClient.GetBusinessAccountTransactions(mapping.PEXExternalAPIToken, syncTimePeriod.Start, syncTimePeriod.End, cancelToken: cancellationToken); await SyncTransfers(_logger, mapping, allBusinessAccountTransactions, aplosTransactions, cancellationToken); + await SyncPexFees(_logger, mapping, allBusinessAccountTransactions, aplosTransactions, additionalFeeTransactions, cancellationToken); await SyncRebates(_logger, mapping, allBusinessAccountTransactions, aplosTransactions, cancellationToken); @@ -1465,8 +1474,8 @@ private async Task SyncInvoices( foreach (var invoiceAllocationModel in invoiceAllocations) { - var isFeeAllocation = invoiceAllocationModel.TagValue == null - && (invoiceAllocationModel.TransactionTypeCategory == TransactionCategory.CardAccountFee + var isFeeAllocation = invoiceAllocationModel.TagValue == null + && (invoiceAllocationModel.TransactionTypeCategory == TransactionCategory.CardAccountFee || invoiceAllocationModel.TransactionTypeCategory == TransactionCategory.BusinessAccountFee); @@ -1483,7 +1492,7 @@ private async Task SyncInvoices( var allocationTagValue = new AllocationTagValue { Amount = invoiceAllocationModel.TotalAmount, - Allocation = new List {new() { Value = allocationValue } } + Allocation = new List { new() { Value = allocationValue } } }; var pexTagValues = new PexTagValuesModel @@ -1522,7 +1531,7 @@ private async Task SyncInvoices( continue; } - var amount = invoicePayment.Type == PaymentType.RebateCredit ? - invoicePayment.Amount : invoicePayment.Amount; + var amount = invoicePayment.Type == PaymentType.RebateCredit ? -invoicePayment.Amount : invoicePayment.Amount; var allocationTagValue = new AllocationTagValue { @@ -2051,8 +2060,19 @@ private static IDictionary GetLoggingScopeForRebate(TransactionM private static bool FilterRebateTransactions(TransactionModel t) { - return t.Description.Contains("Rebate Credit") || - t.Description.StartsWith("Prepaid customer rebate payout to business"); + return + // new way to identify rebates. but possibly 'only in prod'... (-___-") + t.TransactionTypeCategory == "BusinessRebate" || + + // Charge Rebates + // https://pexcard.visualstudio.com/Balance%20Engine/_git/Balance%20Engine?path=/BalanceEngine.Core/Services/RebateService.cs&version=GBmaster&line=599&lineEnd=600&lineStartColumn=1&lineEndColumn=1&lineStyle=plain&_a=contents + // https://pexcard.visualstudio.com/Balance%20Engine/_git/Balance%20Engine?path=%2FBalanceEngine.Models%2FEnums%2FPaymentType.cs&version=GBmaster&_a=contents + t.Description.Equals("Rebate Credit") || + t.Description.Equals("Rebate Credit Reversal") || + + // Prepaid Rebates + // https://pexcard.visualstudio.com/Balance%20Engine/_git/Balance%20Engine?path=/BalanceEngine.Core/Services/RebateService.cs&version=GBmaster&line=261&lineEnd=262&lineStartColumn=1&lineEndColumn=1&lineStyle=plain&_a=contents + t.Description.Equals("Rebate payout"); } public DateTime GetStartDateUtc(Pex2AplosMappingModel model, DateTime utcNow, SyncSettingsModel settings) diff --git a/src/AplosConnector.Common/Services/StorageMappingService.cs b/src/AplosConnector.Common/Services/StorageMappingService.cs index 5a0debc..39a9bf8 100644 --- a/src/AplosConnector.Common/Services/StorageMappingService.cs +++ b/src/AplosConnector.Common/Services/StorageMappingService.cs @@ -44,6 +44,7 @@ public Pex2AplosMappingEntity Map(Pex2AplosMappingModel model) result = new Pex2AplosMappingEntity { AutomaticSync = model.AutomaticSync, + IsSyncing = model.IsSyncing, IsManualSync = false, // always reset to false when saving CreatedUtc = model.CreatedUtc, PEXBusinessAcctId = model.PEXBusinessAcctId, @@ -162,6 +163,7 @@ public Pex2AplosMappingModel Map(Pex2AplosMappingEntity model) result = new Pex2AplosMappingModel { AutomaticSync = model.AutomaticSync, + IsSyncing = model.IsManualSync, IsManualSync = model.IsManualSync, CreatedUtc = model.CreatedUtc, PEXBusinessAcctId = model.PEXBusinessAcctId, diff --git a/src/AplosConnector.Common/Temporal/DateTimeExtensions.cs b/src/AplosConnector.Common/Temporal/DateTimeExtensions.cs new file mode 100644 index 0000000..87d2be6 --- /dev/null +++ b/src/AplosConnector.Common/Temporal/DateTimeExtensions.cs @@ -0,0 +1,173 @@ +using System.Globalization; +using System.Threading; + +namespace System +{ + public static class DateTimeExtensions + { + public static bool IsWeekend(this DateTime dateTime) + { + return dateTime.DayOfWeek == DayOfWeek.Sunday || dateTime.DayOfWeek == DayOfWeek.Saturday; + } + + public static bool IsWeekday(this DateTime dateTime) + { + return !IsWeekend(dateTime); + } + + public static DateTime AddWeeks(this DateTime dateTime, int weeks) + { + return dateTime.AddDays(weeks * 7); + } + + public static int DaysUntil(this DateTime startDate, DateTime endDate) + { + return (endDate - startDate).Days; + } + + public static double PartialDaysUntil(this DateTime startDate, DateTime endDate) + { + return (endDate - startDate).TotalDays; + } + + public static int MonthsUntil(this DateTime startDate, DateTime endDate) + { + return (12 * (endDate.Year - startDate.Year)) + (endDate.Month - startDate.Month); + } + + public static bool IsLastDayOfMonth(this DateTime dateTime) + { + return dateTime.Day.Equals(DateTime.DaysInMonth(dateTime.Year, dateTime.Month)); + } + + public static DateTime LastDayOfMonth(this DateTime dateTime) + { + return dateTime.AddDays(DateTime.DaysInMonth(dateTime.Year, dateTime.Month) - dateTime.Day); + } + + public static bool IsFirstDayOfMonth(this DateTime dateTime) + { + return dateTime.Day.Equals(1); + } + + public static DateTime FirstDayOfMonth(this DateTime dateTime) + { + return dateTime.AddDays(-dateTime.Day + 1); + } + + public static bool IsLastDayOfFebruary(this DateTime dateTime) + { + return dateTime.Month.Equals(2) && dateTime.IsLastDayOfMonth(); + } + + public static bool IsLeapDay(this DateTime dateTime) + { + return dateTime.Month.Equals(2) && dateTime.Day.Equals(29) && DateTime.IsLeapYear(dateTime.Year); + } + + public static bool IsLeapYear(this DateTime dateTime) + { + return DateTime.IsLeapYear(dateTime.Year); + } + + public static int CountLeapDays(this DateTime startDate, DateTime endDate) + { + var nonLeapDays = 365 * (endDate.Year - startDate.Year); + return (endDate - startDate).Days - nonLeapDays; + } + + public static int DaysInMonth(this DateTime dateTime) + { + return DateTime.DaysInMonth(dateTime.Year, dateTime.Month); + } + + public static DateTime ToTimeZone(this DateTime dateTime, TimeZoneInfo timeZone) + { + return TimeZoneInfo.ConvertTime(dateTime, timeZone); + } + + public static DateTime ToStartOfDay(this DateTime dateTime, TimeZoneInfo timeZone) + { + var tzDateTime = dateTime.ToTimeZone(timeZone); + var tzStartOfDay = tzDateTime.Date; + + var utcResult = new DateTimeOffset(tzStartOfDay, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime; + + return utcResult; + } + + public static DateTime ToEndOfDay(this DateTime dateTime, TimeZoneInfo timeZone) + { + var tzDateTime = dateTime.ToTimeZone(timeZone); + var tzEndOfDay = new DateTime(tzDateTime.Year, tzDateTime.Month, tzDateTime.Day, 23, 59, 59, 999, tzDateTime.Kind); + + var utcResult = new DateTimeOffset(tzEndOfDay, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime; + + return utcResult; + } + + public static DateTime ToStartOfWeek(this DateTime dateTime, TimeZoneInfo timeZone) => ToStartOfWeek(dateTime, timeZone, Thread.CurrentThread.CurrentCulture); + + public static DateTime ToStartOfWeek(this DateTime dateTime, TimeZoneInfo timeZone, CultureInfo culture) + { + var tzDateTime = dateTime.ToTimeZone(timeZone); + var tzStartOfWeek = tzDateTime.AddDays(-(int)tzDateTime.DayOfWeek + (int)culture.DateTimeFormat.FirstDayOfWeek); + + var utcResult = new DateTimeOffset(tzStartOfWeek, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime.ToStartOfDay(timeZone); + + return utcResult; + } + + public static DateTime ToEndOfWeek(this DateTime dateTime, TimeZoneInfo timeZone) => ToEndOfWeek(dateTime, timeZone, Thread.CurrentThread.CurrentCulture); + + public static DateTime ToEndOfWeek(this DateTime dateTime, TimeZoneInfo timeZone, CultureInfo culture) + { + var tzDateTime = dateTime.ToTimeZone(timeZone); + var tzEndOfWeek = tzDateTime.AddDays(-(int)tzDateTime.DayOfWeek + (int)culture.DateTimeFormat.FirstDayOfWeek).AddDays(6); + + var utcResult = new DateTimeOffset(tzEndOfWeek, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime.ToEndOfDay(timeZone); + + return utcResult; + } + + public static DateTime ToStartOfMonth(this DateTime dateTime, TimeZoneInfo timeZone) + { + var tzDateTime = dateTime.ToTimeZone(timeZone); + var tzStartOfMonth = new DateTime(tzDateTime.Year, tzDateTime.Month, 1, tzDateTime.Hour, tzDateTime.Minute, tzDateTime.Second, tzDateTime.Millisecond, tzDateTime.Kind); + + var utcResult = new DateTimeOffset(tzStartOfMonth, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime.ToStartOfDay(timeZone); + + return utcResult; + } + + public static DateTime ToEndOfMonth(this DateTime dateTime, TimeZoneInfo timeZone) + { + var tzDateTime = dateTime.ToTimeZone(timeZone); + var tzEndOfMonth = new DateTime(tzDateTime.Year, tzDateTime.Month, DateTime.DaysInMonth(tzDateTime.Year, tzDateTime.Month), tzDateTime.Hour, tzDateTime.Minute, tzDateTime.Second, tzDateTime.Millisecond, tzDateTime.Kind); + + var utcResult = new DateTimeOffset(tzEndOfMonth, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime.ToEndOfDay(timeZone); + + return utcResult; + } + + public static DateTime ToStartOfYear(this DateTime dateTime, TimeZoneInfo timeZone) + { + var tzDateTime = dateTime.ToTimeZone(timeZone); + var tzStartOfYear = new DateTime(tzDateTime.Year, 1, 1, tzDateTime.Hour, tzDateTime.Minute, tzDateTime.Second, tzDateTime.Millisecond, tzDateTime.Kind); + + var utcResult = new DateTimeOffset(tzStartOfYear, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime.ToStartOfDay(timeZone); + + return utcResult; + } + + public static DateTime ToEndOfYear(this DateTime dateTime, TimeZoneInfo timeZone) + { + var tzDateTime = dateTime.ToTimeZone(timeZone); + var tzEndOfYear = new DateTime(tzDateTime.Year, 12, DateTime.DaysInMonth(tzDateTime.Year, 12), tzDateTime.Hour, tzDateTime.Minute, tzDateTime.Second, tzDateTime.Millisecond, tzDateTime.Kind); + + var utcResult = new DateTimeOffset(tzEndOfYear, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime.ToEndOfDay(timeZone); + + return utcResult; + } + } +} diff --git a/src/AplosConnector.Common/Temporal/TimePeriod.cs b/src/AplosConnector.Common/Temporal/TimePeriod.cs new file mode 100644 index 0000000..6961d1b --- /dev/null +++ b/src/AplosConnector.Common/Temporal/TimePeriod.cs @@ -0,0 +1,51 @@ +namespace System +{ + public struct TimePeriod + : IEquatable + { + public TimePeriod(DateTime start, DateTime end) + { + if (start > end) + { + throw new ArgumentOutOfRangeException(nameof(start), "The start date cannot be after the end date."); + } + + Start = start; + End = end; + } + + public DateTime Start { get; } + + public DateTime End { get; } + + public static bool operator ==(TimePeriod? first, TimePeriod? second) => AreEqual(first, second); + + public static bool operator !=(TimePeriod? first, TimePeriod? second) => !AreEqual(first, second); + + public static bool AreEqual(TimePeriod? first, TimePeriod? second) + { + if (first is null && second is null) + { + return true; + } + if (first is null || second is null) + { + return false; + } + + return first.Equals(second); + } + + public bool Equals(TimePeriod other) + { + // equality by "value" + return Start == other.Start && End == other.End; + } + + public override bool Equals(object? obj) => (obj is TimePeriod other) && Equals(other); + + public override int GetHashCode() => HashCode.Combine(Start, End); + + public override string ToString() => $"{Start:o} - {End:o}"; + } +} diff --git a/src/AplosConnector.Common/Temporal/TimePeriodExtensions.cs b/src/AplosConnector.Common/Temporal/TimePeriodExtensions.cs new file mode 100644 index 0000000..66e7f2f --- /dev/null +++ b/src/AplosConnector.Common/Temporal/TimePeriodExtensions.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace System +{ + public static class TimePeriodExtensions + { + public static IEnumerable Batch(this TimePeriod timePeriod, TimeSpan batchSize, TimeSpan? batchStep = default) + { + if (batchSize < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be a positive TimeSpan."); + } + if (batchStep < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(batchStep), "Batch step must be a positive TimeSpan."); + } + + IEnumerable Batch() + { + if ((timePeriod.End - timePeriod.Start) <= batchSize) + { + yield return timePeriod; + } + else + { + var start = timePeriod.Start; + DateTime? end; + + do + { + end = start.Add(batchSize); + + if (end > timePeriod.End) + { + end = timePeriod.End; + } + + yield return new TimePeriod(start, end.Value); + + start = end.Value.Add(batchStep.GetValueOrDefault(TimeSpan.FromMilliseconds(1))); + } + while (start < timePeriod.End && end < timePeriod.End); + } + } + + return Batch(); + } + } +} diff --git a/src/AplosConnector.Common/Temporal/TimeZones.cs b/src/AplosConnector.Common/Temporal/TimeZones.cs new file mode 100644 index 0000000..69390f3 --- /dev/null +++ b/src/AplosConnector.Common/Temporal/TimeZones.cs @@ -0,0 +1,120 @@ +namespace System +{ + public static class TimeZones + { + public static readonly TimeZoneInfo UTC = TimeZoneInfo.Utc; + public static DateTime ToUTC(this DateTime dateTime) => dateTime.ToTimeZone(UTC); + public static DateTime IsUTC(this DateTime dateTime) => NewDateTime(UTC, dateTime); + + public static readonly TimeZoneInfo EST = GetTimeZoneCrossPlatform("Eastern Standard Time", "US/Eastern"); + public static DateTime ToEST(this DateTime dateTime) => dateTime.ToTimeZone(EST); + public static DateTime IsEST(this DateTime dateTime) => NewDateTime(EST, dateTime); + public static DateTime CoalesceFromEST(this DateTime dateTime) => Coalesce(EST, dateTime); + + public static readonly TimeZoneInfo CST = GetTimeZoneCrossPlatform("Central Standard Time", "US/Indiana-Starke"); + public static DateTime ToCST(this DateTime dateTime) => dateTime.ToTimeZone(CST); + public static DateTime IsCST(this DateTime dateTime) => NewDateTime(CST, dateTime); + public static DateTime CoalesceFromCST(this DateTime dateTime) => Coalesce(CST, dateTime); + + public static readonly TimeZoneInfo MDT = GetTimeZoneCrossPlatform("Mountain Standard Time", "US/Mountain"); + public static DateTime ToMDT(this DateTime dateTime) => dateTime.ToTimeZone(MDT); + public static DateTime IsMDT(this DateTime dateTime) => NewDateTime(MDT, dateTime); + public static DateTime CoalesceFromMDT(this DateTime dateTime) => Coalesce(MDT, dateTime); + + public static readonly TimeZoneInfo PST = GetTimeZoneCrossPlatform("Pacific Standard Time", "US/Pacific"); + public static DateTime ToPST(this DateTime dateTime) => dateTime.ToTimeZone(PST); + public static DateTime IsPST(this DateTime dateTime) => NewDateTime(PST, dateTime); + public static DateTime CoalesceFromPST(this DateTime dateTime) => Coalesce(PST, dateTime); + + public static TimeZoneInfo GetUsaTimeZoneByCode(string threeLetterTimeZoneName) + { + switch (threeLetterTimeZoneName?.ToUpper()) + { + case nameof(UTC): + return UTC; + case nameof(EST): + return EST; + case nameof(CST): + return CST; + case nameof(MDT): + return MDT; + case nameof(PST): + return PST; + default: + throw new ArgumentOutOfRangeException(nameof(threeLetterTimeZoneName), $"Unexpected time zone name: {threeLetterTimeZoneName}."); + } + } + + public static TimeZoneInfo GetTimeZoneCrossPlatform(string systemTimeZoneName) + { + return GetTimeZoneCrossPlatform(systemTimeZoneName, systemTimeZoneName); + } + + public static TimeZoneInfo GetTimeZoneCrossPlatform(string windowsTimeZoneId, string unixTimeZoneId) + { + if (string.IsNullOrEmpty(windowsTimeZoneId)) + { + throw new ArgumentException($"'{nameof(windowsTimeZoneId)}' cannot be null or empty.", nameof(windowsTimeZoneId)); + } + if (string.IsNullOrEmpty(unixTimeZoneId)) + { + throw new ArgumentException($"'{nameof(unixTimeZoneId)}' cannot be null or empty.", nameof(unixTimeZoneId)); + } + + try + { + return TimeZoneInfo.FindSystemTimeZoneById(windowsTimeZoneId); + } + catch (Exception) + { + // https://github.com/dotnet/runtime/issues/20523 + return TimeZoneInfo.FindSystemTimeZoneById(unixTimeZoneId); + } + } + + public static DateTime Coalesce(this TimeZoneInfo timeZone, DateTime dateTime) + { + if (dateTime.Kind == DateTimeKind.Unspecified) + { + return dateTime.ToTimeZone(timeZone).ToUniversalTime(); + } + else if (dateTime.Kind == DateTimeKind.Local) + { + return dateTime.ToUniversalTime(); + } + else + { + return dateTime; + } + } + + public static DateTime NewDateTime(this TimeZoneInfo timeZone, DateTime dateTime) => NewDateTime(timeZone, dateTime.Year, dateTime.Month, dateTime.Day, dateTime.Hour, dateTime.Minute, dateTime.Second, dateTime.Millisecond); + + public static DateTime NewDateTime(this TimeZoneInfo timeZone, int year, int month, int day) + { + var tzDateTime = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Unspecified); + + var utcResult = new DateTimeOffset(tzDateTime, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime; + + return utcResult; + } + + public static DateTime NewDateTime(this TimeZoneInfo timeZone, int year, int month, int day, int hour, int minute, int second) + { + var tzDateTime = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified); + + var utcResult = new DateTimeOffset(tzDateTime, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime; + + return utcResult; + } + + public static DateTime NewDateTime(this TimeZoneInfo timeZone, int year, int month, int day, int hour, int minute, int second, int millisecond) + { + var tzDateTime = new DateTime(year, month, day, hour, minute, second, millisecond, DateTimeKind.Unspecified); + + var utcResult = new DateTimeOffset(tzDateTime, timeZone.GetUtcOffset(tzDateTime)).UtcDateTime; + + return utcResult; + } + } +} diff --git a/src/AplosConnector.Web/ClientApp/src/app/services/pex.service.ts b/src/AplosConnector.Web/ClientApp/src/app/services/pex.service.ts index a977797..fbeb54a 100644 --- a/src/AplosConnector.Web/ClientApp/src/app/services/pex.service.ts +++ b/src/AplosConnector.Web/ClientApp/src/app/services/pex.service.ts @@ -92,6 +92,7 @@ export interface PexConnectionDetailModel { aplosConnection: boolean, syncingSetup: boolean, vendorsSetup: boolean, + isSyncing: boolean; lastSync: string, accountBalance?: number; useBusinessBalanceEnabled: boolean; diff --git a/src/AplosConnector.Web/ClientApp/src/app/sync-history/sync-history.component.ts b/src/AplosConnector.Web/ClientApp/src/app/sync-history/sync-history.component.ts index 2e6a9d7..43b30f3 100644 --- a/src/AplosConnector.Web/ClientApp/src/app/sync-history/sync-history.component.ts +++ b/src/AplosConnector.Web/ClientApp/src/app/sync-history/sync-history.component.ts @@ -1,31 +1,49 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { AuthService } from '../services/auth.service'; import { MappingService, SettingsModel, SyncResultModel } from '../services/mapping.service'; +import { PexConnectionDetailModel, PexService } from '../services/pex.service'; +import { interval, of, Subscription } from 'rxjs'; +import { concatMap, timeout, catchError } from 'rxjs/operators'; @Component({ selector: 'app-sync-history', templateUrl: './sync-history.component.html', styleUrls: ['./sync-history.component.css'] }) -export class SyncHistoryComponent implements OnInit { +export class SyncHistoryComponent implements OnInit, OnDestroy { sessionId = ''; + connection?: PexConnectionDetailModel; settings: SettingsModel; syncResults: SyncResultModel[]; syncing = false; loadingHistory = false; - constructor(private auth: AuthService, private mapping: MappingService) { } + private readonly CONNECTION_POLLING_INTERVAL_MS = 60 * 1000; // 60 sec + private _connectionPollingSubscription: Subscription = Subscription.EMPTY; + + constructor(private auth: AuthService, private mapping: MappingService, private pex: PexService) { } + ngOnInit() { this.auth.sessionId.subscribe(sessionId => { this.sessionId = sessionId; if (sessionId) { - this.getSettings(); - this.getSyncResults(); + this.pex.getConnectionAccountDetail(this.sessionId) + .subscribe({ + next: () => { + this._pollForConnection(); + this.getSettings(); + this.getSyncResults(); + } + }); } }); } + ngOnDestroy(): void { + this._connectionPollingSubscription?.unsubscribe(); + } + private getSettings() { this.mapping.getSettings(this.sessionId).subscribe(settings => { if (settings) { @@ -51,12 +69,32 @@ export class SyncHistoryComponent implements OnInit { sync() { this.syncing = true; - this.mapping.sync(this.sessionId).subscribe( - () => { - this.syncing = false; - this.getSyncResults(); - } - ); + this.mapping.sync(this.sessionId).subscribe({ + next: () => this._pollForConnection() + }); + } + + private _pollForConnection(pollingIntervalMs?: number): void { + this._connectionPollingSubscription?.unsubscribe(); + this._connectionPollingSubscription = interval(pollingIntervalMs ?? this.CONNECTION_POLLING_INTERVAL_MS) + .pipe( + concatMap(() => + this.pex.getConnectionAccountDetail(this.sessionId) + .pipe( + timeout(30000), + catchError(() => of(undefined)), + concatMap((connection) => { + if (connection) { + if (this.connection?.isSyncing && !connection.isSyncing) { + this.getSyncResults(); + } + this.syncing = connection.isSyncing; + } + this.connection = connection; + return of(void 0); + }) + )), + ).subscribe(); } diff --git a/src/AplosConnector.Web/Controllers/MappingController.cs b/src/AplosConnector.Web/Controllers/MappingController.cs index f48fd6c..b69f61d 100644 --- a/src/AplosConnector.Web/Controllers/MappingController.cs +++ b/src/AplosConnector.Web/Controllers/MappingController.cs @@ -161,11 +161,14 @@ public async Task Sync(string sessionId, CancellationToken cancel var mapping = await _pex2AplosMappingStorage.GetByBusinessAcctIdAsync(session.PEXBusinessAcctId, cancellationToken); if (mapping == null) return NotFound(); - mapping.IsManualSync = true; + if (!mapping.IsSyncing) + { + mapping.IsManualSync = true; - await _aplosIntegrationService.Sync(mapping, CancellationToken.None); + await Task.Factory.StartNew(async () => await _aplosIntegrationService.Sync(mapping, CancellationToken.None)); - //await _mappingQueue.EnqueueMapping(mapping, CancellationToken.None); + //await _mappingQueue.EnqueueMapping(mapping, CancellationToken.None); + } return Ok(); } diff --git a/src/AplosConnector.Web/Controllers/PEXController.cs b/src/AplosConnector.Web/Controllers/PEXController.cs index 74ba589..eb5efdf 100644 --- a/src/AplosConnector.Web/Controllers/PEXController.cs +++ b/src/AplosConnector.Web/Controllers/PEXController.cs @@ -14,6 +14,7 @@ using LazyCache; using AplosConnector.Common.VendorCards; using AplosConnector.Common.Services.Abstractions; +using Aplos.Api.Client; namespace AplosConnector.Web.Controllers { @@ -175,6 +176,7 @@ public async Task> GetPexConnectionDetail { Email = mapping.PEXEmailAccount, Name = mapping.PEXNameAccount, + IsSyncing = mapping.IsSyncing, LastSync = mapping.LastSyncUtc, SyncingSetup = mapping.SyncInvoices || mapping.SyncTransactions || mapping.SyncTransfers || mapping.SyncPexFees || mapping.SyncTags || mapping.SyncFundsToPex || mapping.SyncTaxTagToPex }; @@ -220,7 +222,8 @@ public async Task> GetPexConnectionDetail } else { - connectionDetail.AplosConnection = await _aplosIntegrationService.ValidateAplosApiCredentials(mapping, cancellationToken); + connectionDetail.AplosConnection = true; + await _aplosIntegrationService.GetAplosAccounts(mapping, AplosApiClient.APLOS_ACCOUNT_CATEGORY_EXPENSE, cancellationToken); } } catch (Exception) diff --git a/tests/AplosConnector.Common.Tests/DateTimeExtensionTests.cs b/tests/AplosConnector.Common.Tests/DateTimeExtensionTests.cs index 07715ed..45a902d 100644 --- a/tests/AplosConnector.Common.Tests/DateTimeExtensionTests.cs +++ b/tests/AplosConnector.Common.Tests/DateTimeExtensionTests.cs @@ -1,5 +1,4 @@ -using AplosConnector.Common.Extensions; -using System; +using System; using Xunit; namespace AplosConnector.Common.Tests @@ -42,7 +41,7 @@ public void ToEst_DataDrivenTests(string inputDateS, string expectedDateS) DateTime expectedDate = DateTime.Parse(expectedDateS); //Act - DateTime actualDate = inputDate.ToEst(); + DateTime actualDate = inputDate.ToEST(); //Assert Assert.Equal(expectedDate, actualDate);