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.
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..5310163
--- /dev/null
+++ b/apps/cli/src/CliApplication.cs
@@ -0,0 +1,101 @@
+using BitFinance.Cli.Configuration;
+using BitFinance.Cli.Errors;
+using BitFinance.Cli.Output;
+using BitFinance.Cli.Services;
+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,
+ IBitFinanceApiClientFactory? apiClientFactory = null)
+ {
+ ArgumentNullException.ThrowIfNull(environment);
+ ArgumentNullException.ThrowIfNull(standardOutput);
+ ArgumentNullException.ThrowIfNull(standardError);
+
+ _output = new CliOutputWriter(standardOutput, standardError);
+ Services = new CliServices(
+ environment,
+ _output,
+ apiClientFactory ?? new BitFinanceApiClientFactory());
+ _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 (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());
+ 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,
+ IBitFinanceApiClientFactory ApiClientFactory);
diff --git a/apps/cli/src/CliCommandFactory.cs b/apps/cli/src/CliCommandFactory.cs
new file mode 100644
index 0000000..67417d6
--- /dev/null
+++ b/apps/cli/src/CliCommandFactory.cs
@@ -0,0 +1,27 @@
+using BitFinance.Cli.Output;
+using BitFinance.Cli.Commands;
+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);
+ 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
new file mode 100644
index 0000000..4be225c
--- /dev/null
+++ b/apps/cli/src/Commands/CliValueParsers.cs
@@ -0,0 +1,65 @@
+using BitFinance.Cli.Errors;
+using System.Globalization;
+
+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))
+ {
+ 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.");
+ }
+ }
+
+ 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/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/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..d29b089
--- /dev/null
+++ b/apps/cli/src/Errors/CliError.cs
@@ -0,0 +1,32 @@
+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 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);
+
+ 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..24e82bf
--- /dev/null
+++ b/apps/cli/src/Errors/CliException.cs
@@ -0,0 +1,21 @@
+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);
+
+ public static CliException InvalidArguments(string message) =>
+ new(CliError.InvalidArguments(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/Models/BitFinanceModels.cs b/apps/cli/src/Models/BitFinanceModels.cs
new file mode 100644
index 0000000..a55b007
--- /dev/null
+++ b/apps/cli/src/Models/BitFinanceModels.cs
@@ -0,0 +1,203 @@
+using System.Text.Json.Serialization;
+
+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; }
+}
+
+[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/Output/CliOutputWriter.cs b/apps/cli/src/Output/CliOutputWriter.cs
new file mode 100644
index 0000000..c06b50b
--- /dev/null
+++ b/apps/cli/src/Output/CliOutputWriter.cs
@@ -0,0 +1,140 @@
+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, TableData? table = null)
+ {
+ if (format == OutputFormat.Table)
+ {
+ if (table is not null)
+ {
+ WriteRows(table.Headers.ToArray(), table.Rows.Select(row => row.ToArray()).ToArray());
+ }
+ else
+ {
+ 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)
+ {
+ if (rows.Length == 0)
+ {
+ StandardOutput.WriteLine("(no results)");
+ return;
+ }
+
+ 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/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/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/src/Services/BitFinanceApiClient.cs b/apps/cli/src/Services/BitFinanceApiClient.cs
new file mode 100644
index 0000000..caac3d4
--- /dev/null
+++ b/apps/cli/src/Services/BitFinanceApiClient.cs
@@ -0,0 +1,363 @@
+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 GetCurrentUserAsync(CancellationToken cancellationToken = default) =>
+ SendAsync(HttpMethod.Get, ApiPath("identity/me"), 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 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,
+ 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);
+
+ 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,
+ 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 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('/')}";
+
+ 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..7633051
--- /dev/null
+++ b/apps/cli/src/Services/IBitFinanceApiClient.cs
@@ -0,0 +1,127 @@
+using BitFinance.Cli.Models;
+
+namespace BitFinance.Cli.Services;
+
+public interface IBitFinanceApiClient
+{
+ Task GetCurrentUserAsync(CancellationToken cancellationToken = default);
+
+ 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 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,
+ int pageSize,
+ DateTimeOffset? from,
+ DateTimeOffset? to,
+ string? status,
+ string? description,
+ string? paymentMethod,
+ CancellationToken cancellationToken = default);
+
+ 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/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/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/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
new file mode 100644
index 0000000..5407b12
--- /dev/null
+++ b/apps/cli/tests/BitFinance.Cli.UnitTests/BitFinanceApiClientTests.cs
@@ -0,0 +1,331 @@
+using BitFinance.Cli.Configuration;
+using BitFinance.Cli.Models;
+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());
+ }
+
+ [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)
+ {
+ 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 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,
+ body));
+ return responseFactory(request);
+ }
+ }
+
+ private sealed record RecordedRequest(
+ HttpMethod Method,
+ Uri Uri,
+ AuthenticationHeaderValue? Authorization,
+ string Body);
+}
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;
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
new file mode 100644
index 0000000..a42ea33
--- /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 : BitFinanceApiClientStub
+ {
+ 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 override Task> ListOrganizationsAsync(
+ CancellationToken cancellationToken = default) =>
+ ListOrganizationsException is null
+ ? Task.FromResult(Organizations)
+ : Task.FromException>(ListOrganizationsException);
+
+ public override Task GetOrganizationAsync(
+ Guid organizationId,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new OrganizationDetailsResponse { Id = organizationId });
+
+ public override Task GetUpcomingBillsAsync(
+ Guid organizationId,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new UpcomingBillsResponse());
+
+ public override Task GetRecentExpensesAsync(
+ Guid organizationId,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new RecentExpensesResponse());
+
+ public override 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 override Task GetBillAsync(
+ Guid organizationId,
+ Guid billId,
+ CancellationToken cancellationToken = default) =>
+ Task.FromResult(new BillResponse { Id = billId });
+
+ public override 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 override 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);
+}