From 9a776ed38e653413c16998041248358321691f4a Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 22 Aug 2026 01:10:19 -0300 Subject: [PATCH 1/4] feat(cli): scaffold agent-ready command application --- apps/cli/src/BitFinance.Cli.csproj | 22 +++ apps/cli/src/CliApplication.cs | 84 ++++++++++++ apps/cli/src/CliCommandFactory.cs | 24 ++++ .../cli/src/Configuration/CliConfiguration.cs | 46 +++++++ .../Configuration/IEnvironmentVariables.cs | 11 ++ apps/cli/src/Errors/CliError.cs | 21 +++ apps/cli/src/Errors/CliException.cs | 18 +++ apps/cli/src/Errors/ExitCodes.cs | 12 ++ apps/cli/src/Output/CliOutputWriter.cs | 126 ++++++++++++++++++ apps/cli/src/Output/OutputFormat.cs | 7 + apps/cli/src/Program.cs | 3 + .../BitFinance.Cli.UnitTests.csproj | 24 ++++ .../CliApplicationTests.cs | 42 ++++++ .../CliConfigurationTests.cs | 109 +++++++++++++++ .../CliOutputWriterTests.cs | 62 +++++++++ .../BitFinance.Cli.UnitTests/GlobalUsings.cs | 1 + 16 files changed, 612 insertions(+) create mode 100644 apps/cli/src/BitFinance.Cli.csproj create mode 100644 apps/cli/src/CliApplication.cs create mode 100644 apps/cli/src/CliCommandFactory.cs create mode 100644 apps/cli/src/Configuration/CliConfiguration.cs create mode 100644 apps/cli/src/Configuration/IEnvironmentVariables.cs create mode 100644 apps/cli/src/Errors/CliError.cs create mode 100644 apps/cli/src/Errors/CliException.cs create mode 100644 apps/cli/src/Errors/ExitCodes.cs create mode 100644 apps/cli/src/Output/CliOutputWriter.cs create mode 100644 apps/cli/src/Output/OutputFormat.cs create mode 100644 apps/cli/src/Program.cs create mode 100644 apps/cli/tests/BitFinance.Cli.UnitTests/BitFinance.Cli.UnitTests.csproj create mode 100644 apps/cli/tests/BitFinance.Cli.UnitTests/CliApplicationTests.cs create mode 100644 apps/cli/tests/BitFinance.Cli.UnitTests/CliConfigurationTests.cs create mode 100644 apps/cli/tests/BitFinance.Cli.UnitTests/CliOutputWriterTests.cs create mode 100644 apps/cli/tests/BitFinance.Cli.UnitTests/GlobalUsings.cs diff --git a/apps/cli/src/BitFinance.Cli.csproj b/apps/cli/src/BitFinance.Cli.csproj new file mode 100644 index 0000000..063bb61 --- /dev/null +++ b/apps/cli/src/BitFinance.Cli.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + bitfinance-cli + BitFinance.Cli + 0.1.0 + 0.1.0 + 0.1.0 + 0.1.0 + false + enable + enable + 14 + + + + + + + diff --git a/apps/cli/src/CliApplication.cs b/apps/cli/src/CliApplication.cs new file mode 100644 index 0000000..8a8a880 --- /dev/null +++ b/apps/cli/src/CliApplication.cs @@ -0,0 +1,84 @@ +using BitFinance.Cli.Configuration; +using BitFinance.Cli.Errors; +using BitFinance.Cli.Output; +using System.CommandLine; + +namespace BitFinance.Cli; + +public sealed class CliApplication +{ + private static readonly string[] HelpOptions = ["--help", "-h", "-?", "/?"]; + private readonly RootCommand _rootCommand; + private readonly CliOutputWriter _output; + + public CliApplication(IEnvironmentVariables environment, TextWriter standardOutput, TextWriter standardError) + { + ArgumentNullException.ThrowIfNull(environment); + ArgumentNullException.ThrowIfNull(standardOutput); + ArgumentNullException.ThrowIfNull(standardError); + + _output = new CliOutputWriter(standardOutput, standardError); + Services = new CliServices(environment, _output); + _rootCommand = CliCommandFactory.Create(Services); + } + + public CliServices Services { get; } + + public static CliApplication CreateDefault() => + new(new SystemEnvironmentVariables(), Console.Out, Console.Error); + + public async Task RunAsync(string[] args, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(args); + + var parseResult = _rootCommand.Parse(args); + var invokesBuiltInAction = args.Contains("--version", StringComparer.Ordinal) + || args.Any(argument => HelpOptions.Contains(argument, StringComparer.Ordinal)); + + if (parseResult.Errors.Count > 0 && !invokesBuiltInAction) + { + var message = string.Join(" ", parseResult.Errors.Select(error => error.Message)); + _output.WriteError(CliError.InvalidArguments(message)); + return ExitCodes.InvalidInput; + } + + var invocationConfiguration = new InvocationConfiguration + { + EnableDefaultExceptionHandler = false, + Output = Services.Output.StandardOutput, + Error = Services.Output.StandardError + }; + + try + { + return await parseResult.InvokeAsync(invocationConfiguration, cancellationToken); + } + catch (CliException exception) + { + _output.WriteError(exception.Error); + return exception.ExitCode; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _output.WriteError(CliError.Cancelled()); + return ExitCodes.Cancelled; + } + catch (TaskCanceledException) + { + _output.WriteError(CliError.Transport("The BitFinance request timed out.")); + return ExitCodes.TransportFailure; + } + catch (HttpRequestException) + { + _output.WriteError(CliError.Transport("The BitFinance API could not be reached.")); + return ExitCodes.TransportFailure; + } + catch (Exception) + { + _output.WriteError(CliError.Unexpected()); + return ExitCodes.UnexpectedFailure; + } + } +} + +public sealed record CliServices(IEnvironmentVariables Environment, CliOutputWriter Output); diff --git a/apps/cli/src/CliCommandFactory.cs b/apps/cli/src/CliCommandFactory.cs new file mode 100644 index 0000000..42896d4 --- /dev/null +++ b/apps/cli/src/CliCommandFactory.cs @@ -0,0 +1,24 @@ +using BitFinance.Cli.Output; +using System.CommandLine; + +namespace BitFinance.Cli; + +public static class CliCommandFactory +{ + public static RootCommand Create(CliServices services) + { + ArgumentNullException.ThrowIfNull(services); + + var outputOption = new Option("--output") + { + Description = "Output format for successful command results.", + DefaultValueFactory = _ => OutputFormat.Json, + Recursive = true + }; + + var rootCommand = new RootCommand("Agent-oriented command-line client for BitFinance."); + rootCommand.Options.Add(outputOption); + + return rootCommand; + } +} diff --git a/apps/cli/src/Configuration/CliConfiguration.cs b/apps/cli/src/Configuration/CliConfiguration.cs new file mode 100644 index 0000000..3062f7e --- /dev/null +++ b/apps/cli/src/Configuration/CliConfiguration.cs @@ -0,0 +1,46 @@ +using BitFinance.Cli.Errors; +using System.Globalization; + +namespace BitFinance.Cli.Configuration; + +public sealed record CliConfiguration(Uri ApiBaseUrl, string AccessToken, string ApiVersion) +{ + public const string ApiBaseUrlVariable = "BITFINANCE_API_BASE_URL"; + public const string AccessTokenVariable = "BITFINANCE_ACCESS_TOKEN"; + public const string ApiVersionVariable = "BITFINANCE_API_VERSION"; + + public static CliConfiguration Load(IEnvironmentVariables environment) + { + ArgumentNullException.ThrowIfNull(environment); + + var apiBaseUrlValue = GetRequired(environment, ApiBaseUrlVariable); + var accessToken = GetRequired(environment, AccessTokenVariable); + var apiVersionValue = environment.Get(ApiVersionVariable); + + if (!Uri.TryCreate(apiBaseUrlValue, UriKind.Absolute, out var apiBaseUrl) + || (apiBaseUrl.Scheme != Uri.UriSchemeHttp && apiBaseUrl.Scheme != Uri.UriSchemeHttps)) + { + throw CliException.Configuration($"{ApiBaseUrlVariable} must be an absolute HTTP or HTTPS URL."); + } + + var apiVersion = string.IsNullOrWhiteSpace(apiVersionValue) ? "1" : apiVersionValue.Trim(); + if (!int.TryParse(apiVersion, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedVersion) + || parsedVersion < 1) + { + throw CliException.Configuration($"{ApiVersionVariable} must be a positive integer when provided."); + } + + return new CliConfiguration(apiBaseUrl, accessToken, apiVersion); + } + + private static string GetRequired(IEnvironmentVariables environment, string name) + { + var value = environment.Get(name); + if (string.IsNullOrWhiteSpace(value)) + { + throw CliException.Configuration($"{name} is required."); + } + + return value.Trim(); + } +} diff --git a/apps/cli/src/Configuration/IEnvironmentVariables.cs b/apps/cli/src/Configuration/IEnvironmentVariables.cs new file mode 100644 index 0000000..24e2669 --- /dev/null +++ b/apps/cli/src/Configuration/IEnvironmentVariables.cs @@ -0,0 +1,11 @@ +namespace BitFinance.Cli.Configuration; + +public interface IEnvironmentVariables +{ + string? Get(string name); +} + +public sealed class SystemEnvironmentVariables : IEnvironmentVariables +{ + public string? Get(string name) => Environment.GetEnvironmentVariable(name); +} diff --git a/apps/cli/src/Errors/CliError.cs b/apps/cli/src/Errors/CliError.cs new file mode 100644 index 0000000..ac1bbc8 --- /dev/null +++ b/apps/cli/src/Errors/CliError.cs @@ -0,0 +1,21 @@ +namespace BitFinance.Cli.Errors; + +public sealed record CliError(string Code, string Message, int? HttpStatus = null, object? Details = null) +{ + public static CliError InvalidArguments(string message) => + new("invalid_arguments", message); + + public static CliError Configuration(string message) => + new("invalid_configuration", message); + + public static CliError Transport(string message) => + new("transport_error", message); + + public static CliError Cancelled() => + new("cancelled", "The operation was cancelled."); + + public static CliError Unexpected() => + new("unexpected_error", "An unexpected error occurred."); +} + +public sealed record CliErrorEnvelope(CliError Error); diff --git a/apps/cli/src/Errors/CliException.cs b/apps/cli/src/Errors/CliException.cs new file mode 100644 index 0000000..4133321 --- /dev/null +++ b/apps/cli/src/Errors/CliException.cs @@ -0,0 +1,18 @@ +namespace BitFinance.Cli.Errors; + +public sealed class CliException : Exception +{ + public CliException(CliError error, int exitCode) + : base(error.Message) + { + Error = error; + ExitCode = exitCode; + } + + public CliError Error { get; } + + public int ExitCode { get; } + + public static CliException Configuration(string message) => + new(CliError.Configuration(message), ExitCodes.InvalidInput); +} diff --git a/apps/cli/src/Errors/ExitCodes.cs b/apps/cli/src/Errors/ExitCodes.cs new file mode 100644 index 0000000..da24b10 --- /dev/null +++ b/apps/cli/src/Errors/ExitCodes.cs @@ -0,0 +1,12 @@ +namespace BitFinance.Cli.Errors; + +public static class ExitCodes +{ + public const int Success = 0; + public const int UnexpectedFailure = 1; + public const int InvalidInput = 2; + public const int AuthenticationFailure = 3; + public const int ApiFailure = 4; + public const int TransportFailure = 5; + public const int Cancelled = 130; +} diff --git a/apps/cli/src/Output/CliOutputWriter.cs b/apps/cli/src/Output/CliOutputWriter.cs new file mode 100644 index 0000000..d2a7f2f --- /dev/null +++ b/apps/cli/src/Output/CliOutputWriter.cs @@ -0,0 +1,126 @@ +using BitFinance.Cli.Errors; +using System.Text.Json; + +namespace BitFinance.Cli.Output; + +public sealed class CliOutputWriter +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = false + }; + + public CliOutputWriter(TextWriter standardOutput, TextWriter standardError) + { + StandardOutput = standardOutput ?? throw new ArgumentNullException(nameof(standardOutput)); + StandardError = standardError ?? throw new ArgumentNullException(nameof(standardError)); + } + + public TextWriter StandardOutput { get; } + + public TextWriter StandardError { get; } + + public void WriteSuccess(T value, OutputFormat format) + { + if (format == OutputFormat.Table) + { + WriteTable(JsonSerializer.SerializeToElement(value, JsonOptions)); + return; + } + + StandardOutput.WriteLine(JsonSerializer.Serialize(value, JsonOptions)); + } + + public void WriteError(CliError error) => + StandardError.WriteLine(JsonSerializer.Serialize(new CliErrorEnvelope(error), JsonOptions)); + + private void WriteTable(JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Array: + WriteArrayTable(element); + break; + case JsonValueKind.Object: + WriteObjectTable(element); + break; + default: + StandardOutput.WriteLine(RenderCell(element)); + break; + } + } + + private void WriteArrayTable(JsonElement array) + { + var items = array.EnumerateArray().ToArray(); + if (items.Length == 0) + { + StandardOutput.WriteLine("(no results)"); + return; + } + + if (items.Any(item => item.ValueKind != JsonValueKind.Object)) + { + WriteRows(["Value"], items.Select(item => new[] { RenderCell(item) }).ToArray()); + return; + } + + var headers = items + .SelectMany(item => item.EnumerateObject().Select(property => property.Name)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var rows = items + .Select(item => headers + .Select(header => item.TryGetProperty(header, out var value) ? RenderCell(value) : string.Empty) + .ToArray()) + .ToArray(); + + WriteRows(headers, rows); + } + + private void WriteObjectTable(JsonElement value) + { + var rows = value + .EnumerateObject() + .Select(property => new[] { property.Name, RenderCell(property.Value) }) + .ToArray(); + + WriteRows(["Field", "Value"], rows); + } + + private void WriteRows(string[] headers, string[][] rows) + { + var widths = headers.Select(header => header.Length).ToArray(); + foreach (var row in rows) + { + for (var index = 0; index < row.Length; index++) + { + widths[index] = Math.Max(widths[index], row[index].Length); + } + } + + WriteRow(headers, widths); + WriteRow(widths.Select(width => new string('-', width)).ToArray(), widths); + foreach (var row in rows) + { + WriteRow(row, widths); + } + } + + private void WriteRow(string[] values, int[] widths) => + StandardOutput.WriteLine(string.Join(" ", values.Select((value, index) => value.PadRight(widths[index]))).TrimEnd()); + + private static string RenderCell(JsonElement value) + { + var rendered = value.ValueKind switch + { + JsonValueKind.String => value.GetString() ?? string.Empty, + JsonValueKind.Null or JsonValueKind.Undefined => string.Empty, + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => value.GetRawText() + }; + + return rendered.Replace('\r', ' ').Replace('\n', ' '); + } +} diff --git a/apps/cli/src/Output/OutputFormat.cs b/apps/cli/src/Output/OutputFormat.cs new file mode 100644 index 0000000..68235d5 --- /dev/null +++ b/apps/cli/src/Output/OutputFormat.cs @@ -0,0 +1,7 @@ +namespace BitFinance.Cli.Output; + +public enum OutputFormat +{ + Json, + Table +} diff --git a/apps/cli/src/Program.cs b/apps/cli/src/Program.cs new file mode 100644 index 0000000..d6bc7b7 --- /dev/null +++ b/apps/cli/src/Program.cs @@ -0,0 +1,3 @@ +using BitFinance.Cli; + +return await CliApplication.CreateDefault().RunAsync(args); diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinance.Cli.UnitTests.csproj b/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinance.Cli.UnitTests.csproj new file mode 100644 index 0000000..3ca3cfa --- /dev/null +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinance.Cli.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/CliApplicationTests.cs b/apps/cli/tests/BitFinance.Cli.UnitTests/CliApplicationTests.cs new file mode 100644 index 0000000..5b0825c --- /dev/null +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/CliApplicationTests.cs @@ -0,0 +1,42 @@ +using System.Text.Json; +using BitFinance.Cli.Errors; + +namespace BitFinance.Cli.UnitTests; + +public sealed class CliApplicationTests +{ + [Fact] + public async Task RunAsync_Help_DoesNotRequireEnvironmentConfiguration() + { + using var standardOutput = new StringWriter(); + using var standardError = new StringWriter(); + var application = new CliApplication( + new DictionaryEnvironment(new Dictionary()), + standardOutput, + standardError); + + var exitCode = await application.RunAsync(["--help"]); + + Assert.Equal(ExitCodes.Success, exitCode); + Assert.Contains("Agent-oriented command-line client", standardOutput.ToString()); + Assert.Equal(string.Empty, standardError.ToString()); + } + + [Fact] + public async Task RunAsync_InvalidOutputFormat_WritesStructuredParseError() + { + using var standardOutput = new StringWriter(); + using var standardError = new StringWriter(); + var application = new CliApplication( + new DictionaryEnvironment(new Dictionary()), + standardOutput, + standardError); + + var exitCode = await application.RunAsync(["--output", "yaml"]); + + Assert.Equal(ExitCodes.InvalidInput, exitCode); + Assert.Equal(string.Empty, standardOutput.ToString()); + using var json = JsonDocument.Parse(standardError.ToString()); + Assert.Equal("invalid_arguments", json.RootElement.GetProperty("error").GetProperty("code").GetString()); + } +} diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/CliConfigurationTests.cs b/apps/cli/tests/BitFinance.Cli.UnitTests/CliConfigurationTests.cs new file mode 100644 index 0000000..4298517 --- /dev/null +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/CliConfigurationTests.cs @@ -0,0 +1,109 @@ +using BitFinance.Cli.Configuration; +using BitFinance.Cli.Errors; + +namespace BitFinance.Cli.UnitTests; + +public sealed class CliConfigurationTests +{ + [Fact] + public void Load_ValidEnvironment_ReturnsConfiguration() + { + var environment = new DictionaryEnvironment(new Dictionary + { + [CliConfiguration.ApiBaseUrlVariable] = "https://api.example.com/", + [CliConfiguration.AccessTokenVariable] = "test-token", + [CliConfiguration.ApiVersionVariable] = "2" + }); + + var configuration = CliConfiguration.Load(environment); + + Assert.Equal(new Uri("https://api.example.com/"), configuration.ApiBaseUrl); + Assert.Equal("test-token", configuration.AccessToken); + Assert.Equal("2", configuration.ApiVersion); + } + + [Fact] + public void Load_OmittedApiVersion_DefaultsToOne() + { + var environment = ValidEnvironment(); + + var configuration = CliConfiguration.Load(environment); + + Assert.Equal("1", configuration.ApiVersion); + } + + [Theory] + [InlineData(CliConfiguration.ApiBaseUrlVariable)] + [InlineData(CliConfiguration.AccessTokenVariable)] + public void Load_MissingRequiredValue_ThrowsStructuredConfigurationException(string missingVariable) + { + var values = new Dictionary + { + [CliConfiguration.ApiBaseUrlVariable] = "https://api.example.com", + [CliConfiguration.AccessTokenVariable] = "test-token" + }; + values.Remove(missingVariable); + + var exception = Assert.Throws(() => + CliConfiguration.Load(new DictionaryEnvironment(values))); + + Assert.Equal(ExitCodes.InvalidInput, exception.ExitCode); + Assert.Equal("invalid_configuration", exception.Error.Code); + Assert.Contains(missingVariable, exception.Error.Message); + } + + [Theory] + [InlineData("api.example.com")] + [InlineData("file:///tmp/bitfinance")] + public void Load_InvalidApiUrl_ThrowsConfigurationException(string value) + { + var environment = ValidEnvironment(new Dictionary + { + [CliConfiguration.ApiBaseUrlVariable] = value + }); + + var exception = Assert.Throws(() => CliConfiguration.Load(environment)); + + Assert.Equal("invalid_configuration", exception.Error.Code); + } + + [Theory] + [InlineData("0")] + [InlineData("v1")] + [InlineData("1.0")] + public void Load_InvalidApiVersion_ThrowsConfigurationException(string value) + { + var environment = ValidEnvironment(new Dictionary + { + [CliConfiguration.ApiVersionVariable] = value + }); + + var exception = Assert.Throws(() => CliConfiguration.Load(environment)); + + Assert.Equal("invalid_configuration", exception.Error.Code); + } + + private static DictionaryEnvironment ValidEnvironment(Dictionary? overrides = null) + { + var values = new Dictionary + { + [CliConfiguration.ApiBaseUrlVariable] = "https://api.example.com", + [CliConfiguration.AccessTokenVariable] = "test-token" + }; + + if (overrides is not null) + { + foreach (var item in overrides) + { + values[item.Key] = item.Value; + } + } + + return new DictionaryEnvironment(values); + } +} + +internal sealed class DictionaryEnvironment(IReadOnlyDictionary values) : IEnvironmentVariables +{ + public string? Get(string name) => values.GetValueOrDefault(name); +} diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/CliOutputWriterTests.cs b/apps/cli/tests/BitFinance.Cli.UnitTests/CliOutputWriterTests.cs new file mode 100644 index 0000000..9c7d2b6 --- /dev/null +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/CliOutputWriterTests.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using BitFinance.Cli.Errors; +using BitFinance.Cli.Output; + +namespace BitFinance.Cli.UnitTests; + +public sealed class CliOutputWriterTests +{ + [Fact] + public void WriteSuccess_Json_WritesSingleCamelCaseValueToStandardOutput() + { + using var standardOutput = new StringWriter(); + using var standardError = new StringWriter(); + var writer = new CliOutputWriter(standardOutput, standardError); + + writer.WriteSuccess(new { ItemId = 42, DisplayName = "Budget" }, OutputFormat.Json); + + using var json = JsonDocument.Parse(standardOutput.ToString()); + Assert.Equal(42, json.RootElement.GetProperty("itemId").GetInt32()); + Assert.Equal("Budget", json.RootElement.GetProperty("displayName").GetString()); + Assert.Equal(string.Empty, standardError.ToString()); + Assert.Single(standardOutput.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)); + } + + [Fact] + public void WriteSuccess_Table_WritesPlainTextTable() + { + using var standardOutput = new StringWriter(); + var writer = new CliOutputWriter(standardOutput, TextWriter.Null); + + writer.WriteSuccess( + new[] + { + new { Id = 1, Name = "Primary" }, + new { Id = 2, Name = "Savings" } + }, + OutputFormat.Table); + + var output = standardOutput.ToString(); + Assert.Contains("id", output); + Assert.Contains("name", output); + Assert.Contains("Primary", output); + Assert.False(output.Contains('\u001b')); + } + + [Fact] + public void WriteError_WritesStableEnvelopeOnlyToStandardError() + { + using var standardOutput = new StringWriter(); + using var standardError = new StringWriter(); + var writer = new CliOutputWriter(standardOutput, standardError); + + writer.WriteError(new CliError("api_error", "Request failed.", 422)); + + using var json = JsonDocument.Parse(standardError.ToString()); + var error = json.RootElement.GetProperty("error"); + Assert.Equal("api_error", error.GetProperty("code").GetString()); + Assert.Equal(422, error.GetProperty("httpStatus").GetInt32()); + Assert.Equal(JsonValueKind.Null, error.GetProperty("details").ValueKind); + Assert.Equal(string.Empty, standardOutput.ToString()); + } +} diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/GlobalUsings.cs b/apps/cli/tests/BitFinance.Cli.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; From fd4d8c266d008e6facdb70b250edf4afb0f35285 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 22 Aug 2026 01:17:40 -0300 Subject: [PATCH 2/4] feat(cli): add read-only finance commands --- apps/cli/src/CliApplication.cs | 23 +- apps/cli/src/CliCommandFactory.cs | 2 + apps/cli/src/Commands/CliValueParsers.cs | 34 ++ apps/cli/src/Commands/ReadCommandTables.cs | 89 ++++++ apps/cli/src/Commands/ReadCommands.cs | 264 ++++++++++++++++ apps/cli/src/Errors/CliError.cs | 11 + apps/cli/src/Errors/CliException.cs | 3 + apps/cli/src/Models/BitFinanceModels.cs | 110 +++++++ apps/cli/src/Output/CliOutputWriter.cs | 18 +- apps/cli/src/Output/TableData.cs | 5 + apps/cli/src/Services/BitFinanceApiClient.cs | 149 +++++++++ .../src/Services/BitFinanceApiException.cs | 35 ++ apps/cli/src/Services/IBitFinanceApiClient.cs | 51 +++ .../Services/IBitFinanceApiClientFactory.cs | 22 ++ .../BitFinanceApiClientTests.cs | 201 ++++++++++++ .../ReadCommandTests.cs | 299 ++++++++++++++++++ 16 files changed, 1311 insertions(+), 5 deletions(-) create mode 100644 apps/cli/src/Commands/CliValueParsers.cs create mode 100644 apps/cli/src/Commands/ReadCommandTables.cs create mode 100644 apps/cli/src/Commands/ReadCommands.cs create mode 100644 apps/cli/src/Models/BitFinanceModels.cs create mode 100644 apps/cli/src/Output/TableData.cs create mode 100644 apps/cli/src/Services/BitFinanceApiClient.cs create mode 100644 apps/cli/src/Services/BitFinanceApiException.cs create mode 100644 apps/cli/src/Services/IBitFinanceApiClient.cs create mode 100644 apps/cli/src/Services/IBitFinanceApiClientFactory.cs create mode 100644 apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientTests.cs create mode 100644 apps/cli/tests/BitFinance.Cli.UnitTests/ReadCommandTests.cs diff --git a/apps/cli/src/CliApplication.cs b/apps/cli/src/CliApplication.cs index 8a8a880..5310163 100644 --- a/apps/cli/src/CliApplication.cs +++ b/apps/cli/src/CliApplication.cs @@ -1,6 +1,7 @@ using BitFinance.Cli.Configuration; using BitFinance.Cli.Errors; using BitFinance.Cli.Output; +using BitFinance.Cli.Services; using System.CommandLine; namespace BitFinance.Cli; @@ -11,14 +12,21 @@ public sealed class CliApplication private readonly RootCommand _rootCommand; private readonly CliOutputWriter _output; - public CliApplication(IEnvironmentVariables environment, TextWriter standardOutput, TextWriter standardError) + public CliApplication( + IEnvironmentVariables environment, + TextWriter standardOutput, + TextWriter standardError, + IBitFinanceApiClientFactory? apiClientFactory = null) { ArgumentNullException.ThrowIfNull(environment); ArgumentNullException.ThrowIfNull(standardOutput); ArgumentNullException.ThrowIfNull(standardError); _output = new CliOutputWriter(standardOutput, standardError); - Services = new CliServices(environment, _output); + Services = new CliServices( + environment, + _output, + apiClientFactory ?? new BitFinanceApiClientFactory()); _rootCommand = CliCommandFactory.Create(Services); } @@ -58,6 +66,12 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok _output.WriteError(exception.Error); return exception.ExitCode; } + catch (BitFinanceApiException exception) + { + var authenticationFailure = exception.StatusCode is 401 or 403; + _output.WriteError(CliError.Api(exception.StatusCode, exception.Details, authenticationFailure)); + return authenticationFailure ? ExitCodes.AuthenticationFailure : ExitCodes.ApiFailure; + } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { _output.WriteError(CliError.Cancelled()); @@ -81,4 +95,7 @@ public async Task RunAsync(string[] args, CancellationToken cancellationTok } } -public sealed record CliServices(IEnvironmentVariables Environment, CliOutputWriter Output); +public sealed record CliServices( + IEnvironmentVariables Environment, + CliOutputWriter Output, + IBitFinanceApiClientFactory ApiClientFactory); diff --git a/apps/cli/src/CliCommandFactory.cs b/apps/cli/src/CliCommandFactory.cs index 42896d4..757e58b 100644 --- a/apps/cli/src/CliCommandFactory.cs +++ b/apps/cli/src/CliCommandFactory.cs @@ -1,4 +1,5 @@ using BitFinance.Cli.Output; +using BitFinance.Cli.Commands; using System.CommandLine; namespace BitFinance.Cli; @@ -18,6 +19,7 @@ public static RootCommand Create(CliServices services) var rootCommand = new RootCommand("Agent-oriented command-line client for BitFinance."); rootCommand.Options.Add(outputOption); + ReadCommands.AddTo(rootCommand, services, outputOption); return rootCommand; } diff --git a/apps/cli/src/Commands/CliValueParsers.cs b/apps/cli/src/Commands/CliValueParsers.cs new file mode 100644 index 0000000..2a09492 --- /dev/null +++ b/apps/cli/src/Commands/CliValueParsers.cs @@ -0,0 +1,34 @@ +using BitFinance.Cli.Errors; +using System.Globalization; + +namespace BitFinance.Cli.Commands; + +internal static class CliValueParsers +{ + public static DateTimeOffset? ParseOptionalDate(string? value, string optionName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (!DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var parsed)) + { + throw CliException.InvalidArguments($"{optionName} must be an ISO 8601 date and time with an offset."); + } + + return parsed; + } + + public static void ValidateDateRange(DateTimeOffset? from, DateTimeOffset? to) + { + if (from.HasValue && to.HasValue && from.Value > to.Value) + { + throw CliException.InvalidArguments("--from must be earlier than or equal to --to."); + } + } +} diff --git a/apps/cli/src/Commands/ReadCommandTables.cs b/apps/cli/src/Commands/ReadCommandTables.cs new file mode 100644 index 0000000..ec0b40e --- /dev/null +++ b/apps/cli/src/Commands/ReadCommandTables.cs @@ -0,0 +1,89 @@ +using BitFinance.Cli.Models; +using BitFinance.Cli.Output; +using System.Globalization; + +namespace BitFinance.Cli.Commands; + +internal static class ReadCommandTables +{ + public static TableData Organizations(IEnumerable organizations) => + Table( + ["Id", "Name", "Plan"], + organizations.Select(item => Row(item.Id, item.Name, item.PlanTier))); + + public static TableData Organization(OrganizationDetailsResponse organization) => + Table( + ["Field", "Value"], + [ + Row("Id", organization.Id), + Row("Name", organization.Name), + Row("Plan", organization.PlanTier), + Row("Plan expires", organization.PlanExpiresAt), + Row("Members", organization.Members.Count) + ]); + + public static TableData Bills(IEnumerable bills) => + Table( + ["Id", "Description", "Category", "Status", "Amount due", "Due date"], + bills.Select(item => Row( + item.Id, + item.Description, + item.Category, + item.Status, + item.AmountDue, + item.DueDate))); + + public static TableData Bill(BillResponse bill) => + Bills([bill]); + + public static TableData Expenses(IEnumerable expenses) => + Table( + ["Id", "Description", "Category", "Status", "Amount", "Occurred at"], + expenses.Select(item => Row( + item.Id, + item.Description, + item.Category, + item.Status, + item.Amount, + item.OccurredAt))); + + public static TableData Expense(ExpenseResponse expense) => + Expenses([expense]); + + public static TableData UpcomingBills(UpcomingBillsResponse response) => + Table( + ["Id", "Description", "Category", "Status", "Amount due", "Due date"], + response.Data.Select(item => Row( + item.Id, + item.Description, + item.Category, + item.Status, + item.AmountDue, + item.DueDate))); + + public static TableData RecentExpenses(RecentExpensesResponse response) => + Table( + ["Id", "Description", "Category", "Amount", "Date"], + response.Data.Select(item => Row( + item.Id, + item.Description, + item.Category, + item.Amount, + item.Date))); + + private static TableData Table(IEnumerable headers, IEnumerable> rows) => + new(headers.ToArray(), rows.ToArray()); + + private static IReadOnlyList Row(params object?[] values) => + values.Select(Format).ToArray(); + + private static string Format(object? value) => + value switch + { + null => string.Empty, + DateTimeOffset date => date.ToString("O", CultureInfo.InvariantCulture), + decimal number => number.ToString("0.00", CultureInfo.InvariantCulture), + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; +} diff --git a/apps/cli/src/Commands/ReadCommands.cs b/apps/cli/src/Commands/ReadCommands.cs new file mode 100644 index 0000000..8c7c8a9 --- /dev/null +++ b/apps/cli/src/Commands/ReadCommands.cs @@ -0,0 +1,264 @@ +using BitFinance.Cli.Configuration; +using BitFinance.Cli.Models; +using BitFinance.Cli.Output; +using BitFinance.Cli.Services; +using System.CommandLine; + +namespace BitFinance.Cli.Commands; + +public static class ReadCommands +{ + public static void AddTo( + RootCommand rootCommand, + CliServices services, + Option outputOption) + { + rootCommand.Subcommands.Add(BuildOrganizationsCommand(services, outputOption)); + rootCommand.Subcommands.Add(BuildDashboardCommand(services, outputOption)); + rootCommand.Subcommands.Add(BuildBillsCommand(services, outputOption)); + rootCommand.Subcommands.Add(BuildExpensesCommand(services, outputOption)); + } + + private static Command BuildOrganizationsCommand( + CliServices services, + Option outputOption) + { + var group = new Command("organizations", "Read BitFinance organizations."); + var list = new Command("list", "List organizations accessible to the authenticated user."); + SetReadAction( + list, + services, + outputOption, + (_, client, cancellationToken) => client.ListOrganizationsAsync(cancellationToken), + ReadCommandTables.Organizations); + + var get = new Command("get", "Get one organization and its members."); + var organizationOption = OrganizationOption(); + get.Options.Add(organizationOption); + SetReadAction( + get, + services, + outputOption, + (result, client, cancellationToken) => client.GetOrganizationAsync( + result.GetRequiredValue(organizationOption), + cancellationToken), + ReadCommandTables.Organization); + + group.Subcommands.Add(list); + group.Subcommands.Add(get); + return group; + } + + private static Command BuildDashboardCommand( + CliServices services, + Option outputOption) + { + var group = new Command("dashboard", "Read BitFinance dashboard data."); + + var upcomingBills = new Command("upcoming-bills", "Get upcoming bills for an organization."); + var upcomingOrganizationOption = OrganizationOption(); + upcomingBills.Options.Add(upcomingOrganizationOption); + SetReadAction( + upcomingBills, + services, + outputOption, + (result, client, cancellationToken) => client.GetUpcomingBillsAsync( + result.GetRequiredValue(upcomingOrganizationOption), + cancellationToken), + ReadCommandTables.UpcomingBills); + + var recentExpenses = new Command("recent-expenses", "Get recent expenses for an organization."); + var recentOrganizationOption = OrganizationOption(); + recentExpenses.Options.Add(recentOrganizationOption); + SetReadAction( + recentExpenses, + services, + outputOption, + (result, client, cancellationToken) => client.GetRecentExpensesAsync( + result.GetRequiredValue(recentOrganizationOption), + cancellationToken), + ReadCommandTables.RecentExpenses); + + group.Subcommands.Add(upcomingBills); + group.Subcommands.Add(recentExpenses); + return group; + } + + private static Command BuildBillsCommand(CliServices services, Option outputOption) + { + var group = new Command("bills", "Read BitFinance bills."); + var list = new Command("list", "List bills for an organization."); + var organizationOption = OrganizationOption(); + var pageOption = PositiveIntegerOption("--page", "Page number.", 1); + var pageSizeOption = PositiveIntegerOption("--page-size", "Number of bills per page.", 100); + var fromOption = OptionalStringOption("--from", "Inclusive ISO 8601 start date and time."); + var toOption = OptionalStringOption("--to", "Inclusive ISO 8601 end date and time."); + var statusOption = OptionalStringOption("--status", "Comma-separated bill statuses."); + var descriptionOption = OptionalStringOption("--description", "Case-insensitive description search."); + list.Options.Add(organizationOption); + list.Options.Add(pageOption); + list.Options.Add(pageSizeOption); + list.Options.Add(fromOption); + list.Options.Add(toOption); + list.Options.Add(statusOption); + list.Options.Add(descriptionOption); + SetReadAction( + list, + services, + outputOption, + async (result, client, cancellationToken) => + { + var (from, to) = ParseDateRange(result, fromOption, toOption); + return await client.ListBillsAsync( + result.GetRequiredValue(organizationOption), + result.GetValue(pageOption), + result.GetValue(pageSizeOption), + from, + to, + result.GetValue(statusOption), + result.GetValue(descriptionOption), + cancellationToken); + }, + response => ReadCommandTables.Bills(response.Data)); + + var get = new Command("get", "Get one bill."); + var getOrganizationOption = OrganizationOption(); + var billIdOption = RequiredGuidOption("--bill-id", "Bill ID."); + get.Options.Add(getOrganizationOption); + get.Options.Add(billIdOption); + SetReadAction( + get, + services, + outputOption, + (result, client, cancellationToken) => client.GetBillAsync( + result.GetRequiredValue(getOrganizationOption), + result.GetRequiredValue(billIdOption), + cancellationToken), + ReadCommandTables.Bill); + + group.Subcommands.Add(list); + group.Subcommands.Add(get); + return group; + } + + private static Command BuildExpensesCommand(CliServices services, Option outputOption) + { + var group = new Command("expenses", "Read BitFinance expenses."); + var list = new Command("list", "List expenses for an organization."); + var organizationOption = OrganizationOption(); + var pageOption = PositiveIntegerOption("--page", "Page number.", 1); + var pageSizeOption = PositiveIntegerOption("--page-size", "Number of expenses per page.", 20); + var fromOption = OptionalStringOption("--from", "Inclusive ISO 8601 start date and time."); + var toOption = OptionalStringOption("--to", "Inclusive ISO 8601 end date and time."); + var statusOption = OptionalStringOption("--status", "Expense status."); + var descriptionOption = OptionalStringOption("--description", "Case-insensitive description search."); + var paymentMethodOption = OptionalStringOption("--payment-method", "Payment method."); + list.Options.Add(organizationOption); + list.Options.Add(pageOption); + list.Options.Add(pageSizeOption); + list.Options.Add(fromOption); + list.Options.Add(toOption); + list.Options.Add(statusOption); + list.Options.Add(descriptionOption); + list.Options.Add(paymentMethodOption); + SetReadAction( + list, + services, + outputOption, + async (result, client, cancellationToken) => + { + var (from, to) = ParseDateRange(result, fromOption, toOption); + return await client.ListExpensesAsync( + result.GetRequiredValue(organizationOption), + result.GetValue(pageOption), + result.GetValue(pageSizeOption), + from, + to, + result.GetValue(statusOption), + result.GetValue(descriptionOption), + result.GetValue(paymentMethodOption), + cancellationToken); + }, + response => ReadCommandTables.Expenses(response.Data)); + + var get = new Command("get", "Get one expense."); + var getOrganizationOption = OrganizationOption(); + var expenseIdOption = RequiredGuidOption("--expense-id", "Expense ID."); + get.Options.Add(getOrganizationOption); + get.Options.Add(expenseIdOption); + SetReadAction( + get, + services, + outputOption, + (result, client, cancellationToken) => client.GetExpenseAsync( + result.GetRequiredValue(getOrganizationOption), + result.GetRequiredValue(expenseIdOption), + cancellationToken), + ReadCommandTables.Expense); + + group.Subcommands.Add(list); + group.Subcommands.Add(get); + return group; + } + + private static void SetReadAction( + Command command, + CliServices services, + Option outputOption, + Func> execute, + Func table) + { + command.SetAction(async (parseResult, cancellationToken) => + { + var configuration = CliConfiguration.Load(services.Environment); + var client = services.ApiClientFactory.Create(configuration); + var response = await execute(parseResult, client, cancellationToken); + services.Output.WriteSuccess(response, parseResult.GetValue(outputOption), table(response)); + return 0; + }); + } + + private static Option OrganizationOption() => + RequiredGuidOption("--organization-id", "Organization ID."); + + private static Option RequiredGuidOption(string name, string description) => + new(name) + { + Description = description, + Required = true + }; + + private static Option PositiveIntegerOption( + string name, + string description, + int defaultValue) + { + var option = new Option(name) + { + Description = description, + DefaultValueFactory = _ => defaultValue + }; + option.Validators.Add(result => + { + if (result.GetValueOrDefault() < 1) + { + result.AddError($"{name} must be greater than zero."); + } + }); + return option; + } + + private static Option OptionalStringOption(string name, string description) => + new(name) { Description = description }; + + private static (DateTimeOffset? From, DateTimeOffset? To) ParseDateRange( + ParseResult result, + Option fromOption, + Option toOption) + { + var from = CliValueParsers.ParseOptionalDate(result.GetValue(fromOption), "--from"); + var to = CliValueParsers.ParseOptionalDate(result.GetValue(toOption), "--to"); + CliValueParsers.ValidateDateRange(from, to); + return (from, to); + } +} diff --git a/apps/cli/src/Errors/CliError.cs b/apps/cli/src/Errors/CliError.cs index ac1bbc8..d29b089 100644 --- a/apps/cli/src/Errors/CliError.cs +++ b/apps/cli/src/Errors/CliError.cs @@ -8,6 +8,17 @@ public static CliError InvalidArguments(string message) => public static CliError Configuration(string message) => new("invalid_configuration", message); + public static CliError Api(int httpStatus, object? details, bool authenticationFailure) => + authenticationFailure + ? new( + httpStatus == 401 ? "authentication_failed" : "authorization_failed", + httpStatus == 401 + ? "The BitFinance access token is missing, invalid, or expired." + : "The authenticated user is not authorized to perform this operation.", + httpStatus, + details) + : new("api_error", $"The BitFinance API returned HTTP {httpStatus}.", httpStatus, details); + public static CliError Transport(string message) => new("transport_error", message); diff --git a/apps/cli/src/Errors/CliException.cs b/apps/cli/src/Errors/CliException.cs index 4133321..24e82bf 100644 --- a/apps/cli/src/Errors/CliException.cs +++ b/apps/cli/src/Errors/CliException.cs @@ -15,4 +15,7 @@ public CliException(CliError error, int exitCode) public static CliException Configuration(string message) => new(CliError.Configuration(message), ExitCodes.InvalidInput); + + public static CliException InvalidArguments(string message) => + new(CliError.InvalidArguments(message), ExitCodes.InvalidInput); } diff --git a/apps/cli/src/Models/BitFinanceModels.cs b/apps/cli/src/Models/BitFinanceModels.cs new file mode 100644 index 0000000..3bc564d --- /dev/null +++ b/apps/cli/src/Models/BitFinanceModels.cs @@ -0,0 +1,110 @@ +namespace BitFinance.Cli.Models; + +public sealed record OrganizationSummaryResponse(Guid Id, string Name, string PlanTier); + +public sealed class OrganizationDetailsResponse +{ + public Guid Id { get; init; } + public string Name { get; init; } = string.Empty; + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; init; } + public string PlanTier { get; init; } = string.Empty; + public DateTimeOffset PlanExpiresAt { get; init; } + public List Members { get; init; } = []; +} + +public sealed record OrganizationMemberResponse(string Id, string UserName, string Email); + +public class PagedResponse +{ + public List Data { get; init; } = []; + public int Page { get; init; } + public int PageSize { get; init; } + public int TotalRecords { get; init; } + public int TotalPages { get; init; } +} + +public sealed class AttachmentResponse +{ + public Guid Id { get; init; } + public string FileName { get; init; } = string.Empty; + public string ContentType { get; init; } = string.Empty; + public string FileCategory { get; init; } = string.Empty; + public string AttachmentType { get; init; } = string.Empty; +} + +public sealed class BillResponse +{ + public Guid Id { get; init; } + public string Description { get; init; } = string.Empty; + public string? Notes { get; init; } + public string Category { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public decimal AmountDue { get; init; } + public decimal? AmountPaid { get; init; } + public DateTimeOffset? CreatedAt { get; init; } + public DateTimeOffset? CreatedDate { get; init; } + public DateTimeOffset? DueDate { get; init; } + public DateTimeOffset? PaymentDate { get; init; } + public DateTimeOffset? PaidDate { get; init; } + public Guid? BillSeriesId { get; init; } + public int? OccurrenceNumber { get; init; } + public int? TotalOccurrences { get; init; } + public string? BillSeriesType { get; init; } + public bool? BillSeriesIsActive { get; init; } + public List Attachments { get; init; } = []; +} + +public sealed class ExpenseResponse +{ + public Guid Id { get; init; } + public string Description { get; init; } = string.Empty; + public string? Notes { get; init; } + public string Category { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public string? PaymentMethod { get; init; } + public decimal Amount { get; init; } + public DateTimeOffset OccurredAt { get; init; } + public string CreatedBy { get; init; } = string.Empty; + public List Attachments { get; init; } = []; +} + +public sealed class ExpensePageResponse : PagedResponse +{ + public ExpenseSummaryResponse Summary { get; init; } = new(); +} + +public sealed class ExpenseSummaryResponse +{ + public decimal TotalAmount { get; init; } + public decimal AverageAmount { get; init; } +} + +public sealed class UpcomingBillsResponse +{ + public List Data { get; init; } = []; +} + +public sealed class RecentExpensesResponse +{ + public List Data { get; init; } = []; +} + +public sealed class DashboardBillResponse +{ + public Guid Id { get; init; } + public string Description { get; init; } = string.Empty; + public string Category { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public decimal AmountDue { get; init; } + public DateTimeOffset DueDate { get; init; } +} + +public sealed class DashboardExpenseResponse +{ + public Guid Id { get; init; } + public string Description { get; init; } = string.Empty; + public string Category { get; init; } = string.Empty; + public decimal Amount { get; init; } + public DateTimeOffset Date { get; init; } +} diff --git a/apps/cli/src/Output/CliOutputWriter.cs b/apps/cli/src/Output/CliOutputWriter.cs index d2a7f2f..c06b50b 100644 --- a/apps/cli/src/Output/CliOutputWriter.cs +++ b/apps/cli/src/Output/CliOutputWriter.cs @@ -20,11 +20,19 @@ public CliOutputWriter(TextWriter standardOutput, TextWriter standardError) public TextWriter StandardError { get; } - public void WriteSuccess(T value, OutputFormat format) + public void WriteSuccess(T value, OutputFormat format, TableData? table = null) { if (format == OutputFormat.Table) { - WriteTable(JsonSerializer.SerializeToElement(value, JsonOptions)); + if (table is not null) + { + WriteRows(table.Headers.ToArray(), table.Rows.Select(row => row.ToArray()).ToArray()); + } + else + { + WriteTable(JsonSerializer.SerializeToElement(value, JsonOptions)); + } + return; } @@ -90,6 +98,12 @@ private void WriteObjectTable(JsonElement value) private void WriteRows(string[] headers, string[][] rows) { + if (rows.Length == 0) + { + StandardOutput.WriteLine("(no results)"); + return; + } + var widths = headers.Select(header => header.Length).ToArray(); foreach (var row in rows) { diff --git a/apps/cli/src/Output/TableData.cs b/apps/cli/src/Output/TableData.cs new file mode 100644 index 0000000..52831ef --- /dev/null +++ b/apps/cli/src/Output/TableData.cs @@ -0,0 +1,5 @@ +namespace BitFinance.Cli.Output; + +public sealed record TableData( + IReadOnlyList Headers, + IReadOnlyList> Rows); diff --git a/apps/cli/src/Services/BitFinanceApiClient.cs b/apps/cli/src/Services/BitFinanceApiClient.cs new file mode 100644 index 0000000..24f0f40 --- /dev/null +++ b/apps/cli/src/Services/BitFinanceApiClient.cs @@ -0,0 +1,149 @@ +using BitFinance.Cli.Configuration; +using BitFinance.Cli.Models; +using System.Globalization; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; + +namespace BitFinance.Cli.Services; + +public sealed class BitFinanceApiClient : IBitFinanceApiClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly HttpClient _httpClient; + private readonly CliConfiguration _configuration; + + public BitFinanceApiClient(HttpClient httpClient, CliConfiguration configuration) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public Task> ListOrganizationsAsync(CancellationToken cancellationToken = default) => + SendAsync>(HttpMethod.Get, ApiPath("organizations"), cancellationToken); + + public Task GetOrganizationAsync( + Guid organizationId, + CancellationToken cancellationToken = default) => + SendAsync( + HttpMethod.Get, + ApiPath($"organizations/{organizationId}"), + cancellationToken); + + public Task GetUpcomingBillsAsync( + Guid organizationId, + CancellationToken cancellationToken = default) => + SendAsync( + HttpMethod.Get, + ApiPath($"organizations/{organizationId}/dashboard/upcoming-bills"), + cancellationToken); + + public Task GetRecentExpensesAsync( + Guid organizationId, + CancellationToken cancellationToken = default) => + SendAsync( + HttpMethod.Get, + ApiPath($"organizations/{organizationId}/dashboard/recent-expenses"), + cancellationToken); + + public Task> ListBillsAsync( + Guid organizationId, + int page, + int pageSize, + DateTimeOffset? from, + DateTimeOffset? to, + string? status, + string? description, + CancellationToken cancellationToken = default) + { + var path = WithQuery( + ApiPath($"organizations/{organizationId}/bills"), + ("page", page.ToString(CultureInfo.InvariantCulture)), + ("pageSize", pageSize.ToString(CultureInfo.InvariantCulture)), + ("from", FormatDate(from)), + ("to", FormatDate(to)), + ("status", status), + ("description", description)); + + return SendAsync>(HttpMethod.Get, path, cancellationToken); + } + + public Task GetBillAsync( + Guid organizationId, + Guid billId, + CancellationToken cancellationToken = default) => + SendAsync( + HttpMethod.Get, + ApiPath($"organizations/{organizationId}/bills/{billId}"), + cancellationToken); + + public Task ListExpensesAsync( + Guid organizationId, + int page, + int pageSize, + DateTimeOffset? from, + DateTimeOffset? to, + string? status, + string? description, + string? paymentMethod, + CancellationToken cancellationToken = default) + { + var path = WithQuery( + ApiPath($"organizations/{organizationId}/expenses"), + ("page", page.ToString(CultureInfo.InvariantCulture)), + ("pageSize", pageSize.ToString(CultureInfo.InvariantCulture)), + ("from", FormatDate(from)), + ("to", FormatDate(to)), + ("status", status), + ("description", description), + ("paymentMethod", paymentMethod)); + + return SendAsync(HttpMethod.Get, path, cancellationToken); + } + + public Task GetExpenseAsync( + Guid organizationId, + Guid expenseId, + CancellationToken cancellationToken = default) => + SendAsync( + HttpMethod.Get, + ApiPath($"organizations/{organizationId}/expenses/{expenseId}"), + cancellationToken); + + private async Task SendAsync( + HttpMethod method, + string path, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _configuration.AccessToken); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new BitFinanceApiException((int)response.StatusCode, method.Method, path, responseBody); + } + + var value = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return value ?? throw new InvalidOperationException( + $"BitFinance API returned an empty response for {method.Method} {path}."); + } + + private string ApiPath(string relativePath) => + $"/api/v{_configuration.ApiVersion}/{relativePath.TrimStart('/')}"; + + private static string? FormatDate(DateTimeOffset? value) => + value?.UtcDateTime.ToString("O", CultureInfo.InvariantCulture); + + private static string WithQuery(string path, params (string Name, string? Value)[] parameters) + { + var query = parameters + .Where(parameter => !string.IsNullOrWhiteSpace(parameter.Value)) + .Select(parameter => + $"{Uri.EscapeDataString(parameter.Name)}={Uri.EscapeDataString(parameter.Value!)}"); + + var queryString = string.Join("&", query); + return string.IsNullOrEmpty(queryString) ? path : $"{path}?{queryString}"; + } +} diff --git a/apps/cli/src/Services/BitFinanceApiException.cs b/apps/cli/src/Services/BitFinanceApiException.cs new file mode 100644 index 0000000..40442e6 --- /dev/null +++ b/apps/cli/src/Services/BitFinanceApiException.cs @@ -0,0 +1,35 @@ +using System.Text.Json; + +namespace BitFinance.Cli.Services; + +public sealed class BitFinanceApiException : Exception +{ + public BitFinanceApiException(int statusCode, string method, string path, string responseBody) + : base($"BitFinance API returned HTTP {statusCode} for {method} {path}.") + { + StatusCode = statusCode; + Details = ParseDetails(responseBody); + } + + public int StatusCode { get; } + + public object? Details { get; } + + private static object? ParseDetails(string responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(responseBody); + return document.RootElement.Clone(); + } + catch (JsonException) + { + return responseBody; + } + } +} diff --git a/apps/cli/src/Services/IBitFinanceApiClient.cs b/apps/cli/src/Services/IBitFinanceApiClient.cs new file mode 100644 index 0000000..a715dc7 --- /dev/null +++ b/apps/cli/src/Services/IBitFinanceApiClient.cs @@ -0,0 +1,51 @@ +using BitFinance.Cli.Models; + +namespace BitFinance.Cli.Services; + +public interface IBitFinanceApiClient +{ + Task> ListOrganizationsAsync(CancellationToken cancellationToken = default); + + Task GetOrganizationAsync( + Guid organizationId, + CancellationToken cancellationToken = default); + + Task GetUpcomingBillsAsync( + Guid organizationId, + CancellationToken cancellationToken = default); + + Task GetRecentExpensesAsync( + Guid organizationId, + CancellationToken cancellationToken = default); + + Task> ListBillsAsync( + Guid organizationId, + int page, + int pageSize, + DateTimeOffset? from, + DateTimeOffset? to, + string? status, + string? description, + CancellationToken cancellationToken = default); + + Task GetBillAsync( + Guid organizationId, + Guid billId, + CancellationToken cancellationToken = default); + + Task ListExpensesAsync( + Guid organizationId, + int page, + int pageSize, + DateTimeOffset? from, + DateTimeOffset? to, + string? status, + string? description, + string? paymentMethod, + CancellationToken cancellationToken = default); + + Task GetExpenseAsync( + Guid organizationId, + Guid expenseId, + CancellationToken cancellationToken = default); +} diff --git a/apps/cli/src/Services/IBitFinanceApiClientFactory.cs b/apps/cli/src/Services/IBitFinanceApiClientFactory.cs new file mode 100644 index 0000000..beabef0 --- /dev/null +++ b/apps/cli/src/Services/IBitFinanceApiClientFactory.cs @@ -0,0 +1,22 @@ +using BitFinance.Cli.Configuration; + +namespace BitFinance.Cli.Services; + +public interface IBitFinanceApiClientFactory +{ + IBitFinanceApiClient Create(CliConfiguration configuration); +} + +public sealed class BitFinanceApiClientFactory : IBitFinanceApiClientFactory +{ + public IBitFinanceApiClient Create(CliConfiguration configuration) + { + var httpClient = new HttpClient + { + BaseAddress = configuration.ApiBaseUrl, + Timeout = TimeSpan.FromSeconds(30) + }; + + return new BitFinanceApiClient(httpClient, configuration); + } +} diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientTests.cs b/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientTests.cs new file mode 100644 index 0000000..0dd1fbd --- /dev/null +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientTests.cs @@ -0,0 +1,201 @@ +using BitFinance.Cli.Configuration; +using BitFinance.Cli.Services; +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace BitFinance.Cli.UnitTests; + +public sealed class BitFinanceApiClientTests +{ + [Fact] + public async Task OrganizationRequests_UseExpectedRoutesAndBearerToken() + { + var organizationId = Guid.NewGuid(); + var handler = new RecordingHandler(request => request.RequestUri!.AbsolutePath.EndsWith("organizations") + ? JsonResponse(HttpStatusCode.OK, "[]") + : JsonResponse( + HttpStatusCode.OK, + $$""" + { + "id": "{{organizationId}}", + "name": "Household", + "planTier": "Basic", + "planExpiresAt": "2027-01-01T00:00:00Z", + "members": [] + } + """)); + var client = CreateClient(handler); + + await client.ListOrganizationsAsync(); + await client.GetOrganizationAsync(organizationId); + + Assert.Equal(2, handler.Requests.Count); + Assert.Equal("/api/v1/organizations", handler.Requests[0].Uri.AbsolutePath); + Assert.Equal($"/api/v1/organizations/{organizationId}", handler.Requests[1].Uri.AbsolutePath); + Assert.All(handler.Requests, request => + { + Assert.Equal("Bearer", request.Authorization?.Scheme); + Assert.Equal("test-token", request.Authorization?.Parameter); + }); + } + + [Fact] + public async Task DashboardRequests_UseExpectedRoutes() + { + var organizationId = Guid.NewGuid(); + var handler = new RecordingHandler(request => + request.RequestUri!.AbsolutePath.EndsWith("upcoming-bills") + ? JsonResponse(HttpStatusCode.OK, """{"data":[]}""") + : JsonResponse(HttpStatusCode.OK, """{"data":[]}""")); + var client = CreateClient(handler); + + await client.GetUpcomingBillsAsync(organizationId); + await client.GetRecentExpensesAsync(organizationId); + + Assert.Equal( + $"/api/v1/organizations/{organizationId}/dashboard/upcoming-bills", + handler.Requests[0].Uri.AbsolutePath); + Assert.Equal( + $"/api/v1/organizations/{organizationId}/dashboard/recent-expenses", + handler.Requests[1].Uri.AbsolutePath); + } + + [Fact] + public async Task ListBills_SendsPagingAndEncodedFilters() + { + var organizationId = Guid.NewGuid(); + var handler = new RecordingHandler(_ => JsonResponse( + HttpStatusCode.OK, + """{"data":[],"page":2,"pageSize":50,"totalRecords":0,"totalPages":0}""")); + var client = CreateClient(handler); + + await client.ListBillsAsync( + organizationId, + 2, + 50, + DateTimeOffset.Parse("2026-05-01T00:00:00-03:00"), + DateTimeOffset.Parse("2026-05-31T23:59:59-03:00"), + "Due,Overdue", + "rent & utilities"); + + var request = Assert.Single(handler.Requests); + Assert.Equal($"/api/v1/organizations/{organizationId}/bills", request.Uri.AbsolutePath); + var query = Uri.UnescapeDataString(request.Uri.Query); + Assert.Contains("page=2", query); + Assert.Contains("pageSize=50", query); + Assert.Contains("from=2026-05-01T03:00:00.0000000Z", query); + Assert.Contains("to=2026-06-01T02:59:59.0000000Z", query); + Assert.Contains("status=Due,Overdue", query); + Assert.Contains("description=rent & utilities", query); + } + + [Fact] + public async Task BillAndExpenseGetRequests_UseExplicitOrganizationId() + { + var organizationId = Guid.NewGuid(); + var billId = Guid.NewGuid(); + var expenseId = Guid.NewGuid(); + var handler = new RecordingHandler(request => request.RequestUri!.AbsolutePath.Contains("/bills/") + ? JsonResponse(HttpStatusCode.OK, $$"""{"id":"{{billId}}"}""") + : JsonResponse( + HttpStatusCode.OK, + $$"""{"id":"{{expenseId}}","occurredAt":"2026-01-01T00:00:00Z"}""")); + var client = CreateClient(handler); + + await client.GetBillAsync(organizationId, billId); + await client.GetExpenseAsync(organizationId, expenseId); + + Assert.Equal($"/api/v1/organizations/{organizationId}/bills/{billId}", handler.Requests[0].Uri.AbsolutePath); + Assert.Equal( + $"/api/v1/organizations/{organizationId}/expenses/{expenseId}", + handler.Requests[1].Uri.AbsolutePath); + } + + [Fact] + public async Task ListExpenses_SendsPagingAndAllFilters() + { + var organizationId = Guid.NewGuid(); + var handler = new RecordingHandler(_ => JsonResponse( + HttpStatusCode.OK, + """{"data":[],"summary":{"totalAmount":0,"averageAmount":0}}""")); + var client = CreateClient(handler, apiVersion: "2"); + + await client.ListExpensesAsync( + organizationId, + 3, + 25, + null, + null, + "Paid", + "coffee shop", + "CreditCard"); + + var request = Assert.Single(handler.Requests); + Assert.Equal($"/api/v2/organizations/{organizationId}/expenses", request.Uri.AbsolutePath); + var query = Uri.UnescapeDataString(request.Uri.Query); + Assert.Contains("page=3", query); + Assert.Contains("pageSize=25", query); + Assert.Contains("status=Paid", query); + Assert.Contains("description=coffee shop", query); + Assert.Contains("paymentMethod=CreditCard", query); + Assert.DoesNotContain("from=", query); + Assert.DoesNotContain("to=", query); + } + + [Fact] + public async Task FailedRequest_ThrowsApiExceptionWithParsedDetails() + { + var handler = new RecordingHandler(_ => JsonResponse( + HttpStatusCode.Unauthorized, + """{"message":"Token expired"}""")); + var client = CreateClient(handler); + + var exception = await Assert.ThrowsAsync(() => + client.ListOrganizationsAsync()); + + Assert.Equal(401, exception.StatusCode); + Assert.NotNull(exception.Details); + Assert.DoesNotContain("test-token", exception.ToString()); + } + + private static BitFinanceApiClient CreateClient(RecordingHandler handler, string apiVersion = "1") + { + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://api.example.com"), + Timeout = TimeSpan.FromSeconds(30) + }; + return new BitFinanceApiClient( + httpClient, + new CliConfiguration(httpClient.BaseAddress, "test-token", apiVersion)); + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, string json) => + new(statusCode) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + + private sealed class RecordingHandler( + Func responseFactory) : HttpMessageHandler + { + public List Requests { get; } = []; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(new RecordedRequest( + request.Method, + request.RequestUri!, + request.Headers.Authorization)); + return Task.FromResult(responseFactory(request)); + } + } + + private sealed record RecordedRequest( + HttpMethod Method, + Uri Uri, + AuthenticationHeaderValue? Authorization); +} diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/ReadCommandTests.cs b/apps/cli/tests/BitFinance.Cli.UnitTests/ReadCommandTests.cs new file mode 100644 index 0000000..f5ba683 --- /dev/null +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/ReadCommandTests.cs @@ -0,0 +1,299 @@ +using BitFinance.Cli.Configuration; +using BitFinance.Cli.Errors; +using BitFinance.Cli.Models; +using BitFinance.Cli.Services; +using System.Text.Json; + +namespace BitFinance.Cli.UnitTests; + +public sealed class ReadCommandTests +{ + public static TheoryData OrganizationScopedCommands => new() + { + new[] { "organizations", "get" }, + new[] { "dashboard", "upcoming-bills" }, + new[] { "dashboard", "recent-expenses" }, + new[] { "bills", "list" }, + new[] { "bills", "get", "--bill-id", Guid.NewGuid().ToString() }, + new[] { "expenses", "list" }, + new[] { "expenses", "get", "--expense-id", Guid.NewGuid().ToString() } + }; + + [Theory] + [MemberData(nameof(OrganizationScopedCommands))] + public async Task OrganizationScopedCommand_MissingOrganizationId_DoesNotCreateApiClient(string[] arguments) + { + var factory = new FakeApiClientFactory(new FakeApiClient()); + var result = await RunAsync(arguments, factory); + + Assert.Equal(ExitCodes.InvalidInput, result.ExitCode); + Assert.Equal(0, factory.CreateCount); + Assert.Equal(string.Empty, result.StandardOutput); + using var json = JsonDocument.Parse(result.StandardError); + Assert.Equal("invalid_arguments", json.RootElement.GetProperty("error").GetProperty("code").GetString()); + Assert.Contains("--organization-id", result.StandardError); + } + + [Fact] + public async Task OrganizationsList_WritesApiResponseAsJson() + { + var organizationId = Guid.NewGuid(); + var client = new FakeApiClient + { + Organizations = [new OrganizationSummaryResponse(organizationId, "Household", "Basic")] + }; + var result = await RunAsync(["organizations", "list"], new FakeApiClientFactory(client)); + + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.Equal(string.Empty, result.StandardError); + using var json = JsonDocument.Parse(result.StandardOutput); + Assert.Equal(organizationId, json.RootElement[0].GetProperty("id").GetGuid()); + Assert.Equal("Household", json.RootElement[0].GetProperty("name").GetString()); + } + + [Fact] + public async Task BillsList_UsesExplicitOrganizationAndMcpPagingDefaults() + { + var organizationId = Guid.NewGuid(); + var client = new FakeApiClient(); + + var result = await RunAsync( + ["bills", "list", "--organization-id", organizationId.ToString()], + new FakeApiClientFactory(client)); + + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.NotNull(client.BillListRequest); + Assert.Equal(organizationId, client.BillListRequest!.OrganizationId); + Assert.Equal(1, client.BillListRequest.Page); + Assert.Equal(100, client.BillListRequest.PageSize); + } + + [Fact] + public async Task ExpensesList_ParsesFiltersAndSupportsLowercaseTableOutput() + { + var organizationId = Guid.NewGuid(); + var client = new FakeApiClient + { + ExpensePage = new ExpensePageResponse + { + Data = + [ + new ExpenseResponse + { + Id = Guid.NewGuid(), + Description = "Coffee", + Category = "Food", + Status = "Paid", + Amount = 8.5m, + OccurredAt = DateTimeOffset.Parse("2026-08-20T10:00:00Z") + } + ] + } + }; + + var result = await RunAsync( + [ + "expenses", "list", + "--organization-id", organizationId.ToString(), + "--page", "2", + "--page-size", "30", + "--from", "2026-08-01T00:00:00-03:00", + "--status", "Paid", + "--description", "coffee", + "--payment-method", "Pix", + "--output", "table" + ], + new FakeApiClientFactory(client)); + + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.NotNull(client.ExpenseListRequest); + Assert.Equal(2, client.ExpenseListRequest!.Page); + Assert.Equal(30, client.ExpenseListRequest.PageSize); + Assert.Equal("Paid", client.ExpenseListRequest.Status); + Assert.Equal("coffee", client.ExpenseListRequest.Description); + Assert.Equal("Pix", client.ExpenseListRequest.PaymentMethod); + Assert.Contains("Coffee", result.StandardOutput); + Assert.DoesNotContain("{", result.StandardOutput); + } + + [Theory] + [InlineData("not-a-date", null)] + [InlineData("2026-09-01T00:00:00Z", "2026-08-01T00:00:00Z")] + public async Task BillsList_InvalidDateInput_DoesNotInvokeApi(string from, string? to) + { + var client = new FakeApiClient(); + var arguments = new List + { + "bills", "list", + "--organization-id", Guid.NewGuid().ToString(), + "--from", from + }; + if (to is not null) + { + arguments.AddRange(["--to", to]); + } + + var result = await RunAsync(arguments.ToArray(), new FakeApiClientFactory(client)); + + Assert.Equal(ExitCodes.InvalidInput, result.ExitCode); + Assert.Null(client.BillListRequest); + using var json = JsonDocument.Parse(result.StandardError); + Assert.Equal("invalid_arguments", json.RootElement.GetProperty("error").GetProperty("code").GetString()); + } + + [Fact] + public async Task ApiAuthenticationFailure_MapsToStableErrorWithoutExposingToken() + { + var client = new FakeApiClient + { + ListOrganizationsException = new BitFinanceApiException( + 401, + "GET", + "/api/v1/organizations", + """{"message":"Expired"}""") + }; + + var result = await RunAsync(["organizations", "list"], new FakeApiClientFactory(client)); + + Assert.Equal(ExitCodes.AuthenticationFailure, result.ExitCode); + Assert.Equal(string.Empty, result.StandardOutput); + using var json = JsonDocument.Parse(result.StandardError); + var error = json.RootElement.GetProperty("error"); + Assert.Equal("authentication_failed", error.GetProperty("code").GetString()); + Assert.Equal(401, error.GetProperty("httpStatus").GetInt32()); + Assert.DoesNotContain("test-token", result.StandardError); + } + + private static async Task RunAsync(string[] arguments, IBitFinanceApiClientFactory factory) + { + using var standardOutput = new StringWriter(); + using var standardError = new StringWriter(); + var environment = new DictionaryEnvironment(new Dictionary + { + [CliConfiguration.ApiBaseUrlVariable] = "https://api.example.com", + [CliConfiguration.AccessTokenVariable] = "test-token" + }); + var application = new CliApplication(environment, standardOutput, standardError, factory); + + var exitCode = await application.RunAsync(arguments); + return new RunResult(exitCode, standardOutput.ToString(), standardError.ToString()); + } + + private sealed record RunResult(int ExitCode, string StandardOutput, string StandardError); + + private sealed class FakeApiClientFactory(IBitFinanceApiClient client) : IBitFinanceApiClientFactory + { + public int CreateCount { get; private set; } + + public IBitFinanceApiClient Create(CliConfiguration configuration) + { + CreateCount++; + return client; + } + } + + private sealed class FakeApiClient : IBitFinanceApiClient + { + public List Organizations { get; init; } = []; + public Exception? ListOrganizationsException { get; init; } + public BillListCall? BillListRequest { get; private set; } + public ExpenseListCall? ExpenseListRequest { get; private set; } + public ExpensePageResponse ExpensePage { get; init; } = new(); + + public Task> ListOrganizationsAsync( + CancellationToken cancellationToken = default) => + ListOrganizationsException is null + ? Task.FromResult(Organizations) + : Task.FromException>(ListOrganizationsException); + + public Task GetOrganizationAsync( + Guid organizationId, + CancellationToken cancellationToken = default) => + Task.FromResult(new OrganizationDetailsResponse { Id = organizationId }); + + public Task GetUpcomingBillsAsync( + Guid organizationId, + CancellationToken cancellationToken = default) => + Task.FromResult(new UpcomingBillsResponse()); + + public Task GetRecentExpensesAsync( + Guid organizationId, + CancellationToken cancellationToken = default) => + Task.FromResult(new RecentExpensesResponse()); + + public Task> ListBillsAsync( + Guid organizationId, + int page, + int pageSize, + DateTimeOffset? from, + DateTimeOffset? to, + string? status, + string? description, + CancellationToken cancellationToken = default) + { + BillListRequest = new BillListCall( + organizationId, + page, + pageSize, + from, + to, + status, + description); + return Task.FromResult(new PagedResponse()); + } + + public Task GetBillAsync( + Guid organizationId, + Guid billId, + CancellationToken cancellationToken = default) => + Task.FromResult(new BillResponse { Id = billId }); + + public Task ListExpensesAsync( + Guid organizationId, + int page, + int pageSize, + DateTimeOffset? from, + DateTimeOffset? to, + string? status, + string? description, + string? paymentMethod, + CancellationToken cancellationToken = default) + { + ExpenseListRequest = new ExpenseListCall( + organizationId, + page, + pageSize, + from, + to, + status, + description, + paymentMethod); + return Task.FromResult(ExpensePage); + } + + public Task GetExpenseAsync( + Guid organizationId, + Guid expenseId, + CancellationToken cancellationToken = default) => + Task.FromResult(new ExpenseResponse { Id = expenseId }); + } + + private sealed record BillListCall( + Guid OrganizationId, + int Page, + int PageSize, + DateTimeOffset? From, + DateTimeOffset? To, + string? Status, + string? Description); + + private sealed record ExpenseListCall( + Guid OrganizationId, + int Page, + int PageSize, + DateTimeOffset? From, + DateTimeOffset? To, + string? Status, + string? Description, + string? PaymentMethod); +} From f40145105883ea993325170b82f06bfbb3c2012e Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 22 Aug 2026 01:33:52 -0300 Subject: [PATCH 3/4] feat(cli): add mutation and document commands --- apps/cli/src/CliCommandFactory.cs | 1 + apps/cli/src/Commands/CliValueParsers.cs | 31 ++ apps/cli/src/Commands/DocumentUpload.cs | 52 ++ .../cli/src/Commands/MutationCommandTables.cs | 32 ++ apps/cli/src/Commands/MutationCommands.cs | 521 ++++++++++++++++++ apps/cli/src/Models/BitFinanceModels.cs | 93 ++++ apps/cli/src/Services/BitFinanceApiClient.cs | 214 +++++++ apps/cli/src/Services/IBitFinanceApiClient.cs | 76 +++ .../BitFinanceApiClientStub.cs | 80 +++ .../BitFinanceApiClientTests.cs | 138 ++++- .../MutationCommandTests.cs | 367 ++++++++++++ .../ReadCommandTests.cs | 18 +- 12 files changed, 1610 insertions(+), 13 deletions(-) create mode 100644 apps/cli/src/Commands/DocumentUpload.cs create mode 100644 apps/cli/src/Commands/MutationCommandTables.cs create mode 100644 apps/cli/src/Commands/MutationCommands.cs create mode 100644 apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientStub.cs create mode 100644 apps/cli/tests/BitFinance.Cli.UnitTests/MutationCommandTests.cs diff --git a/apps/cli/src/CliCommandFactory.cs b/apps/cli/src/CliCommandFactory.cs index 757e58b..67417d6 100644 --- a/apps/cli/src/CliCommandFactory.cs +++ b/apps/cli/src/CliCommandFactory.cs @@ -20,6 +20,7 @@ public static RootCommand Create(CliServices services) var rootCommand = new RootCommand("Agent-oriented command-line client for BitFinance."); rootCommand.Options.Add(outputOption); ReadCommands.AddTo(rootCommand, services, outputOption); + MutationCommands.AddTo(rootCommand, services, outputOption); return rootCommand; } diff --git a/apps/cli/src/Commands/CliValueParsers.cs b/apps/cli/src/Commands/CliValueParsers.cs index 2a09492..4be225c 100644 --- a/apps/cli/src/Commands/CliValueParsers.cs +++ b/apps/cli/src/Commands/CliValueParsers.cs @@ -5,6 +5,10 @@ namespace BitFinance.Cli.Commands; internal static class CliValueParsers { + public static DateTimeOffset ParseRequiredDate(string value, string optionName) => + ParseOptionalDate(value, optionName) + ?? throw CliException.InvalidArguments($"{optionName} is required."); + public static DateTimeOffset? ParseOptionalDate(string? value, string optionName) { if (string.IsNullOrWhiteSpace(value)) @@ -31,4 +35,31 @@ public static void ValidateDateRange(DateTimeOffset? from, DateTimeOffset? to) throw CliException.InvalidArguments("--from must be earlier than or equal to --to."); } } + + public static decimal ParseRequiredDecimal(string value, string optionName) => + ParseOptionalDecimal(value, optionName) + ?? throw CliException.InvalidArguments($"{optionName} is required."); + + public static decimal? ParseOptionalDecimal(string? value, string optionName) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (!decimal.TryParse(value, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed)) + { + throw CliException.InvalidArguments($"{optionName} must be a decimal number using invariant formatting."); + } + + return parsed; + } + + public static void ValidateNotes(string? notes) + { + if (notes?.Length > 2000) + { + throw CliException.InvalidArguments("--notes must be 2,000 characters or fewer."); + } + } } diff --git a/apps/cli/src/Commands/DocumentUpload.cs b/apps/cli/src/Commands/DocumentUpload.cs new file mode 100644 index 0000000..ddacdd9 --- /dev/null +++ b/apps/cli/src/Commands/DocumentUpload.cs @@ -0,0 +1,52 @@ +using BitFinance.Cli.Errors; +using System.Net.Http.Headers; + +namespace BitFinance.Cli.Commands; + +internal sealed record DocumentUpload(FileInfo File, string ContentType) +{ + private const long MaximumFileSize = 10 * 1024 * 1024; + + private static readonly IReadOnlyDictionary ContentTypes = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [".pdf"] = "application/pdf", + [".jpg"] = "image/jpeg", + [".jpeg"] = "image/jpeg", + [".png"] = "image/png", + [".doc"] = "application/msword", + [".docx"] = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }; + + public static DocumentUpload Validate(FileInfo file, string? requestedContentType) + { + ArgumentNullException.ThrowIfNull(file); + file.Refresh(); + + if (!file.Exists) + { + throw CliException.InvalidArguments($"Document file does not exist: {file.FullName}"); + } + + if (!ContentTypes.TryGetValue(file.Extension, out var inferredContentType)) + { + throw CliException.InvalidArguments( + "Document extension must be one of: .pdf, .jpg, .jpeg, .png, .doc, .docx."); + } + + if (file.Length > MaximumFileSize) + { + throw CliException.InvalidArguments("Document file must be 10 MB or smaller."); + } + + var contentType = string.IsNullOrWhiteSpace(requestedContentType) + ? inferredContentType + : requestedContentType.Trim(); + if (!MediaTypeHeaderValue.TryParse(contentType, out _)) + { + throw CliException.InvalidArguments("--content-type must be a valid media type."); + } + + return new DocumentUpload(file, contentType); + } +} diff --git a/apps/cli/src/Commands/MutationCommandTables.cs b/apps/cli/src/Commands/MutationCommandTables.cs new file mode 100644 index 0000000..332eb31 --- /dev/null +++ b/apps/cli/src/Commands/MutationCommandTables.cs @@ -0,0 +1,32 @@ +using BitFinance.Cli.Models; +using BitFinance.Cli.Output; +using System.Globalization; + +namespace BitFinance.Cli.Commands; + +internal static class MutationCommandTables +{ + public static TableData UpdatedBill(UpdateBillResponse bill) => + new( + ["Id", "Description", "Category", "Status", "Amount due", "Due date"], + [ + [ + bill.Id.ToString(), + bill.Description, + bill.Category, + bill.Status, + bill.AmountDue.ToString("0.00", CultureInfo.InvariantCulture), + bill.DueDate.ToString("O", CultureInfo.InvariantCulture) + ] + ]); + + public static TableData UploadedDocument(UploadDocumentResponse document) => + new( + ["Id", "File name", "Content type", "Category", "Attachment type"], + [[document.Id.ToString(), document.FileName, document.ContentType, document.FileCategory, document.AttachmentType]]); + + public static TableData DownloadUrl(DocumentDownloadUrlResponse document) => + new( + ["File name", "Content type", "Expires at", "URL"], + [[document.FileName, document.ContentType, document.ExpiresAt.ToString("O"), document.Url]]); +} diff --git a/apps/cli/src/Commands/MutationCommands.cs b/apps/cli/src/Commands/MutationCommands.cs new file mode 100644 index 0000000..5042bd2 --- /dev/null +++ b/apps/cli/src/Commands/MutationCommands.cs @@ -0,0 +1,521 @@ +using BitFinance.Cli.Configuration; +using BitFinance.Cli.Errors; +using BitFinance.Cli.Models; +using BitFinance.Cli.Output; +using BitFinance.Cli.Services; +using System.CommandLine; + +namespace BitFinance.Cli.Commands; + +public static class MutationCommands +{ + private static readonly string[] BillCategories = + ["Housing", "Transportation", "Food", "Utilities", "Clothing", "Healthcare", "Insurance", "Personal", "Debt", "Savings", "Education", "Entertainment", "Miscellaneous", "Subscriptions", "Taxes", "Pets"]; + private static readonly string[] BillStatuses = ["Created", "Due", "Paid", "Overdue", "Cancelled", "Upcoming"]; + private static readonly string[] ExpenseCategories = + ["Housing", "Transportation", "Food", "Utilities", "Clothing", "Healthcare", "Insurance", "Personal", "Debt", "Savings", "Education", "Entertainment", "Travel", "Pets", "Gifts", "Miscellaneous", "Subscriptions", "Taxes"]; + private static readonly string[] ExpenseStatuses = ["Pending", "Paid", "Cancelled"]; + private static readonly string[] PaymentMethods = ["Cash", "CreditCard", "DebitCard", "Pix", "BankTransfer", "Boleto", "Other"]; + private static readonly string[] FileCategories = ["Boleto", "Receipt", "Invoice", "Other"]; + + public static void AddTo( + RootCommand rootCommand, + CliServices services, + Option outputOption) + { + var bills = FindGroup(rootCommand, "bills"); + bills.Subcommands.Add(BuildCreateBillCommand(services, outputOption)); + bills.Subcommands.Add(BuildUpdateBillCommand(services, outputOption)); + bills.Subcommands.Add(BuildDeleteBillCommand(services, outputOption)); + bills.Subcommands.Add(BuildStopBillSeriesCommand(services, outputOption)); + bills.Subcommands.Add(BuildBillDocumentsCommand(services, outputOption)); + + var expenses = FindGroup(rootCommand, "expenses"); + expenses.Subcommands.Add(BuildCreateExpenseCommand(services, outputOption)); + expenses.Subcommands.Add(BuildUpdateExpenseCommand(services, outputOption)); + expenses.Subcommands.Add(BuildExpenseDocumentsCommand(services, outputOption)); + } + + private static Command BuildCreateBillCommand(CliServices services, Option outputOption) + { + var command = new Command("create", "Create a one-time, recurring, or installment bill."); + var organization = OrganizationOption(); + var description = RequiredTextOption("--description", "Bill description."); + var category = RequiredChoiceOption("--category", "Bill category.", BillCategories); + var status = RequiredChoiceOption("--status", "Bill status.", BillStatuses); + var dueDate = RequiredTextOption("--due-date", "Due date and time in ISO 8601 format."); + var amountDue = RequiredTextOption("--amount-due", "Amount due using invariant decimal formatting."); + var paymentDate = OptionalTextOption("--payment-date", "Payment date and time in ISO 8601 format."); + var amountPaid = OptionalTextOption("--amount-paid", "Amount paid using invariant decimal formatting."); + var frequency = new Option("--frequency") { Description = "Recurrence frequency." }; + var installments = new Option("--installments") { Description = "Positive installment count." }; + installments.Validators.Add(result => + { + if (result.GetValueOrDefault() is <= 0) + { + result.AddError("--installments must be greater than zero."); + } + }); + var notes = OptionalTextOption("--notes", "Optional notes; pass an empty value to clear where supported."); + AddOptions(command, organization, description, category, status, dueDate, amountDue, paymentDate, amountPaid, frequency, installments, notes); + + SetApiAction( + command, + services, + outputOption, + async (result, client, cancellationToken) => + { + var resolvedFrequency = result.GetValue(frequency); + var resolvedInstallments = result.GetValue(installments); + if (resolvedInstallments.HasValue && !resolvedFrequency.HasValue) + { + throw CliException.InvalidArguments("--installments requires --frequency."); + } + + if (resolvedFrequency.HasValue + && (result.GetValue(paymentDate) is not null || result.GetValue(amountPaid) is not null)) + { + throw CliException.InvalidArguments( + "--payment-date and --amount-paid apply only to one-time bills."); + } + + var resolvedNotes = result.GetValue(notes); + CliValueParsers.ValidateNotes(resolvedNotes); + var request = new CreateBillRequest( + result.GetRequiredValue(description), + result.GetRequiredValue(category), + result.GetRequiredValue(status), + CliValueParsers.ParseRequiredDate(result.GetRequiredValue(dueDate), "--due-date"), + CliValueParsers.ParseOptionalDate(result.GetValue(paymentDate), "--payment-date"), + CliValueParsers.ParseRequiredDecimal(result.GetRequiredValue(amountDue), "--amount-due"), + CliValueParsers.ParseOptionalDecimal(result.GetValue(amountPaid), "--amount-paid"), + resolvedFrequency, + resolvedInstallments, + resolvedNotes); + return await client.CreateBillAsync( + result.GetRequiredValue(organization), + request, + cancellationToken); + }, + ReadCommandTables.Bill); + return command; + } + + private static Command BuildUpdateBillCommand(CliServices services, Option outputOption) + { + var command = new Command("update", "Update one bill occurrence."); + var organization = OrganizationOption(); + var billId = RequiredGuidOption("--bill-id", "Bill ID."); + var description = RequiredTextOption("--description", "Bill description."); + var category = RequiredChoiceOption("--category", "Bill category.", BillCategories); + var status = RequiredChoiceOption("--status", "Bill status.", BillStatuses); + var dueDate = RequiredTextOption("--due-date", "Due date and time in ISO 8601 format."); + var amountDue = RequiredTextOption("--amount-due", "Amount due using invariant decimal formatting."); + var paymentDate = OptionalTextOption("--payment-date", "Payment date and time in ISO 8601 format."); + var amountPaid = OptionalTextOption("--amount-paid", "Amount paid using invariant decimal formatting."); + var notes = OptionalTextOption("--notes", "Omit to preserve notes; pass an empty value to clear them."); + AddOptions(command, organization, billId, description, category, status, dueDate, amountDue, paymentDate, amountPaid, notes); + + SetApiAction( + command, + services, + outputOption, + async (result, client, cancellationToken) => + { + var resolvedNotes = result.GetValue(notes); + CliValueParsers.ValidateNotes(resolvedNotes); + var request = new UpdateBillRequest( + result.GetRequiredValue(description), + result.GetRequiredValue(category), + result.GetRequiredValue(status), + CliValueParsers.ParseRequiredDate(result.GetRequiredValue(dueDate), "--due-date"), + CliValueParsers.ParseOptionalDate(result.GetValue(paymentDate), "--payment-date"), + CliValueParsers.ParseRequiredDecimal(result.GetRequiredValue(amountDue), "--amount-due"), + CliValueParsers.ParseOptionalDecimal(result.GetValue(amountPaid), "--amount-paid"), + resolvedNotes); + return await client.UpdateBillAsync( + result.GetRequiredValue(organization), + result.GetRequiredValue(billId), + request, + cancellationToken); + }, + MutationCommandTables.UpdatedBill); + return command; + } + + private static Command BuildDeleteBillCommand(CliServices services, Option outputOption) + { + var command = new Command("delete", "Delete one bill occurrence and its documents."); + var organization = OrganizationOption(); + var billId = RequiredGuidOption("--bill-id", "Bill ID."); + var confirm = ConfirmationOption(); + AddOptions(command, organization, billId, confirm); + RequireConfirmationAtParse(command, confirm); + SetApiAction( + command, + services, + outputOption, + async (result, client, cancellationToken) => + { + RequireConfirmation(result, confirm); + var resolvedBillId = result.GetRequiredValue(billId); + await client.DeleteBillAsync( + result.GetRequiredValue(organization), + resolvedBillId, + cancellationToken); + return new DeleteBillResponse(true, resolvedBillId); + }); + return command; + } + + private static Command BuildStopBillSeriesCommand(CliServices services, Option outputOption) + { + var command = new Command("stop-series", "Stop future generation for a bill series."); + var organization = OrganizationOption(); + var seriesId = RequiredGuidOption("--series-id", "Bill series ID."); + var confirm = ConfirmationOption(); + AddOptions(command, organization, seriesId, confirm); + RequireConfirmationAtParse(command, confirm); + SetApiAction( + command, + services, + outputOption, + async (result, client, cancellationToken) => + { + RequireConfirmation(result, confirm); + var resolvedSeriesId = result.GetRequiredValue(seriesId); + await client.StopBillSeriesAsync( + result.GetRequiredValue(organization), + resolvedSeriesId, + cancellationToken); + return new StopBillSeriesResponse(true, resolvedSeriesId); + }); + return command; + } + + private static Command BuildCreateExpenseCommand(CliServices services, Option outputOption) + { + var command = new Command("create", "Create an expense."); + var organization = OrganizationOption(); + var description = RequiredTextOption("--description", "Expense description."); + var category = RequiredChoiceOption("--category", "Expense category.", ExpenseCategories); + var amount = RequiredTextOption("--amount", "Amount using invariant decimal formatting."); + var status = RequiredChoiceOption("--status", "Expense status.", ExpenseStatuses); + var occurredAt = OptionalTextOption("--occurred-at", "Occurrence date and time in ISO 8601 format."); + var createdBy = OptionalTextOption("--created-by", "BitFinance user ID; defaults to the token owner."); + var notes = OptionalTextOption("--notes", "Optional notes."); + var paymentMethod = OptionalChoiceOption("--payment-method", "Payment method.", PaymentMethods); + AddOptions(command, organization, description, category, amount, status, occurredAt, createdBy, notes, paymentMethod); + + SetApiAction( + command, + services, + outputOption, + async (result, client, cancellationToken) => + { + var resolvedNotes = result.GetValue(notes); + CliValueParsers.ValidateNotes(resolvedNotes); + var resolvedCreatedBy = result.GetValue(createdBy); + if (string.IsNullOrWhiteSpace(resolvedCreatedBy)) + { + resolvedCreatedBy = (await client.GetCurrentUserAsync(cancellationToken)).Id; + } + + var request = new CreateExpenseRequest( + result.GetRequiredValue(description), + result.GetRequiredValue(category), + CliValueParsers.ParseRequiredDecimal(result.GetRequiredValue(amount), "--amount"), + result.GetRequiredValue(status), + CliValueParsers.ParseOptionalDate(result.GetValue(occurredAt), "--occurred-at"), + resolvedCreatedBy, + resolvedNotes, + result.GetValue(paymentMethod)); + return await client.CreateExpenseAsync( + result.GetRequiredValue(organization), + request, + cancellationToken); + }, + ReadCommandTables.Expense); + return command; + } + + private static Command BuildUpdateExpenseCommand(CliServices services, Option outputOption) + { + var command = new Command("update", "Update an expense."); + var organization = OrganizationOption(); + var expenseId = RequiredGuidOption("--expense-id", "Expense ID."); + var description = RequiredTextOption("--description", "Expense description."); + var category = RequiredChoiceOption("--category", "Expense category.", ExpenseCategories); + var amount = RequiredTextOption("--amount", "Amount using invariant decimal formatting."); + var status = RequiredChoiceOption("--status", "Expense status.", ExpenseStatuses); + var occurredAt = RequiredTextOption("--occurred-at", "Occurrence date and time in ISO 8601 format."); + var notes = OptionalTextOption("--notes", "Omit to preserve notes; pass an empty value to clear them."); + var paymentMethod = OptionalChoiceOption( + "--payment-method", + "Omit to preserve payment method; pass an empty value to clear it.", + PaymentMethods, + allowEmpty: true); + AddOptions(command, organization, expenseId, description, category, amount, status, occurredAt, notes, paymentMethod); + + SetApiAction( + command, + services, + outputOption, + async (result, client, cancellationToken) => + { + var resolvedNotes = result.GetValue(notes); + CliValueParsers.ValidateNotes(resolvedNotes); + var request = new UpdateExpenseRequest( + result.GetRequiredValue(description), + result.GetRequiredValue(category), + CliValueParsers.ParseRequiredDecimal(result.GetRequiredValue(amount), "--amount"), + result.GetRequiredValue(status), + CliValueParsers.ParseRequiredDate(result.GetRequiredValue(occurredAt), "--occurred-at"), + resolvedNotes, + result.GetValue(paymentMethod)); + return await client.UpdateExpenseAsync( + result.GetRequiredValue(organization), + result.GetRequiredValue(expenseId), + request, + cancellationToken); + }, + ReadCommandTables.Expense); + return command; + } + + private static Command BuildBillDocumentsCommand(CliServices services, Option outputOption) => + BuildDocumentsCommand( + services, + outputOption, + "bill", + "--bill-id", + (client, organizationId, ownerId, stream, fileName, contentType, category, cancellationToken) => + client.UploadBillDocumentAsync(organizationId, ownerId, stream, fileName, contentType, category, cancellationToken), + (client, organizationId, ownerId, documentId, cancellationToken) => + client.GetBillDocumentDownloadUrlAsync(organizationId, ownerId, documentId, cancellationToken), + (client, organizationId, ownerId, documentId, cancellationToken) => + client.DeleteBillDocumentAsync(organizationId, ownerId, documentId, cancellationToken)); + + private static Command BuildExpenseDocumentsCommand(CliServices services, Option outputOption) => + BuildDocumentsCommand( + services, + outputOption, + "expense", + "--expense-id", + (client, organizationId, ownerId, stream, fileName, contentType, category, cancellationToken) => + client.UploadExpenseDocumentAsync(organizationId, ownerId, stream, fileName, contentType, category, cancellationToken), + (client, organizationId, ownerId, documentId, cancellationToken) => + client.GetExpenseDocumentDownloadUrlAsync(organizationId, ownerId, documentId, cancellationToken), + (client, organizationId, ownerId, documentId, cancellationToken) => + client.DeleteExpenseDocumentAsync(organizationId, ownerId, documentId, cancellationToken)); + + private static Command BuildDocumentsCommand( + CliServices services, + Option outputOption, + string ownerName, + string ownerOptionName, + Func> uploadDocument, + Func> getDownloadUrl, + Func deleteDocument) + { + var article = ownerName.StartsWith('e') ? "an" : "a"; + var group = new Command("documents", $"Manage documents attached to {article} {ownerName}."); + + var upload = new Command("upload", $"Upload a document to {article} {ownerName}."); + var uploadOrganization = OrganizationOption(); + var uploadOwner = RequiredGuidOption(ownerOptionName, $"{Capitalize(ownerName)} ID."); + var file = new Option("--file") { Description = "Local document path.", Required = true }; + var fileCategory = RequiredChoiceOption("--file-category", "Document category.", FileCategories); + var contentType = OptionalTextOption("--content-type", "Optional MIME type; inferred from the extension by default."); + AddOptions(upload, uploadOrganization, uploadOwner, file, fileCategory, contentType); + SetApiAction( + upload, + services, + outputOption, + async (result, client, cancellationToken) => + { + var document = DocumentUpload.Validate(result.GetRequiredValue(file), result.GetValue(contentType)); + await using var stream = document.File.Open(FileMode.Open, FileAccess.Read, FileShare.Read); + return await uploadDocument( + client, + result.GetRequiredValue(uploadOrganization), + result.GetRequiredValue(uploadOwner), + stream, + document.File.Name, + document.ContentType, + result.GetRequiredValue(fileCategory), + cancellationToken); + }, + MutationCommandTables.UploadedDocument); + + var downloadUrl = new Command("download-url", $"Get a temporary download URL for {article} {ownerName} document."); + var downloadOrganization = OrganizationOption(); + var downloadOwner = RequiredGuidOption(ownerOptionName, $"{Capitalize(ownerName)} ID."); + var downloadDocumentId = RequiredGuidOption("--document-id", "Document ID."); + AddOptions(downloadUrl, downloadOrganization, downloadOwner, downloadDocumentId); + SetApiAction( + downloadUrl, + services, + outputOption, + (result, client, cancellationToken) => getDownloadUrl( + client, + result.GetRequiredValue(downloadOrganization), + result.GetRequiredValue(downloadOwner), + result.GetRequiredValue(downloadDocumentId), + cancellationToken), + MutationCommandTables.DownloadUrl); + + var delete = new Command("delete", $"Delete a document attached to {article} {ownerName}."); + var deleteOrganization = OrganizationOption(); + var deleteOwner = RequiredGuidOption(ownerOptionName, $"{Capitalize(ownerName)} ID."); + var deleteDocumentId = RequiredGuidOption("--document-id", "Document ID."); + var confirm = ConfirmationOption(); + AddOptions(delete, deleteOrganization, deleteOwner, deleteDocumentId, confirm); + RequireConfirmationAtParse(delete, confirm); + SetApiAction( + delete, + services, + outputOption, + async (result, client, cancellationToken) => + { + RequireConfirmation(result, confirm); + var resolvedDocumentId = result.GetRequiredValue(deleteDocumentId); + await deleteDocument( + client, + result.GetRequiredValue(deleteOrganization), + result.GetRequiredValue(deleteOwner), + resolvedDocumentId, + cancellationToken); + return new DeleteDocumentResponse(true, resolvedDocumentId); + }); + + group.Subcommands.Add(upload); + group.Subcommands.Add(downloadUrl); + group.Subcommands.Add(delete); + return group; + } + + private static void SetApiAction( + Command command, + CliServices services, + Option outputOption, + Func> execute, + Func? table = null) + { + command.SetAction(async (parseResult, cancellationToken) => + { + var configuration = CliConfiguration.Load(services.Environment); + var client = services.ApiClientFactory.Create(configuration); + var response = await execute(parseResult, client, cancellationToken); + services.Output.WriteSuccess( + response, + parseResult.GetValue(outputOption), + table?.Invoke(response)); + return ExitCodes.Success; + }); + } + + private static Command FindGroup(RootCommand rootCommand, string name) => + rootCommand.Subcommands.Single(command => command.Name == name); + + private static Option OrganizationOption() => + RequiredGuidOption("--organization-id", "Organization ID."); + + private static Option RequiredGuidOption(string name, string description) => + new(name) { Description = description, Required = true }; + + private static Option RequiredTextOption(string name, string description) + { + var option = new Option(name) { Description = description, Required = true }; + option.Validators.Add(result => + { + if (string.IsNullOrWhiteSpace(result.GetValueOrDefault())) + { + result.AddError($"{name} cannot be empty."); + } + }); + return option; + } + + private static Option RequiredChoiceOption( + string name, + string description, + IReadOnlyCollection allowedValues) + { + var option = RequiredTextOption(name, description); + AddChoiceValidator(option, name, allowedValues, allowEmpty: false); + return option; + } + + private static Option OptionalChoiceOption( + string name, + string description, + IReadOnlyCollection allowedValues, + bool allowEmpty = false) + { + var option = OptionalTextOption(name, description); + AddChoiceValidator(option, name, allowedValues, allowEmpty); + return option; + } + + private static void AddChoiceValidator( + Option option, + string name, + IReadOnlyCollection allowedValues, + bool allowEmpty) + { + option.Validators.Add(result => + { + var value = result.GetValueOrDefault()?.ToString(); + if (value is null || (allowEmpty && value.Length == 0)) + { + return; + } + + if (!allowedValues.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + result.AddError($"{name} must be one of: {string.Join(", ", allowedValues)}."); + } + }); + } + + private static Option OptionalTextOption(string name, string description) => + new(name) { Description = description }; + + private static Option ConfirmationOption() => + new("--confirm") + { + Description = "Confirm this destructive operation.", + Required = true + }; + + private static void RequireConfirmation(ParseResult result, Option confirmation) + { + if (!result.GetValue(confirmation)) + { + throw CliException.InvalidArguments("--confirm is required for this destructive operation."); + } + } + + private static void RequireConfirmationAtParse(Command command, Option confirmation) + { + command.Validators.Add(result => + { + if (result.GetResult(confirmation) is not { Implicit: false }) + { + result.AddError("--confirm is required for this destructive operation."); + } + }); + } + + private static void AddOptions(Command command, params Option[] options) + { + foreach (var option in options) + { + command.Options.Add(option); + } + } + + private static string Capitalize(string value) => + char.ToUpperInvariant(value[0]) + value[1..]; +} diff --git a/apps/cli/src/Models/BitFinanceModels.cs b/apps/cli/src/Models/BitFinanceModels.cs index 3bc564d..a55b007 100644 --- a/apps/cli/src/Models/BitFinanceModels.cs +++ b/apps/cli/src/Models/BitFinanceModels.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace BitFinance.Cli.Models; public sealed record OrganizationSummaryResponse(Guid Id, string Name, string PlanTier); @@ -108,3 +110,94 @@ public sealed class DashboardExpenseResponse public decimal Amount { get; init; } public DateTimeOffset Date { get; init; } } + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BillFrequency +{ + Daily, + Weekly, + Monthly, + Annually +} + +public sealed record CreateBillRequest( + string Description, + string Category, + string Status, + DateTimeOffset DueDate, + DateTimeOffset? PaymentDate, + decimal AmountDue, + decimal? AmountPaid, + BillFrequency? Frequency = null, + int? Installments = null, + string? Notes = null); + +public sealed record UpdateBillRequest( + string Description, + string Category, + string Status, + DateTimeOffset DueDate, + DateTimeOffset? PaymentDate, + decimal AmountDue, + decimal? AmountPaid, + string? Notes = null); + +public sealed class UpdateBillResponse +{ + public Guid Id { get; init; } + public string Description { get; init; } = string.Empty; + public string? Notes { get; init; } + public string Category { get; init; } = string.Empty; + public string Status { get; init; } = string.Empty; + public decimal AmountDue { get; init; } + public decimal? AmountPaid { get; init; } + public DateTimeOffset DueDate { get; init; } + public DateTimeOffset? PaidDate { get; init; } + public Guid? BillSeriesId { get; init; } + public int? OccurrenceNumber { get; init; } + public int? TotalOccurrences { get; init; } + public string? BillSeriesType { get; init; } + public bool? BillSeriesIsActive { get; init; } +} + +public sealed record CreateExpenseRequest( + string Description, + string Category, + decimal Amount, + string Status, + DateTimeOffset? OccurredAt, + string CreatedBy, + string? Notes = null, + string? PaymentMethod = null); + +public sealed record UpdateExpenseRequest( + string Description, + string Category, + decimal Amount, + string Status, + DateTimeOffset OccurredAt, + string? Notes = null, + string? PaymentMethod = null); + +public sealed class UploadDocumentResponse +{ + public Guid Id { get; init; } + public string FileName { get; init; } = string.Empty; + public string ContentType { get; init; } = string.Empty; + public string FileCategory { get; init; } = string.Empty; + public string AttachmentType { get; init; } = string.Empty; +} + +public sealed record DocumentDownloadUrlResponse( + string Url, + string FileName, + string ContentType, + DateTimeOffset ExpiresAt); + +public sealed record CurrentUserResponse(string Id); + +public sealed record DeleteBillResponse(bool Deleted, Guid BillId); + +public sealed record StopBillSeriesResponse(bool Stopped, Guid SeriesId); + +public sealed record DeleteDocumentResponse(bool Deleted, Guid DocumentId); diff --git a/apps/cli/src/Services/BitFinanceApiClient.cs b/apps/cli/src/Services/BitFinanceApiClient.cs index 24f0f40..caac3d4 100644 --- a/apps/cli/src/Services/BitFinanceApiClient.cs +++ b/apps/cli/src/Services/BitFinanceApiClient.cs @@ -22,6 +22,9 @@ public BitFinanceApiClient(HttpClient httpClient, CliConfiguration configuration public Task> ListOrganizationsAsync(CancellationToken cancellationToken = default) => SendAsync>(HttpMethod.Get, ApiPath("organizations"), cancellationToken); + public Task GetCurrentUserAsync(CancellationToken cancellationToken = default) => + SendAsync(HttpMethod.Get, ApiPath("identity/me"), cancellationToken); + public Task GetOrganizationAsync( Guid organizationId, CancellationToken cancellationToken = default) => @@ -77,6 +80,81 @@ public Task GetBillAsync( ApiPath($"organizations/{organizationId}/bills/{billId}"), cancellationToken); + public Task CreateBillAsync( + Guid organizationId, + CreateBillRequest request, + CancellationToken cancellationToken = default) => + SendJsonAsync( + HttpMethod.Post, + ApiPath($"organizations/{organizationId}/bills"), + request, + cancellationToken); + + public Task UpdateBillAsync( + Guid organizationId, + Guid billId, + UpdateBillRequest request, + CancellationToken cancellationToken = default) => + SendJsonAsync( + HttpMethod.Patch, + ApiPath($"organizations/{organizationId}/bills/{billId}"), + request, + cancellationToken); + + public Task DeleteBillAsync( + Guid organizationId, + Guid billId, + CancellationToken cancellationToken = default) => + SendNoContentAsync( + HttpMethod.Delete, + ApiPath($"organizations/{organizationId}/bills/{billId}"), + cancellationToken); + + public Task StopBillSeriesAsync( + Guid organizationId, + Guid seriesId, + CancellationToken cancellationToken = default) => + SendNoContentAsync( + HttpMethod.Post, + ApiPath($"organizations/{organizationId}/bills/series/{seriesId}/stop"), + cancellationToken); + + public Task UploadBillDocumentAsync( + Guid organizationId, + Guid billId, + Stream content, + string fileName, + string contentType, + string fileCategory, + CancellationToken cancellationToken = default) => + UploadDocumentAsync( + ApiPath($"organizations/{organizationId}/bills/{billId}/documents"), + content, + fileName, + contentType, + fileCategory, + cancellationToken); + + public Task GetBillDocumentDownloadUrlAsync( + Guid organizationId, + Guid billId, + Guid documentId, + CancellationToken cancellationToken = default) => + SendAsync( + HttpMethod.Get, + ApiPath($"organizations/{organizationId}/bills/{billId}/documents/{documentId}/download-url"), + cancellationToken); + + public Task DeleteBillDocumentAsync( + Guid organizationId, + Guid billId, + Guid documentId, + CancellationToken cancellationToken = default) => + SendNoContentAsync( + HttpMethod.Delete, + ApiPath($"organizations/{organizationId}/bills/{billId}/documents/{documentId}"), + cancellationToken); + public Task ListExpensesAsync( Guid organizationId, int page, @@ -110,6 +188,63 @@ public Task GetExpenseAsync( ApiPath($"organizations/{organizationId}/expenses/{expenseId}"), cancellationToken); + public Task CreateExpenseAsync( + Guid organizationId, + CreateExpenseRequest request, + CancellationToken cancellationToken = default) => + SendJsonAsync( + HttpMethod.Post, + ApiPath($"organizations/{organizationId}/expenses"), + request, + cancellationToken); + + public Task UpdateExpenseAsync( + Guid organizationId, + Guid expenseId, + UpdateExpenseRequest request, + CancellationToken cancellationToken = default) => + SendJsonAsync( + HttpMethod.Patch, + ApiPath($"organizations/{organizationId}/expenses/{expenseId}"), + request, + cancellationToken); + + public Task UploadExpenseDocumentAsync( + Guid organizationId, + Guid expenseId, + Stream content, + string fileName, + string contentType, + string fileCategory, + CancellationToken cancellationToken = default) => + UploadDocumentAsync( + ApiPath($"organizations/{organizationId}/expenses/{expenseId}/documents"), + content, + fileName, + contentType, + fileCategory, + cancellationToken); + + public Task GetExpenseDocumentDownloadUrlAsync( + Guid organizationId, + Guid expenseId, + Guid documentId, + CancellationToken cancellationToken = default) => + SendAsync( + HttpMethod.Get, + ApiPath($"organizations/{organizationId}/expenses/{expenseId}/documents/{documentId}/download-url"), + cancellationToken); + + public Task DeleteExpenseDocumentAsync( + Guid organizationId, + Guid expenseId, + Guid documentId, + CancellationToken cancellationToken = default) => + SendNoContentAsync( + HttpMethod.Delete, + ApiPath($"organizations/{organizationId}/expenses/{expenseId}/documents/{documentId}"), + cancellationToken); + private async Task SendAsync( HttpMethod method, string path, @@ -130,6 +265,85 @@ private async Task SendAsync( $"BitFinance API returned an empty response for {method.Method} {path}."); } + private async Task SendJsonAsync( + HttpMethod method, + string path, + object body, + CancellationToken cancellationToken) + { + using var content = JsonContent.Create(body, options: JsonOptions); + return await SendContentAsync(method, path, content, cancellationToken); + } + + private async Task UploadDocumentAsync( + string path, + Stream content, + string fileName, + string contentType, + string fileCategory, + CancellationToken cancellationToken) + { + using var multipart = new MultipartFormDataContent(); + using var fileContent = new StreamContent(content); + fileContent.Headers.ContentType = new MediaTypeHeaderValue(contentType); + multipart.Add(fileContent, "File", fileName); + multipart.Add(new StringContent(fileCategory), "FileCategory"); + + return await SendContentAsync( + HttpMethod.Post, + path, + multipart, + cancellationToken); + } + + private async Task SendContentAsync( + HttpMethod method, + string path, + HttpContent content, + CancellationToken cancellationToken) + { + using var request = CreateRequest(method, path); + request.Content = content; + + using var response = await _httpClient.SendAsync(request, cancellationToken); + await EnsureSuccessAsync(response, method, path, cancellationToken); + var value = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return value ?? throw new InvalidOperationException( + $"BitFinance API returned an empty response for {method.Method} {path}."); + } + + private async Task SendNoContentAsync( + HttpMethod method, + string path, + CancellationToken cancellationToken) + { + using var request = CreateRequest(method, path); + using var response = await _httpClient.SendAsync(request, cancellationToken); + await EnsureSuccessAsync(response, method, path, cancellationToken); + } + + private HttpRequestMessage CreateRequest(HttpMethod method, string path) + { + var request = new HttpRequestMessage(method, path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _configuration.AccessToken); + return request; + } + + private static async Task EnsureSuccessAsync( + HttpResponseMessage response, + HttpMethod method, + string path, + CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new BitFinanceApiException((int)response.StatusCode, method.Method, path, responseBody); + } + private string ApiPath(string relativePath) => $"/api/v{_configuration.ApiVersion}/{relativePath.TrimStart('/')}"; diff --git a/apps/cli/src/Services/IBitFinanceApiClient.cs b/apps/cli/src/Services/IBitFinanceApiClient.cs index a715dc7..7633051 100644 --- a/apps/cli/src/Services/IBitFinanceApiClient.cs +++ b/apps/cli/src/Services/IBitFinanceApiClient.cs @@ -4,6 +4,8 @@ namespace BitFinance.Cli.Services; public interface IBitFinanceApiClient { + Task GetCurrentUserAsync(CancellationToken cancellationToken = default); + Task> ListOrganizationsAsync(CancellationToken cancellationToken = default); Task GetOrganizationAsync( @@ -33,6 +35,48 @@ Task GetBillAsync( Guid billId, CancellationToken cancellationToken = default); + Task CreateBillAsync( + Guid organizationId, + CreateBillRequest request, + CancellationToken cancellationToken = default); + + Task UpdateBillAsync( + Guid organizationId, + Guid billId, + UpdateBillRequest request, + CancellationToken cancellationToken = default); + + Task DeleteBillAsync( + Guid organizationId, + Guid billId, + CancellationToken cancellationToken = default); + + Task StopBillSeriesAsync( + Guid organizationId, + Guid seriesId, + CancellationToken cancellationToken = default); + + Task UploadBillDocumentAsync( + Guid organizationId, + Guid billId, + Stream content, + string fileName, + string contentType, + string fileCategory, + CancellationToken cancellationToken = default); + + Task GetBillDocumentDownloadUrlAsync( + Guid organizationId, + Guid billId, + Guid documentId, + CancellationToken cancellationToken = default); + + Task DeleteBillDocumentAsync( + Guid organizationId, + Guid billId, + Guid documentId, + CancellationToken cancellationToken = default); + Task ListExpensesAsync( Guid organizationId, int page, @@ -48,4 +92,36 @@ Task GetExpenseAsync( Guid organizationId, Guid expenseId, CancellationToken cancellationToken = default); + + Task CreateExpenseAsync( + Guid organizationId, + CreateExpenseRequest request, + CancellationToken cancellationToken = default); + + Task UpdateExpenseAsync( + Guid organizationId, + Guid expenseId, + UpdateExpenseRequest request, + CancellationToken cancellationToken = default); + + Task UploadExpenseDocumentAsync( + Guid organizationId, + Guid expenseId, + Stream content, + string fileName, + string contentType, + string fileCategory, + CancellationToken cancellationToken = default); + + Task GetExpenseDocumentDownloadUrlAsync( + Guid organizationId, + Guid expenseId, + Guid documentId, + CancellationToken cancellationToken = default); + + Task DeleteExpenseDocumentAsync( + Guid organizationId, + Guid expenseId, + Guid documentId, + CancellationToken cancellationToken = default); } diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientStub.cs b/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientStub.cs new file mode 100644 index 0000000..91f4d45 --- /dev/null +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientStub.cs @@ -0,0 +1,80 @@ +using BitFinance.Cli.Models; +using BitFinance.Cli.Services; + +namespace BitFinance.Cli.UnitTests; + +internal abstract class BitFinanceApiClientStub : IBitFinanceApiClient +{ + public virtual Task GetCurrentUserAsync(CancellationToken cancellationToken = default) => + Task.FromResult(new CurrentUserResponse("test-user")); + + public virtual Task> ListOrganizationsAsync(CancellationToken cancellationToken = default) => + Task.FromResult>([]); + + public virtual Task GetOrganizationAsync(Guid organizationId, CancellationToken cancellationToken = default) => + Task.FromResult(new OrganizationDetailsResponse { Id = organizationId }); + + public virtual Task GetUpcomingBillsAsync(Guid organizationId, CancellationToken cancellationToken = default) => + Task.FromResult(new UpcomingBillsResponse()); + + public virtual Task GetRecentExpensesAsync(Guid organizationId, CancellationToken cancellationToken = default) => + Task.FromResult(new RecentExpensesResponse()); + + public virtual Task> ListBillsAsync( + Guid organizationId, int page, int pageSize, DateTimeOffset? from, DateTimeOffset? to, + string? status, string? description, CancellationToken cancellationToken = default) => + Task.FromResult(new PagedResponse()); + + public virtual Task GetBillAsync(Guid organizationId, Guid billId, CancellationToken cancellationToken = default) => + Task.FromResult(new BillResponse { Id = billId }); + + public virtual Task CreateBillAsync(Guid organizationId, CreateBillRequest request, CancellationToken cancellationToken = default) => + Task.FromResult(new BillResponse()); + + public virtual Task UpdateBillAsync(Guid organizationId, Guid billId, UpdateBillRequest request, CancellationToken cancellationToken = default) => + Task.FromResult(new UpdateBillResponse { Id = billId }); + + public virtual Task DeleteBillAsync(Guid organizationId, Guid billId, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public virtual Task StopBillSeriesAsync(Guid organizationId, Guid seriesId, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public virtual Task UploadBillDocumentAsync( + Guid organizationId, Guid billId, Stream content, string fileName, string contentType, + string fileCategory, CancellationToken cancellationToken = default) => + Task.FromResult(new UploadDocumentResponse()); + + public virtual Task GetBillDocumentDownloadUrlAsync( + Guid organizationId, Guid billId, Guid documentId, CancellationToken cancellationToken = default) => + Task.FromResult(new DocumentDownloadUrlResponse(string.Empty, string.Empty, string.Empty, default)); + + public virtual Task DeleteBillDocumentAsync(Guid organizationId, Guid billId, Guid documentId, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public virtual Task ListExpensesAsync( + Guid organizationId, int page, int pageSize, DateTimeOffset? from, DateTimeOffset? to, + string? status, string? description, string? paymentMethod, CancellationToken cancellationToken = default) => + Task.FromResult(new ExpensePageResponse()); + + public virtual Task GetExpenseAsync(Guid organizationId, Guid expenseId, CancellationToken cancellationToken = default) => + Task.FromResult(new ExpenseResponse { Id = expenseId }); + + public virtual Task CreateExpenseAsync(Guid organizationId, CreateExpenseRequest request, CancellationToken cancellationToken = default) => + Task.FromResult(new ExpenseResponse()); + + public virtual Task UpdateExpenseAsync(Guid organizationId, Guid expenseId, UpdateExpenseRequest request, CancellationToken cancellationToken = default) => + Task.FromResult(new ExpenseResponse { Id = expenseId }); + + public virtual Task UploadExpenseDocumentAsync( + Guid organizationId, Guid expenseId, Stream content, string fileName, string contentType, + string fileCategory, CancellationToken cancellationToken = default) => + Task.FromResult(new UploadDocumentResponse()); + + public virtual Task GetExpenseDocumentDownloadUrlAsync( + Guid organizationId, Guid expenseId, Guid documentId, CancellationToken cancellationToken = default) => + Task.FromResult(new DocumentDownloadUrlResponse(string.Empty, string.Empty, string.Empty, default)); + + public virtual Task DeleteExpenseDocumentAsync(Guid organizationId, Guid expenseId, Guid documentId, CancellationToken cancellationToken = default) => + Task.CompletedTask; +} diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientTests.cs b/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientTests.cs index 0dd1fbd..5407b12 100644 --- a/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientTests.cs +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientTests.cs @@ -1,4 +1,5 @@ using BitFinance.Cli.Configuration; +using BitFinance.Cli.Models; using BitFinance.Cli.Services; using System.Net; using System.Net.Http.Headers; @@ -159,6 +160,130 @@ public async Task FailedRequest_ThrowsApiExceptionWithParsedDetails() Assert.DoesNotContain("test-token", exception.ToString()); } + [Fact] + public async Task BillMutations_UseExpectedMethodsRoutesAndJsonBodies() + { + var organizationId = Guid.NewGuid(); + var billId = Guid.NewGuid(); + var seriesId = Guid.NewGuid(); + var handler = new RecordingHandler(request => request.Method == HttpMethod.Delete + || request.RequestUri!.AbsolutePath.EndsWith("/stop") + ? new HttpResponseMessage(HttpStatusCode.NoContent) + : request.Method == HttpMethod.Post + ? JsonResponse(HttpStatusCode.Created, $$"""{"id":"{{billId}}"}""") + : JsonResponse( + HttpStatusCode.OK, + $$"""{"id":"{{billId}}","dueDate":"2026-09-10T00:00:00Z"}""")); + var client = CreateClient(handler); + + await client.CreateBillAsync( + organizationId, + new CreateBillRequest( + "Rent", "Housing", "Upcoming", DateTimeOffset.Parse("2026-09-10T00:00:00Z"), + null, 1500m, null, BillFrequency.Monthly, null, "Monthly rent")); + await client.UpdateBillAsync( + organizationId, + billId, + new UpdateBillRequest( + "Rent", "Housing", "Paid", DateTimeOffset.Parse("2026-09-10T00:00:00Z"), + DateTimeOffset.Parse("2026-09-08T00:00:00Z"), 1500m, 1500m, string.Empty)); + await client.DeleteBillAsync(organizationId, billId); + await client.StopBillSeriesAsync(organizationId, seriesId); + + Assert.Equal(HttpMethod.Post, handler.Requests[0].Method); + Assert.Equal($"/api/v1/organizations/{organizationId}/bills", handler.Requests[0].Uri.AbsolutePath); + Assert.Contains("\"frequency\":\"Monthly\"", handler.Requests[0].Body); + Assert.Contains("\"notes\":\"Monthly rent\"", handler.Requests[0].Body); + Assert.Equal(HttpMethod.Patch, handler.Requests[1].Method); + Assert.Contains("\"notes\":\"\"", handler.Requests[1].Body); + Assert.Equal(HttpMethod.Delete, handler.Requests[2].Method); + Assert.Equal($"/api/v1/organizations/{organizationId}/bills/{billId}", handler.Requests[2].Uri.AbsolutePath); + Assert.Equal( + $"/api/v1/organizations/{organizationId}/bills/series/{seriesId}/stop", + handler.Requests[3].Uri.AbsolutePath); + } + + [Fact] + public async Task ExpenseMutationsAndCurrentUser_UseExpectedRoutesAndBodies() + { + var organizationId = Guid.NewGuid(); + var expenseId = Guid.NewGuid(); + var handler = new RecordingHandler(request => request.RequestUri!.AbsolutePath.EndsWith("identity/me") + ? JsonResponse(HttpStatusCode.OK, """{"id":"user-123"}""") + : JsonResponse( + request.Method == HttpMethod.Post ? HttpStatusCode.Created : HttpStatusCode.OK, + $$"""{"id":"{{expenseId}}","occurredAt":"2026-08-20T12:00:00Z"}""")); + var client = CreateClient(handler); + + var currentUser = await client.GetCurrentUserAsync(); + await client.CreateExpenseAsync( + organizationId, + new CreateExpenseRequest( + "Lunch", "Food", 42.5m, "Paid", null, currentUser.Id, null, "Pix")); + await client.UpdateExpenseAsync( + organizationId, + expenseId, + new UpdateExpenseRequest( + "Lunch", "Food", 45m, "Paid", DateTimeOffset.Parse("2026-08-20T12:00:00Z"), + string.Empty, string.Empty)); + + Assert.Equal("/api/v1/identity/me", handler.Requests[0].Uri.AbsolutePath); + Assert.Equal(HttpMethod.Post, handler.Requests[1].Method); + Assert.Contains("\"createdBy\":\"user-123\"", handler.Requests[1].Body); + Assert.Contains("\"paymentMethod\":\"Pix\"", handler.Requests[1].Body); + Assert.Equal(HttpMethod.Patch, handler.Requests[2].Method); + Assert.Contains("\"paymentMethod\":\"\"", handler.Requests[2].Body); + } + + [Fact] + public async Task DocumentOperations_SendMultipartAndExpectedRoutes() + { + var organizationId = Guid.NewGuid(); + var billId = Guid.NewGuid(); + var expenseId = Guid.NewGuid(); + var documentId = Guid.NewGuid(); + var handler = new RecordingHandler(request => request.Method == HttpMethod.Delete + ? new HttpResponseMessage(HttpStatusCode.NoContent) + : request.Method == HttpMethod.Post + ? JsonResponse( + HttpStatusCode.OK, + $$"""{"id":"{{documentId}}","fileName":"receipt.pdf"}""") + : JsonResponse( + HttpStatusCode.OK, + """{"url":"https://files.example/receipt","fileName":"receipt.pdf","contentType":"application/pdf","expiresAt":"2026-08-22T12:00:00Z"}""")); + var client = CreateClient(handler); + + await client.UploadBillDocumentAsync( + organizationId, + billId, + new MemoryStream([1, 2, 3]), + "receipt.pdf", + "application/pdf", + "Receipt"); + await client.GetBillDocumentDownloadUrlAsync(organizationId, billId, documentId); + await client.DeleteBillDocumentAsync(organizationId, billId, documentId); + await client.UploadExpenseDocumentAsync( + organizationId, + expenseId, + new MemoryStream([4, 5, 6]), + "receipt.pdf", + "application/pdf", + "Receipt"); + await client.GetExpenseDocumentDownloadUrlAsync(organizationId, expenseId, documentId); + await client.DeleteExpenseDocumentAsync(organizationId, expenseId, documentId); + + Assert.Contains("name=File", handler.Requests[0].Body); + Assert.Contains("filename=receipt.pdf", handler.Requests[0].Body); + Assert.Contains("name=FileCategory", handler.Requests[0].Body); + Assert.Equal( + $"/api/v1/organizations/{organizationId}/bills/{billId}/documents/{documentId}/download-url", + handler.Requests[1].Uri.AbsolutePath); + Assert.Equal(HttpMethod.Delete, handler.Requests[2].Method); + Assert.Contains($"/expenses/{expenseId}/documents", handler.Requests[3].Uri.AbsolutePath); + Assert.EndsWith($"/{documentId}/download-url", handler.Requests[4].Uri.AbsolutePath); + Assert.Equal(HttpMethod.Delete, handler.Requests[5].Method); + } + private static BitFinanceApiClient CreateClient(RecordingHandler handler, string apiVersion = "1") { var httpClient = new HttpClient(handler) @@ -182,20 +307,25 @@ private sealed class RecordingHandler( { public List Requests { get; } = []; - protected override Task SendAsync( + protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { + var body = request.Content is null + ? string.Empty + : await request.Content.ReadAsStringAsync(cancellationToken); Requests.Add(new RecordedRequest( request.Method, request.RequestUri!, - request.Headers.Authorization)); - return Task.FromResult(responseFactory(request)); + request.Headers.Authorization, + body)); + return responseFactory(request); } } private sealed record RecordedRequest( HttpMethod Method, Uri Uri, - AuthenticationHeaderValue? Authorization); + AuthenticationHeaderValue? Authorization, + string Body); } diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/MutationCommandTests.cs b/apps/cli/tests/BitFinance.Cli.UnitTests/MutationCommandTests.cs new file mode 100644 index 0000000..35c3cc4 --- /dev/null +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/MutationCommandTests.cs @@ -0,0 +1,367 @@ +using BitFinance.Cli.Configuration; +using BitFinance.Cli.Errors; +using BitFinance.Cli.Models; +using BitFinance.Cli.Services; +using System.Text.Json; + +namespace BitFinance.Cli.UnitTests; + +public sealed class MutationCommandTests +{ + public static TheoryData UnconfirmedDestructiveCommands => new() + { + new[] { "bills", "delete", "--organization-id", Guid.NewGuid().ToString(), "--bill-id", Guid.NewGuid().ToString() }, + new[] { "bills", "stop-series", "--organization-id", Guid.NewGuid().ToString(), "--series-id", Guid.NewGuid().ToString() }, + new[] { "bills", "documents", "delete", "--organization-id", Guid.NewGuid().ToString(), "--bill-id", Guid.NewGuid().ToString(), "--document-id", Guid.NewGuid().ToString() }, + new[] { "expenses", "documents", "delete", "--organization-id", Guid.NewGuid().ToString(), "--expense-id", Guid.NewGuid().ToString(), "--document-id", Guid.NewGuid().ToString() } + }; + + [Theory] + [MemberData(nameof(UnconfirmedDestructiveCommands))] + public async Task DestructiveCommand_WithoutConfirm_DoesNotCreateApiClient(string[] arguments) + { + var factory = new FakeFactory(new MutationApiClient()); + + var result = await RunAsync(arguments, factory); + + Assert.Equal(ExitCodes.InvalidInput, result.ExitCode); + Assert.Equal(0, factory.CreateCount); + Assert.Contains("--confirm", result.StandardError); + } + + [Fact] + public async Task CreateBill_ParsesRecurringBillUsingInvariantValues() + { + var organizationId = Guid.NewGuid(); + var client = new MutationApiClient(); + + var result = await RunAsync( + [ + "bills", "create", + "--organization-id", organizationId.ToString(), + "--description", "Rent", + "--category", "housing", + "--status", "upcoming", + "--due-date", "2026-09-10T00:00:00-03:00", + "--amount-due", "1500.25", + "--frequency", "monthly", + "--installments", "10", + "--notes", "Apartment" + ], + new FakeFactory(client)); + + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.NotNull(client.CreatedBill); + Assert.Equal(organizationId, client.CreatedBill!.OrganizationId); + Assert.Equal(1500.25m, client.CreatedBill.Request.AmountDue); + Assert.Equal(BillFrequency.Monthly, client.CreatedBill.Request.Frequency); + Assert.Equal(10, client.CreatedBill.Request.Installments); + Assert.Equal(TimeSpan.FromHours(-3), client.CreatedBill.Request.DueDate.Offset); + } + + [Theory] + [InlineData("--installments", "2")] + [InlineData("--amount-due", "12,34.56")] + public async Task CreateBill_InvalidCombinationOrDecimal_DoesNotInvokeApi(string option, string value) + { + var client = new MutationApiClient(); + var arguments = new List + { + "bills", "create", + "--organization-id", Guid.NewGuid().ToString(), + "--description", "Rent", + "--category", "Housing", + "--status", "Upcoming", + "--due-date", "2026-09-10T00:00:00Z", + "--amount-due", "1500" + }; + arguments.AddRange([option, value]); + + var result = await RunAsync(arguments.ToArray(), new FakeFactory(client)); + + Assert.Equal(ExitCodes.InvalidInput, result.ExitCode); + Assert.Null(client.CreatedBill); + } + + [Fact] + public async Task CreateExpense_ResolvesCurrentTokenOwnerWhenCreatedByIsOmitted() + { + var organizationId = Guid.NewGuid(); + var client = new MutationApiClient { CurrentUserId = "user-from-token" }; + + var result = await RunAsync( + [ + "expenses", "create", + "--organization-id", organizationId.ToString(), + "--description", "Lunch", + "--category", "Food", + "--amount", "42.50", + "--status", "Paid", + "--payment-method", "Pix" + ], + new FakeFactory(client)); + + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.Equal(1, client.CurrentUserCalls); + Assert.Equal("user-from-token", client.CreatedExpense?.Request.CreatedBy); + Assert.Equal("Pix", client.CreatedExpense?.Request.PaymentMethod); + } + + [Fact] + public async Task CreateExpense_ExplicitCreatedBySkipsCurrentUserRequest() + { + var client = new MutationApiClient(); + + var result = await RunAsync( + [ + "expenses", "create", + "--organization-id", Guid.NewGuid().ToString(), + "--description", "Lunch", + "--category", "Food", + "--amount", "42.50", + "--status", "Paid", + "--created-by", "explicit-user" + ], + new FakeFactory(client)); + + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.Equal(0, client.CurrentUserCalls); + Assert.Equal("explicit-user", client.CreatedExpense?.Request.CreatedBy); + } + + [Fact] + public async Task UpdateExpense_PreservesEmptyValuesForClearSemantics() + { + var client = new MutationApiClient(); + + var result = await RunAsync( + [ + "expenses", "update", + "--organization-id", Guid.NewGuid().ToString(), + "--expense-id", Guid.NewGuid().ToString(), + "--description", "Lunch", + "--category", "Food", + "--amount", "45.00", + "--status", "Paid", + "--occurred-at", "2026-08-20T12:00:00Z", + "--notes", "", + "--payment-method", "" + ], + new FakeFactory(client)); + + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.Equal(string.Empty, client.UpdatedExpense?.Request.Notes); + Assert.Equal(string.Empty, client.UpdatedExpense?.Request.PaymentMethod); + } + + [Fact] + public async Task UploadDocument_ValidatesAndStreamsLocalFile() + { + var filePath = Path.Combine(Path.GetTempPath(), $"bitfinance-{Guid.NewGuid():N}.pdf"); + await File.WriteAllBytesAsync(filePath, [1, 2, 3, 4]); + try + { + var client = new MutationApiClient(); + + var result = await RunAsync( + [ + "bills", "documents", "upload", + "--organization-id", Guid.NewGuid().ToString(), + "--bill-id", Guid.NewGuid().ToString(), + "--file", filePath, + "--file-category", "Receipt" + ], + new FakeFactory(client)); + + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.NotNull(client.UploadedBillDocument); + Assert.Equal(Path.GetFileName(filePath), client.UploadedBillDocument!.FileName); + Assert.Equal("application/pdf", client.UploadedBillDocument.ContentType); + Assert.Equal("Receipt", client.UploadedBillDocument.FileCategory); + Assert.Equal(4, client.UploadedBillDocument.Length); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public async Task UploadDocument_UnsupportedExtension_DoesNotInvokeApi() + { + var filePath = Path.Combine(Path.GetTempPath(), $"bitfinance-{Guid.NewGuid():N}.txt"); + await File.WriteAllTextAsync(filePath, "not supported"); + try + { + var client = new MutationApiClient(); + var result = await RunAsync( + [ + "expenses", "documents", "upload", + "--organization-id", Guid.NewGuid().ToString(), + "--expense-id", Guid.NewGuid().ToString(), + "--file", filePath, + "--file-category", "Receipt" + ], + new FakeFactory(client)); + + Assert.Equal(ExitCodes.InvalidInput, result.ExitCode); + Assert.Null(client.UploadedExpenseDocument); + } + finally + { + File.Delete(filePath); + } + } + + [Fact] + public async Task ConfirmedDelete_ReturnsExplicitSuccessObject() + { + var billId = Guid.NewGuid(); + var client = new MutationApiClient(); + + var result = await RunAsync( + [ + "bills", "delete", + "--organization-id", Guid.NewGuid().ToString(), + "--bill-id", billId.ToString(), + "--confirm" + ], + new FakeFactory(client)); + + Assert.Equal(ExitCodes.Success, result.ExitCode); + Assert.Equal(billId, client.DeletedBillId); + using var json = JsonDocument.Parse(result.StandardOutput); + Assert.True(json.RootElement.GetProperty("deleted").GetBoolean()); + Assert.Equal(billId, json.RootElement.GetProperty("billId").GetGuid()); + } + + private static async Task RunAsync(string[] arguments, IBitFinanceApiClientFactory factory) + { + using var standardOutput = new StringWriter(); + using var standardError = new StringWriter(); + var environment = new DictionaryEnvironment(new Dictionary + { + [CliConfiguration.ApiBaseUrlVariable] = "https://api.example.com", + [CliConfiguration.AccessTokenVariable] = "test-token" + }); + var application = new CliApplication(environment, standardOutput, standardError, factory); + + var exitCode = await application.RunAsync(arguments); + return new RunResult(exitCode, standardOutput.ToString(), standardError.ToString()); + } + + private sealed record RunResult(int ExitCode, string StandardOutput, string StandardError); + + private sealed class FakeFactory(IBitFinanceApiClient client) : IBitFinanceApiClientFactory + { + public int CreateCount { get; private set; } + + public IBitFinanceApiClient Create(CliConfiguration configuration) + { + CreateCount++; + return client; + } + } + + private sealed class MutationApiClient : BitFinanceApiClientStub + { + public string CurrentUserId { get; init; } = "test-user"; + public int CurrentUserCalls { get; private set; } + public BillCall? CreatedBill { get; private set; } + public ExpenseCall? CreatedExpense { get; private set; } + public UpdateExpenseCall? UpdatedExpense { get; private set; } + public UploadCall? UploadedBillDocument { get; private set; } + public UploadCall? UploadedExpenseDocument { get; private set; } + public Guid? DeletedBillId { get; private set; } + + public override Task GetCurrentUserAsync(CancellationToken cancellationToken = default) + { + CurrentUserCalls++; + return Task.FromResult(new CurrentUserResponse(CurrentUserId)); + } + + public override Task CreateBillAsync( + Guid organizationId, + CreateBillRequest request, + CancellationToken cancellationToken = default) + { + CreatedBill = new BillCall(organizationId, request); + return Task.FromResult(new BillResponse { Description = request.Description }); + } + + public override Task CreateExpenseAsync( + Guid organizationId, + CreateExpenseRequest request, + CancellationToken cancellationToken = default) + { + CreatedExpense = new ExpenseCall(organizationId, request); + return Task.FromResult(new ExpenseResponse + { + Description = request.Description, + OccurredAt = request.OccurredAt ?? DateTimeOffset.UtcNow + }); + } + + public override Task UpdateExpenseAsync( + Guid organizationId, + Guid expenseId, + UpdateExpenseRequest request, + CancellationToken cancellationToken = default) + { + UpdatedExpense = new UpdateExpenseCall(organizationId, expenseId, request); + return Task.FromResult(new ExpenseResponse { Id = expenseId, OccurredAt = request.OccurredAt }); + } + + public override async Task UploadBillDocumentAsync( + Guid organizationId, + Guid billId, + Stream content, + string fileName, + string contentType, + string fileCategory, + CancellationToken cancellationToken = default) + { + UploadedBillDocument = new UploadCall( + organizationId, billId, fileName, contentType, fileCategory, await ReadLengthAsync(content)); + return new UploadDocumentResponse { FileName = fileName, ContentType = contentType, FileCategory = fileCategory }; + } + + public override async Task UploadExpenseDocumentAsync( + Guid organizationId, + Guid expenseId, + Stream content, + string fileName, + string contentType, + string fileCategory, + CancellationToken cancellationToken = default) + { + UploadedExpenseDocument = new UploadCall( + organizationId, expenseId, fileName, contentType, fileCategory, await ReadLengthAsync(content)); + return new UploadDocumentResponse { FileName = fileName, ContentType = contentType, FileCategory = fileCategory }; + } + + public override Task DeleteBillAsync(Guid organizationId, Guid billId, CancellationToken cancellationToken = default) + { + DeletedBillId = billId; + return Task.CompletedTask; + } + + private static async Task ReadLengthAsync(Stream content) + { + using var memory = new MemoryStream(); + await content.CopyToAsync(memory); + return checked((int)memory.Length); + } + } + + private sealed record BillCall(Guid OrganizationId, CreateBillRequest Request); + private sealed record ExpenseCall(Guid OrganizationId, CreateExpenseRequest Request); + private sealed record UpdateExpenseCall(Guid OrganizationId, Guid ExpenseId, UpdateExpenseRequest Request); + private sealed record UploadCall( + Guid OrganizationId, + Guid OwnerId, + string FileName, + string ContentType, + string FileCategory, + int Length); +} diff --git a/apps/cli/tests/BitFinance.Cli.UnitTests/ReadCommandTests.cs b/apps/cli/tests/BitFinance.Cli.UnitTests/ReadCommandTests.cs index f5ba683..a42ea33 100644 --- a/apps/cli/tests/BitFinance.Cli.UnitTests/ReadCommandTests.cs +++ b/apps/cli/tests/BitFinance.Cli.UnitTests/ReadCommandTests.cs @@ -192,7 +192,7 @@ public IBitFinanceApiClient Create(CliConfiguration configuration) } } - private sealed class FakeApiClient : IBitFinanceApiClient + private sealed class FakeApiClient : BitFinanceApiClientStub { public List Organizations { get; init; } = []; public Exception? ListOrganizationsException { get; init; } @@ -200,28 +200,28 @@ private sealed class FakeApiClient : IBitFinanceApiClient public ExpenseListCall? ExpenseListRequest { get; private set; } public ExpensePageResponse ExpensePage { get; init; } = new(); - public Task> ListOrganizationsAsync( + public override Task> ListOrganizationsAsync( CancellationToken cancellationToken = default) => ListOrganizationsException is null ? Task.FromResult(Organizations) : Task.FromException>(ListOrganizationsException); - public Task GetOrganizationAsync( + public override Task GetOrganizationAsync( Guid organizationId, CancellationToken cancellationToken = default) => Task.FromResult(new OrganizationDetailsResponse { Id = organizationId }); - public Task GetUpcomingBillsAsync( + public override Task GetUpcomingBillsAsync( Guid organizationId, CancellationToken cancellationToken = default) => Task.FromResult(new UpcomingBillsResponse()); - public Task GetRecentExpensesAsync( + public override Task GetRecentExpensesAsync( Guid organizationId, CancellationToken cancellationToken = default) => Task.FromResult(new RecentExpensesResponse()); - public Task> ListBillsAsync( + public override Task> ListBillsAsync( Guid organizationId, int page, int pageSize, @@ -242,13 +242,13 @@ public Task> ListBillsAsync( return Task.FromResult(new PagedResponse()); } - public Task GetBillAsync( + public override Task GetBillAsync( Guid organizationId, Guid billId, CancellationToken cancellationToken = default) => Task.FromResult(new BillResponse { Id = billId }); - public Task ListExpensesAsync( + public override Task ListExpensesAsync( Guid organizationId, int page, int pageSize, @@ -271,7 +271,7 @@ public Task ListExpensesAsync( return Task.FromResult(ExpensePage); } - public Task GetExpenseAsync( + public override Task GetExpenseAsync( Guid organizationId, Guid expenseId, CancellationToken cancellationToken = default) => From 15b4a7e48cf8476a913638037afffa86cd551e33 Mon Sep 17 00:00:00 2001 From: Gustavo Miranda Date: Sat, 22 Aug 2026 01:40:06 -0300 Subject: [PATCH 4/4] ci(cli): publish versioned Linux binaries --- .github/workflows/cli-release.yml | 184 ++++++++++++++++++++++++++ .github/workflows/main-validation.yml | 32 +++++ CONTRIBUTING.md | 11 ++ README.md | 12 +- apps/cli/README.md | 164 +++++++++++++++++++++++ 5 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cli-release.yml create mode 100644 apps/cli/README.md diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 0000000..65005bf --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,184 @@ +name: CLI Release + +on: + push: + tags: + - "cli/v*" + +env: + PROJECT_FILE: apps/cli/src/BitFinance.Cli.csproj + TEST_PROJECT: apps/cli/tests/BitFinance.Cli.UnitTests/BitFinance.Cli.UnitTests.csproj + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set release version + id: version + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + VERSION="${REF_NAME#cli/v}" + + if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::Version must be semver without the v prefix. Received '$VERSION'." + exit 1 + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Validate project version + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + PROJECT_VERSION="$(sed -n 's:.*\(.*\).*:\1:p' "$PROJECT_FILE" | head -n 1)" + + if [ -z "$PROJECT_VERSION" ]; then + echo "::error::$PROJECT_FILE must define a value." + exit 1 + fi + + if [ "$RELEASE_VERSION" != "$PROJECT_VERSION" ]; then + echo "::error::Version mismatch: CLI release '$RELEASE_VERSION' does not match project version '$PROJECT_VERSION'." + exit 1 + fi + + - name: Set up .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-cli-${{ hashFiles('apps/cli/**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget-cli- + + - name: Test CLI + run: dotnet test "$TEST_PROJECT" -c Release --disable-build-servers -v:minimal + + build: + needs: validate + strategy: + fail-fast: false + matrix: + include: + - rid: linux-x64 + runner: ubuntu-latest + - rid: linux-arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-${{ matrix.rid }}-nuget-cli-${{ hashFiles('apps/cli/**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.rid }}-nuget-cli- + + - name: Publish single-file executable + env: + RID: ${{ matrix.rid }} + run: | + set -euo pipefail + dotnet publish "$PROJECT_FILE" \ + -c Release \ + -r "$RID" \ + --self-contained true \ + -p:PublishSingleFile=true \ + -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:PublishTrimmed=false \ + -p:DebugType=embedded \ + -p:ContinuousIntegrationBuild=true \ + -o "$RUNNER_TEMP/publish" + + - name: Smoke-test executable + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + EXECUTABLE="$RUNNER_TEMP/publish/bitfinance-cli" + test -x "$EXECUTABLE" + "$EXECUTABLE" --help > /dev/null + ACTUAL_VERSION="$($EXECUTABLE --version)" + test "$ACTUAL_VERSION" = "$RELEASE_VERSION" + + - name: Package executable + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + RID: ${{ matrix.rid }} + run: | + set -euo pipefail + ARCHIVE="bitfinance-cli-$RELEASE_VERSION-$RID.tar.gz" + tar -czf "$RUNNER_TEMP/$ARCHIVE" -C "$RUNNER_TEMP/publish" bitfinance-cli + + - name: Upload packaged executable + uses: actions/upload-artifact@v7 + with: + name: bitfinance-cli-${{ matrix.rid }} + path: ${{ runner.temp }}/bitfinance-cli-${{ needs.validate.outputs.version }}-${{ matrix.rid }}.tar.gz + if-no-files-found: error + retention-days: 1 + + release: + runs-on: ubuntu-latest + needs: + - validate + - build + permissions: + contents: write + + steps: + - name: Download packaged executables + uses: actions/download-artifact@v7 + with: + path: ${{ runner.temp }}/release + pattern: bitfinance-cli-* + merge-multiple: true + + - name: Generate checksums + working-directory: ${{ runner.temp }}/release + run: sha256sum bitfinance-cli-*.tar.gz > checksums.txt + + - name: Create GitHub Release + working-directory: ${{ runner.temp }}/release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + set -euo pipefail + EXTRA_ARGS=() + if [[ "$RELEASE_VERSION" == *-* ]]; then + EXTRA_ARGS+=(--prerelease) + fi + + gh release create "$GITHUB_REF_NAME" \ + bitfinance-cli-*.tar.gz \ + checksums.txt \ + --verify-tag \ + --generate-notes \ + --title "BitFinance CLI v$RELEASE_VERSION" \ + "${EXTRA_ARGS[@]}" diff --git a/.github/workflows/main-validation.yml b/.github/workflows/main-validation.yml index f1b71d1..1660b2d 100644 --- a/.github/workflows/main-validation.yml +++ b/.github/workflows/main-validation.yml @@ -19,6 +19,7 @@ jobs: frontend: ${{ steps.filter.outputs.frontend }} backend: ${{ steps.filter.outputs.backend }} mcp: ${{ steps.filter.outputs.mcp }} + cli: ${{ steps.filter.outputs.cli }} steps: - name: Checkout repository uses: actions/checkout@v6 @@ -40,6 +41,10 @@ jobs: - 'apps/mcp-server/**' - '.github/workflows/mcp-docker-publish.yml' - '.github/workflows/main-validation.yml' + cli: + - 'apps/cli/**' + - '.github/workflows/cli-release.yml' + - '.github/workflows/main-validation.yml' frontend: runs-on: ubuntu-latest @@ -129,3 +134,30 @@ jobs: - name: Test MCP server run: dotnet test apps/mcp-server/tests/BitFinance.MCP.UnitTests/BitFinance.MCP.UnitTests.csproj --disable-build-servers -v:minimal + + cli: + runs-on: ubuntu-latest + needs: changes + if: needs.changes.outputs.cli == 'true' + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Cache NuGet packages + uses: actions/cache@v5 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-cli-${{ hashFiles('apps/cli/**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget-cli- + + - name: Build CLI + run: dotnet build apps/cli/src/BitFinance.Cli.csproj --disable-build-servers -v:minimal + + - name: Test CLI + run: dotnet test apps/cli/tests/BitFinance.Cli.UnitTests/BitFinance.Cli.UnitTests.csproj --disable-build-servers -v:minimal diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6512c6c..790c901 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,15 @@ MCP server: dotnet build apps/mcp-server/src/BitFinance.MCP.csproj ``` +CLI: + +```bash +dotnet build apps/cli/src/BitFinance.Cli.csproj +dotnet test apps/cli/tests/BitFinance.Cli.UnitTests/BitFinance.Cli.UnitTests.csproj +dotnet format apps/cli/src/BitFinance.Cli.csproj --verify-no-changes +dotnet format apps/cli/tests/BitFinance.Cli.UnitTests/BitFinance.Cli.UnitTests.csproj --verify-no-changes +``` + ## Commit Format This repository follows Conventional Commits. Include the affected project or area as the scope. @@ -49,6 +58,7 @@ Examples: feat(frontend): add bill status filter fix(backend): correct filtered bill count feat(mcp-server): expose bill description search +feat(cli): add bill creation command docs(readme): clarify local setup ci(frontend): update deploy workflow paths ``` @@ -68,6 +78,7 @@ Preferred scopes: - `frontend` - `backend` - `mcp-server` +- `cli` - `docs` - `ci` - `monorepo` diff --git a/README.md b/README.md index f74c287..957accf 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # BitFinance -BitFinance is a finance platform for tracking bills, expenses, organizations, and financial activity across a web app, backend API, and MCP server integration. +BitFinance is a finance platform for tracking bills, expenses, organizations, and financial activity across a web app, backend API, MCP server integration, and agent-oriented CLI. ## Components - **Frontend**: React 19, TypeScript, Vite, Tailwind CSS, TanStack Query, Zustand, and installable PWA support. - **Backend**: .NET API with PostgreSQL persistence, Redis caching support, object storage integration, authentication, and organization-based finance workflows. - **MCP server**: .NET Streamable HTTP MCP server that exposes BitFinance API capabilities to MCP-compatible agents and clients. +- **CLI**: Self-contained .NET command-line client with stable JSON output for agents and automation. ## Local Development @@ -41,6 +42,15 @@ Run the MCP server directly after setting the required environment variables: dotnet run --project apps/mcp-server/src/BitFinance.MCP.csproj ``` +Run the CLI after configuring an API URL and access token: + +```bash +export BITFINANCE_API_BASE_URL="https://" +export BITFINANCE_ACCESS_TOKEN="" + +dotnet run --project apps/cli/src/BitFinance.Cli.csproj -- organizations list +``` + ## Documentation Each component keeps its own README with setup, configuration, and deployment details. diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000..e096367 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,164 @@ +# BitFinance CLI + +`bitfinance-cli` is a non-interactive C# client for the BitFinance API. It is designed for agents and automation: successful commands emit one JSON value to standard output by default, diagnostics use standard error, and failures return documented nonzero exit codes. + +## Installation + +Download the archive matching the Linux host from the GitHub Release for the desired `cli/vX.Y.Z` tag: + +- `bitfinance-cli--linux-x64.tar.gz` for x86-64 Linux. +- `bitfinance-cli--linux-arm64.tar.gz` for ARM64 Linux. + +Verify the archive against the release's `checksums.txt`, extract it, and place the executable on `PATH`: + +```bash +sha256sum --check checksums.txt --ignore-missing +tar -xzf bitfinance-cli--linux-x64.tar.gz +install -m 0755 bitfinance-cli "$HOME/.local/bin/bitfinance-cli" +bitfinance-cli --version +``` + +The release executables are self-contained and do not require a preinstalled .NET runtime. They target glibc-based Linux distributions. + +## Configuration + +Provide configuration through environment variables: + +```bash +export BITFINANCE_API_BASE_URL="https://" +export BITFINANCE_ACCESS_TOKEN="" +export BITFINANCE_API_VERSION="1" +``` + +`BITFINANCE_API_BASE_URL` and `BITFINANCE_ACCESS_TOKEN` are required for API commands. `BITFINANCE_API_VERSION` is optional and defaults to `1`. + +There is no default-organization setting. Every organization-scoped command requires `--organization-id`; omission is a command error and no API request is made. Access tokens are read only from the environment and are never accepted as command arguments or written to output. + +## Commands + +Discover the complete command and option reference from the executable: + +```bash +bitfinance-cli --help +bitfinance-cli bills create --help +bitfinance-cli expenses documents upload --help +``` + +The command groups are: + +- `organizations list|get` +- `dashboard upcoming-bills|recent-expenses` +- `bills list|get|create|update|delete|stop-series` +- `bills documents upload|download-url|delete` +- `expenses list|get|create|update` +- `expenses documents upload|download-url|delete` + +List expenses as JSON: + +```bash +bitfinance-cli expenses list \ + --organization-id 00000000-0000-0000-0000-000000000000 \ + --from 2026-08-01T00:00:00-03:00 \ + --to 2026-08-31T23:59:59-03:00 \ + --status Paid +``` + +Create an expense using the authenticated token owner as `createdBy`: + +```bash +bitfinance-cli expenses create \ + --organization-id 00000000-0000-0000-0000-000000000000 \ + --description "Lunch" \ + --category Food \ + --amount 42.50 \ + --status Paid \ + --payment-method Pix +``` + +Create a recurring bill: + +```bash +bitfinance-cli bills create \ + --organization-id 00000000-0000-0000-0000-000000000000 \ + --description "Rent" \ + --category Housing \ + --status Upcoming \ + --due-date 2026-09-10T00:00:00-03:00 \ + --amount-due 1500.00 \ + --frequency Monthly +``` + +Destructive operations are non-interactive and require `--confirm`: + +```bash +bitfinance-cli bills delete \ + --organization-id 00000000-0000-0000-0000-000000000000 \ + --bill-id 00000000-0000-0000-0000-000000000001 \ + --confirm +``` + +Upload a local document: + +```bash +bitfinance-cli bills documents upload \ + --organization-id 00000000-0000-0000-0000-000000000000 \ + --bill-id 00000000-0000-0000-0000-000000000001 \ + --file ./receipt.pdf \ + --file-category Receipt +``` + +Uploads support `.pdf`, `.jpg`, `.jpeg`, `.png`, `.doc`, and `.docx` files up to 10 MB. The MIME type is inferred from the extension unless `--content-type` is provided. + +## Output and Exit Codes + +JSON is the default success format. Use `--output table` for human-readable output: + +```bash +bitfinance-cli organizations list --output table +``` + +Errors always use JSON on standard error: + +```json +{"error":{"code":"invalid_arguments","message":"...","httpStatus":null,"details":null}} +``` + +Exit codes: + +- `0`: success. +- `1`: unexpected internal failure. +- `2`: parsing, configuration, input-validation, or confirmation failure. +- `3`: authentication or authorization failure. +- `4`: another BitFinance API failure. +- `5`: network or timeout failure. +- `130`: cancellation. + +Dates use ISO 8601, decimal values use invariant formatting, and enum-like values are case-insensitive. Update commands preserve optional notes or payment methods when their option is omitted; pass an empty value to clear a supported field. + +## Local Development + +Build and test from the repository root: + +```bash +dotnet build apps/cli/src/BitFinance.Cli.csproj +dotnet test apps/cli/tests/BitFinance.Cli.UnitTests/BitFinance.Cli.UnitTests.csproj +``` + +Run from source: + +```bash +dotnet run --project apps/cli/src/BitFinance.Cli.csproj -- organizations list +``` + +## Versioning and Releases + +The CLI has independent SemVer starting at `0.1.0`. The version is stored in `apps/cli/src/BitFinance.Cli.csproj` and must match the release tag. + +To release version `0.1.0`: + +1. Set `Version` and `InformationalVersion` to the SemVer release value in the CLI project. Set `AssemblyVersion` and `FileVersion` to its numeric `major.minor.patch` value (without a prerelease suffix). +2. Commit and merge the version change. +3. Create and push tag `cli/v0.1.0`. +4. The CLI Release workflow tests the project, builds native `linux-x64` and `linux-arm64` archives, smoke-tests them, creates `checksums.txt`, and publishes a GitHub Release. + +The workflow rejects malformed tags and tag/project version mismatches before publishing. Prerelease versions such as `0.2.0-beta.1` produce GitHub prereleases.