From 3bf6761c828a6a63c834a08040a29d43efd5313f Mon Sep 17 00:00:00 2001 From: Rob Prouse Date: Sat, 26 Oct 2024 17:55:40 -0400 Subject: [PATCH 1/3] Download bills from Alectra --- Guppi.Console/Guppi.Console.csproj | 2 +- Guppi.Console/Program.cs | 1 + Guppi.Console/Properties/launchSettings.json | 2 +- Guppi.Console/Skills/BillsSkill.cs | 60 +++++++++ .../Configurations/BillConfiguration.cs | 18 +++ .../Configurations/StravaConfiguration.cs | 20 ++- Guppi.Core/DependencyInjection.cs | 1 + Guppi.Core/Guppi.Core.csproj | 1 + .../Interfaces/Services/IBillService.cs | 9 ++ Guppi.Core/Services/BillService.cs | 120 ++++++++++++++++++ 10 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 Guppi.Console/Skills/BillsSkill.cs create mode 100644 Guppi.Core/Configurations/BillConfiguration.cs create mode 100644 Guppi.Core/Interfaces/Services/IBillService.cs create mode 100644 Guppi.Core/Services/BillService.cs diff --git a/Guppi.Console/Guppi.Console.csproj b/Guppi.Console/Guppi.Console.csproj index 0bb13ba..4422e7c 100644 --- a/Guppi.Console/Guppi.Console.csproj +++ b/Guppi.Console/Guppi.Console.csproj @@ -13,7 +13,7 @@ https://github.com/rprouse/guppi https://github.com/rprouse/guppi dotnet-guppi - 6.2.1 + 6.3.0 true guppi ./nupkg diff --git a/Guppi.Console/Program.cs b/Guppi.Console/Program.cs index c8a0e56..cb5925a 100644 --- a/Guppi.Console/Program.cs +++ b/Guppi.Console/Program.cs @@ -13,6 +13,7 @@ static IServiceProvider ConfigureServices() => .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient() diff --git a/Guppi.Console/Properties/launchSettings.json b/Guppi.Console/Properties/launchSettings.json index 9a590db..23f73bb 100644 --- a/Guppi.Console/Properties/launchSettings.json +++ b/Guppi.Console/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "Guppi.Console": { "commandName": "Project", - "commandLineArgs": "cal agenda -t" + "commandLineArgs": "bills alectra" } } } diff --git a/Guppi.Console/Skills/BillsSkill.cs b/Guppi.Console/Skills/BillsSkill.cs new file mode 100644 index 0000000..757caf4 --- /dev/null +++ b/Guppi.Console/Skills/BillsSkill.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.NamingConventionBinder; +using System.Threading.Tasks; +using Guppi.Core.Exceptions; +using Guppi.Core.Extensions; +using Guppi.Core.Interfaces.Services; +using Spectre.Console; + +namespace Guppi.Console.Skills; + +internal class BillsSkill(IBillService service) : ISkill +{ + private readonly IBillService _service = service; + + public IEnumerable GetCommands() + { + var alectra = new Command("alectra", "Download bills from Alectra") + { + }; + alectra.Handler = CommandHandler.Create(async () => await DownloadAlectraBills()); + + var configure = new Command("configure", "Configures the Bill provider"); + configure.AddAlias("config"); + configure.Handler = CommandHandler.Create(() => Configure()); + + var command = new Command("bills", "Download bills from online") + { + alectra, + configure + }; + command.AddAlias("billing"); + command.AddAlias("bill"); + + return new List { command }; + } + + private async Task DownloadAlectraBills() + { + try + { + AnsiConsoleHelper.TitleRule(":high_voltage: Alectra Bills"); + + await _service.DownloadAlectraBills(); + + AnsiConsoleHelper.Rule("white"); + } + catch (UnconfiguredException ue) + { + AnsiConsole.MarkupLine($"[yellow][[:yellow_circle: {ue.Message}]][/]"); + } + catch (UnauthorizedException ue) + { + AnsiConsole.MarkupLine($"[red][[:cross_mark: ${ue.Message}]][/]"); + } + } + + private void Configure() => _service.Configure(); +} diff --git a/Guppi.Core/Configurations/BillConfiguration.cs b/Guppi.Core/Configurations/BillConfiguration.cs new file mode 100644 index 0000000..e3bb574 --- /dev/null +++ b/Guppi.Core/Configurations/BillConfiguration.cs @@ -0,0 +1,18 @@ +using Guppi.Core.Attributes; + +namespace Guppi.Core.Configurations; + +public class BillConfiguration : Configuration +{ + [Display("Alectra Username")] + public string AlectraUsername { get; set; } + + [Display("Alectra Password")] + public string AlectraPassword { get; set; } + + [Display("Enbridge Username")] + public string EnbridgeUsername { get; set; } + + [Display("Enbridge Password")] + public string EnbridgePassword { get; set; } +} diff --git a/Guppi.Core/Configurations/StravaConfiguration.cs b/Guppi.Core/Configurations/StravaConfiguration.cs index 86f4c87..7010238 100644 --- a/Guppi.Core/Configurations/StravaConfiguration.cs +++ b/Guppi.Core/Configurations/StravaConfiguration.cs @@ -1,17 +1,15 @@ -using Guppi.Core; using Guppi.Core.Attributes; -namespace Guppi.Core.Configurations +namespace Guppi.Core.Configurations; + +public class StravaConfiguration : Configuration { - public class StravaConfiguration : Configuration - { - [Display("Client Id")] - public string ClientId { get; set; } + [Display("Client Id")] + public string ClientId { get; set; } - [Display("Client Secret")] - public string ClientSecret { get; set; } + [Display("Client Secret")] + public string ClientSecret { get; set; } - [Hide] - public string RefreshToken { get; set; } - } + [Hide] + public string RefreshToken { get; set; } } diff --git a/Guppi.Core/DependencyInjection.cs b/Guppi.Core/DependencyInjection.cs index f27d54a..b3636a3 100644 --- a/Guppi.Core/DependencyInjection.cs +++ b/Guppi.Core/DependencyInjection.cs @@ -45,6 +45,7 @@ public static IServiceCollection AddCore(this IServiceCollection services) // Add the services .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient() diff --git a/Guppi.Core/Guppi.Core.csproj b/Guppi.Core/Guppi.Core.csproj index 08b380f..ddfadba 100644 --- a/Guppi.Core/Guppi.Core.csproj +++ b/Guppi.Core/Guppi.Core.csproj @@ -14,6 +14,7 @@ + diff --git a/Guppi.Core/Interfaces/Services/IBillService.cs b/Guppi.Core/Interfaces/Services/IBillService.cs new file mode 100644 index 0000000..397d5e6 --- /dev/null +++ b/Guppi.Core/Interfaces/Services/IBillService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Guppi.Core.Interfaces.Services; + +public interface IBillService +{ + Task DownloadAlectraBills(); + void Configure(); +} diff --git a/Guppi.Core/Services/BillService.cs b/Guppi.Core/Services/BillService.cs new file mode 100644 index 0000000..d8c09bf --- /dev/null +++ b/Guppi.Core/Services/BillService.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Guppi.Core.Configurations; +using Guppi.Core.Exceptions; +using Guppi.Core.Interfaces.Services; +using Microsoft.Playwright; + +namespace Guppi.Core.Services; + +internal class BillService : IBillService +{ + private readonly BillConfiguration _configuration = Configuration.Load("billing"); + + private bool Configured => _configuration.Configured; + + public async Task DownloadAlectraBills() + { + if (!Configured) + { + throw new UnconfiguredException("Please configure the Billing provider"); + } + + using var playwright = await Playwright.CreateAsync(); + var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = false }); + var context = await browser.NewContextAsync(); + var page = await context.NewPageAsync(); + + // Login to Alectra + await page.GotoAsync("https://myalectra.alectrautilities.com/portal/#/login"); + await page.GetByLabel("Please enter Username").FillAsync(_configuration.AlectraUsername); + await page.GetByLabel("Please enter your password.").FillAsync(_configuration.AlectraPassword); + await page.GetByLabel("Click Here to Sign In").ClickAsync(); + + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + await DownloadBillsForAccount(page, "8501783878"); + await DownloadBillsForAccount(page, "7030931444"); + await DownloadBillsForAccount(page, "9676981145"); + await DownloadBillsForAccount(page, "7076520332"); + + await Task.Delay(5000); + } + + private static async Task DownloadBillsForAccount(IPage page, string account) + { + // Navigate to Billing History + await page.GotoAsync("https://myalectra.alectrautilities.com/portal/#/billinghistory"); + + // Wait for the bills to load + await page.WaitForSelectorAsync("tr.billing-history-row"); + + // Click the account selector dropdown + var dropdown = await page.QuerySelectorAsync("button#accountSelect"); + await dropdown.ClickAsync(); + + // Click the account in the dropdown + var menu = await page.QuerySelectorAsync($"li[value=\"{account}\"]"); + await menu.ClickAsync(); + + // Wait for the billing history to load + await page.WaitForSelectorAsync("tr.billing-history-row"); + + // Download each bill + var rows = await page.QuerySelectorAllAsync("tr.billing-history-row"); + foreach (var row in rows) + { + var cells = await row.QuerySelectorAllAsync("td"); + var date = await cells[0].InnerTextAsync(); + var amount = await cells[1].InnerTextAsync(); + + Console.WriteLine($"{account} {date} {amount}"); + + await DownloadBill(page, account, $"{date} {amount}"); + } + } + + private static async Task DownloadBill(IPage page, string account, string bill) + { + // Open the billing page in a new tab + var billingPage = await page.RunAndWaitForPopupAsync(async () => + { + await page.GetByRole(AriaRole.Checkbox, new() { Name = bill }).GetByLabel("Navigate to View Bill PDF").ClickAsync(); + }); + + // Listen for download events so we can specify the + billingPage.Download += async (_, download) => + { + string downloadsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads", "bills"); + + // Ensure the directory exists + Directory.CreateDirectory(downloadsPath); + + var filePath = Path.Combine(downloadsPath, $"{account} {bill}.pdf"); + await download.SaveAsAsync(filePath); + + await billingPage.CloseAsync(); + }; + + // This closes the warning popup that appears when the download is initiated + async void billingPage_Dialog_EventHandler(object sender, IDialog dialog) + { + await dialog.AcceptAsync(); + billingPage.Dialog -= billingPage_Dialog_EventHandler; + } + + billingPage.Dialog += billingPage_Dialog_EventHandler; + + // Click the download button + var download = await billingPage.RunAndWaitForDownloadAsync(async () => + { + await billingPage.GetByRole(AriaRole.Img, new() { Name = "Download PDF" }).ClickAsync(); + }); + } + public void Configure() + { + var configuration = Configuration.Load("billing"); + configuration.RunConfiguration("Billing", "Enter credentials for billing providers"); + } +} From ae4820057ffce652279a8c7b29c89d3b07c90f99 Mon Sep 17 00:00:00 2001 From: Rob Prouse Date: Sat, 26 Oct 2024 20:30:43 -0400 Subject: [PATCH 2/3] Format the amount and date --- Guppi.Core/Services/BillService.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Guppi.Core/Services/BillService.cs b/Guppi.Core/Services/BillService.cs index d8c09bf..694cbb5 100644 --- a/Guppi.Core/Services/BillService.cs +++ b/Guppi.Core/Services/BillService.cs @@ -69,6 +69,16 @@ private static async Task DownloadBillsForAccount(IPage page, string account) var date = await cells[0].InnerTextAsync(); var amount = await cells[1].InnerTextAsync(); + if (DateTime.TryParse(date, out DateTime d)) + { + date = d.ToShortDateString(); + } + + if (double.TryParse(amount.Substring(1), out double a)) + { + amount = a.ToString("N2"); + } + Console.WriteLine($"{account} {date} {amount}"); await DownloadBill(page, account, $"{date} {amount}"); From a2d4ec265b991996a30b5134cb0778a9d3bdcf8d Mon Sep 17 00:00:00 2001 From: Rob Prouse Date: Sat, 26 Oct 2024 21:02:49 -0400 Subject: [PATCH 3/3] Create a spreadsheet with the bills --- Guppi.Core/Guppi.Core.csproj | 1 + Guppi.Core/Services/BillService.cs | 38 ++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/Guppi.Core/Guppi.Core.csproj b/Guppi.Core/Guppi.Core.csproj index ddfadba..15d2bf7 100644 --- a/Guppi.Core/Guppi.Core.csproj +++ b/Guppi.Core/Guppi.Core.csproj @@ -9,6 +9,7 @@ + diff --git a/Guppi.Core/Services/BillService.cs b/Guppi.Core/Services/BillService.cs index 694cbb5..f58a04f 100644 --- a/Guppi.Core/Services/BillService.cs +++ b/Guppi.Core/Services/BillService.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; +using ClosedXML.Excel; using Guppi.Core.Configurations; using Guppi.Core.Exceptions; using Guppi.Core.Interfaces.Services; @@ -12,6 +13,11 @@ internal class BillService : IBillService { private readonly BillConfiguration _configuration = Configuration.Load("billing"); + readonly static string _downloadsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads", "bills"); + + int _row = 2; + IXLWorksheet _worksheet; + private bool Configured => _configuration.Configured; public async Task DownloadAlectraBills() @@ -21,6 +27,19 @@ public async Task DownloadAlectraBills() throw new UnconfiguredException("Please configure the Billing provider"); } + // Create an Excel spreadsheet to store the billing data + string path = Path.Combine(_downloadsPath, "Bills.xlsx"); + if (File.Exists(path)) + { + File.Delete(path); + } + var workbook = new ClosedXML.Excel.XLWorkbook(); + _worksheet = workbook.Worksheets.Add("Bills"); + + _worksheet.Cell(1, 1).Value = "Account"; + _worksheet.Cell(1, 2).Value = "Date"; + _worksheet.Cell(1, 3).Value = "Amount"; + using var playwright = await Playwright.CreateAsync(); var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = false }); var context = await browser.NewContextAsync(); @@ -40,9 +59,11 @@ public async Task DownloadAlectraBills() await DownloadBillsForAccount(page, "7076520332"); await Task.Delay(5000); + + workbook.SaveAs(path); } - private static async Task DownloadBillsForAccount(IPage page, string account) + private async Task DownloadBillsForAccount(IPage page, string account) { // Navigate to Billing History await page.GotoAsync("https://myalectra.alectrautilities.com/portal/#/billinghistory"); @@ -79,13 +100,18 @@ private static async Task DownloadBillsForAccount(IPage page, string account) amount = a.ToString("N2"); } + _worksheet.Cell(_row, 1).Value = account; + _worksheet.Cell(_row, 2).Value = date; + _worksheet.Cell(_row, 3).Value = amount; + _row++; + Console.WriteLine($"{account} {date} {amount}"); await DownloadBill(page, account, $"{date} {amount}"); } } - private static async Task DownloadBill(IPage page, string account, string bill) + private async Task DownloadBill(IPage page, string account, string bill) { // Open the billing page in a new tab var billingPage = await page.RunAndWaitForPopupAsync(async () => @@ -96,12 +122,10 @@ private static async Task DownloadBill(IPage page, string account, string bill) // Listen for download events so we can specify the billingPage.Download += async (_, download) => { - string downloadsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads", "bills"); - // Ensure the directory exists - Directory.CreateDirectory(downloadsPath); + Directory.CreateDirectory(_downloadsPath); - var filePath = Path.Combine(downloadsPath, $"{account} {bill}.pdf"); + var filePath = Path.Combine(_downloadsPath, $"{account} {bill}.pdf"); await download.SaveAsAsync(filePath); await billingPage.CloseAsync(); @@ -117,7 +141,7 @@ async void billingPage_Dialog_EventHandler(object sender, IDialog dialog) billingPage.Dialog += billingPage_Dialog_EventHandler; // Click the download button - var download = await billingPage.RunAndWaitForDownloadAsync(async () => + await billingPage.RunAndWaitForDownloadAsync(async () => { await billingPage.GetByRole(AriaRole.Img, new() { Name = "Download PDF" }).ClickAsync(); });