172 lines
5.6 KiB
C#
172 lines
5.6 KiB
C#
/*
|
|
BunyMuny parses the CSV output of various bank statement listings and converts it to something more human readable with nice visualisations.
|
|
Copyright (C) 2020 Lynnear Software
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program.If not, see <https: //www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.CommandLine.DragonFruit;
|
|
using System.Linq;
|
|
using System.Text.RegularExpressions;
|
|
using CsvHelper;
|
|
using CsvHelper.Configuration;
|
|
|
|
namespace BunyMuny {
|
|
internal class Program {
|
|
/// <summary>
|
|
/// BunyMuny parses the CSV output of various bank statement listings and converts it to something more human readable with nice visualisations.
|
|
/// </summary>
|
|
/// <param name="file">The CSV file to read</param>
|
|
/// <param name="ruleFile">The CSV file to use for rules when parsing statement descriptions</param>
|
|
/// <returns></returns>
|
|
private static int Main(string file = "test.csv", string ruleFile = "rules.csv") {
|
|
var bank = Bank.Other;
|
|
var statements = new List<Statement>();
|
|
List<Rule> rules;
|
|
|
|
using (var ruleStreamReader = new StreamReader(ruleFile)) {
|
|
using var ruleCsv = new CsvReader(ruleStreamReader, CultureInfo.InvariantCulture);
|
|
rules = ruleCsv.GetRecords<Rule>().ToList();
|
|
}
|
|
|
|
// get the first line of the CSV file (the header) as a string
|
|
string header;
|
|
using (var headerReader = new StreamReader(file)) {
|
|
header = headerReader.ReadLine();
|
|
}
|
|
|
|
using var sr = new StreamReader(file);
|
|
using var csv = new CsvReader(sr, CultureInfo.InvariantCulture);
|
|
csv.Configuration.HasHeaderRecord = false;
|
|
|
|
var nabRegex = new Regex(@"^\d\d \w{3} \d\d");
|
|
var commbankRegex = new Regex(@"\d\d\/\d\d\/\d{4}");
|
|
|
|
if (header == null) {
|
|
Console.WriteLine("File is empty 0uo");
|
|
return 1;
|
|
}
|
|
else if (header == "Date,Description,Debits and credits,Balance") {
|
|
bank = Bank.ME;
|
|
csv.Configuration.HasHeaderRecord = true;
|
|
// read in the header to allow for accessing fields by name rather than by index
|
|
csv.Read();
|
|
csv.ReadHeader();
|
|
}
|
|
else if (nabRegex.IsMatch(header)) {
|
|
// NAB exports don't have headers
|
|
// records look like "02 Apr 20,23.00,,,MISCELLANEOUS DEBIT,V1234 02/09 PAYPAL Sydney,1234.56
|
|
// the columns mean: date of transaction, amount, ???, ???, category,method,remaining balance
|
|
// i don't like it >:c
|
|
bank = Bank.NAB;
|
|
}
|
|
else if (commbankRegex.IsMatch(header)) {
|
|
bank = Bank.CommBank;
|
|
}
|
|
else {
|
|
Console.WriteLine($"Unknown header: [{header}]");
|
|
}
|
|
|
|
while (csv.Read()) {
|
|
decimal value;
|
|
DateTime date;
|
|
string originalDescription;
|
|
string category;
|
|
string description;
|
|
|
|
switch (bank) {
|
|
case Bank.ME:
|
|
value = decimal.Parse(csv.GetField("Debits and credits").TrimStart().Replace("$", ""));
|
|
(category, description) = MatchAgainstRules(rules, csv.GetField("Description"));
|
|
date = DateTime.ParseExact(csv.GetField("Date"), "dd/MM/yyyy", CultureInfo.InvariantCulture);
|
|
originalDescription = csv.GetField("Description");
|
|
break;
|
|
|
|
case Bank.NAB:
|
|
value = decimal.Parse(csv.GetField(1));
|
|
(category, description) = MatchAgainstRules(rules, csv.GetField(5));
|
|
date = DateTime.ParseExact(csv.GetField(0), "dd MMM yy", CultureInfo.CurrentCulture);
|
|
originalDescription = csv.GetField(5);
|
|
break;
|
|
|
|
case Bank.CommBank:
|
|
value = decimal.Parse(csv.GetField(1));
|
|
(category, description) = MatchAgainstRules(rules, csv.GetField(2));
|
|
date = DateTime.ParseExact(csv.GetField(0), "dd/MM/yyyy", CultureInfo.InvariantCulture);
|
|
originalDescription = csv.GetField(2);
|
|
break;
|
|
|
|
case Bank.Other:
|
|
Console.WriteLine("Unknown bank!");
|
|
return 1;
|
|
|
|
default:
|
|
Console.WriteLine(":(");
|
|
return 1;
|
|
}
|
|
|
|
statements.Add(new Statement() {
|
|
Date = date,
|
|
OriginalDescription = originalDescription,
|
|
Description = description,
|
|
Category = category,
|
|
Value = value
|
|
});
|
|
}
|
|
|
|
foreach (var statement in statements.
|
|
// Where(s => s.Category == null).
|
|
OrderBy(s => s.Date)) {
|
|
Console.WriteLine(statement);
|
|
}
|
|
|
|
if (statements.Count == 0) {
|
|
Console.WriteLine("No statements found.");
|
|
return 2;
|
|
}
|
|
|
|
Console.WriteLine("Summary:");
|
|
Console.WriteLine("==================");
|
|
var summaries = statements.GroupBy(s => s.Category). // group statements by category
|
|
Select(summary => new {
|
|
// and then select:
|
|
Name = summary.First().Category ?? "Other", // the name of the category...
|
|
Total = summary.Sum(s => s.Value).ToString() // ...and the sum of all the statement's values in that category
|
|
});
|
|
|
|
var longestCategoryName = summaries.ToList().Max(summary => summary.Name.Length);
|
|
|
|
foreach (var summary in summaries) {
|
|
Console.WriteLine("{0}: {1}", summary.Name.PadLeft(longestCategoryName), summary.Total);
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
private static (string Category, string Description) MatchAgainstRules(List<Rule> rules, string value) {
|
|
foreach (var rule in rules) {
|
|
if (rule.Check(value)) {
|
|
return (rule.Category, rule.Description);
|
|
}
|
|
}
|
|
|
|
return (null, null);
|
|
}
|
|
}
|
|
}
|