Program.cs
The complete import program:
using System.Globalization;
using Funcky;
using Funcky.Extensions;
using Funcky.Monads;
using static System.Globalization.CultureInfo;
CurrentCulture = InvariantCulture;
const string csv = """
Date,Description,Amount
2026-01-03,Salary,4200.00
2026-01-05,Groceries,-83.20
2026-01-19,Rent,-1450.00
2026-01-31,Groceries,-121.70
2026-02-01,Dentist,-180
2026-02-30,Refund,35.00
2026-02-14,Concert tickets,-2x40
2026-02-17,Bike repair
2026-03-02,Salary,4200.00
2026-03-06,Groceries,-97.45
""";
var lines = csv
.SplitLines()
.Skip(1)
.WithIndex()
.Select(ParseLine);
Console.WriteLine("== Lenient import: skip the bad lines ==");
var (errors, transactions) = lines.Partition();
foreach (var error in errors)
{
Console.WriteLine($"line {error.LineNumber}: {error.Message}");
}
Console.WriteLine(Report(transactions));
Console.WriteLine();
Console.WriteLine("== Strict import: all or nothing ==");
Console.WriteLine(lines.Sequence().Match(
left: error => $"import rejected, line {error.LineNumber}: {error.Message}",
right: Report));
static Either<ImportError, Transaction> ParseLine(ValueWithIndex<string> line)
=> line.Value.Split(',') is [var date, var description, var amount]
? from parsedDate in date.ParseDateOnlyOrNone(InvariantCulture).ToEither(Error(line, $"'{date}' is not a date"))
from parsedAmount in amount.ParseDecimalOrNone(NumberStyles.Number, InvariantCulture).ToEither(Error(line, $"'{amount}' is not an amount"))
select new Transaction(parsedDate, description, parsedAmount)
: Either<ImportError, Transaction>.Left(Error(line, "expected 3 fields"));
// The header is line 1 and was skipped, so the first indexed line is line 2.
static ImportError Error(ValueWithIndex<string> line, string message)
=> new(line.Index + 2, message);
static string Report(IReadOnlyList<Transaction> transactions)
=> Sequence
.Return(
$"{transactions.Count} transactions imported",
MonthlyTotals(transactions),
LargestIncome(transactions),
LongestGap(transactions),
$"final balance: {Balance(transactions):F2}")
.JoinToString(Environment.NewLine);
static string MonthlyTotals(IEnumerable<Transaction> transactions)
=> transactions
.AdjacentGroupBy(
transaction => (transaction.Date.Year, transaction.Date.Month),
(month, items) => $" {month.Year}-{month.Month:00}: {items.Sum(transaction => transaction.Amount),9:F2}")
.Prepend("monthly totals:")
.JoinToString(Environment.NewLine);
static string LargestIncome(IEnumerable<Transaction> transactions)
=> transactions
.MaxByOrNone(transaction => transaction.Amount)
.Match(
none: "largest income: none",
some: transaction => $"largest income: {transaction.Description} ({transaction.Amount:F2})");
static string LongestGap(IEnumerable<Transaction> transactions)
=> transactions
.Pairwise((earlier, later) => later.Date.DayNumber - earlier.Date.DayNumber)
.MaxOrNone()
.Match(
none: "longest gap: fewer than two transactions",
some: days => $"longest gap between two transactions: {days} days");
static decimal Balance(IEnumerable<Transaction> transactions)
=> transactions
.InclusiveScan(0m, (balance, transaction) => balance + transaction.Amount)
.LastOrNone()
.GetOrElse(0m);
record Transaction(DateOnly Date, string Description, decimal Amount);
record ImportError(int LineNumber, string Message);