From ae5b482bcb7affe3bfb3963e867b3bc3cb4ccd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Sat, 4 Jul 2026 10:37:28 +0800 Subject: [PATCH 1/6] feat: categorization intelligence - rules engine, Claude API, review queue (Phase 2) --- Directory.Packages.props | 2 + ...orizeImportedTransactionsCommandHandler.cs | 176 ++++++ .../CategoryPaths.cs | 27 + .../Claude/ClaudeCategorizer.cs | 240 ++++++++ .../Claude/IClaudeCategorizer.cs | 44 ++ .../FinanceApp.Categorization.csproj | 7 + .../Initialization.cs | 15 +- .../Rules/RuleMatcher.cs | 70 +++ .../ApplyCategorySuggestionsCommand.cs | 53 ++ .../Suggestions/CategorySuggestion.cs | 21 + .../DismissCategorySuggestionsCommand.cs | 24 + .../packages.lock.json | 536 +++++++++++++++++ src/FinanceApp.Connectors/packages.lock.json | 7 + .../Categories/CategoryRule.cs | 38 ++ .../Categories/CreateCategoryRuleCommand.cs | 70 +++ .../Categories/DeleteCategoryRuleCommand.cs | 25 + .../Credentials/ICredentialStore.cs | 21 + .../Credentials/MartenCredentialStore.cs | 63 ++ .../Credentials/ProviderCredential.cs | 25 + .../SaveProviderCredentialCommand.cs | 30 + .../FinanceApp.Domain.csproj | 1 + .../CategorizeImportedTransactionsCommand.cs | 9 + .../Imports/ImportStatementCommand.cs | 10 + src/FinanceApp.Domain/Initialization.cs | 3 + src/FinanceApp.Domain/packages.lock.json | 6 + .../Components/FinanceNavMenu.razor | 3 + .../Components/Review/Pages/Review.razor | 205 +++++++ .../Settings/Pages/Credentials.razor | 78 +++ .../Components/Settings/Pages/Rules.razor | 162 ++++++ .../Transactions/Pages/Transactions.razor | 58 ++ src/FinanceApp/_Imports.razor | 3 + src/FinanceApp/packages.lock.json | 7 +- .../Claude/ClaudeCategorizer.Tests.cs | 242 ++++++++ .../Rules/RuleMatcher.Tests.cs | 101 ++++ .../packages.lock.json | 541 +++++++++++++++++- .../packages.lock.json | 7 + .../CreateCategoryRuleCommandHandler.Tests.cs | 75 +++ .../MartenCredentialStore.Tests.cs | 127 ++++ ...eProviderCredentialCommandHandler.Tests.cs | 46 ++ .../FinanceApp.Domain.Tests.csproj | 4 + .../packages.lock.json | 7 + tests/FinanceApp.Tests/packages.lock.json | 7 +- 42 files changed, 3189 insertions(+), 7 deletions(-) create mode 100644 src/FinanceApp.Categorization/CategorizeImportedTransactionsCommandHandler.cs create mode 100644 src/FinanceApp.Categorization/CategoryPaths.cs create mode 100644 src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs create mode 100644 src/FinanceApp.Categorization/Claude/IClaudeCategorizer.cs create mode 100644 src/FinanceApp.Categorization/Rules/RuleMatcher.cs create mode 100644 src/FinanceApp.Categorization/Suggestions/ApplyCategorySuggestionsCommand.cs create mode 100644 src/FinanceApp.Categorization/Suggestions/CategorySuggestion.cs create mode 100644 src/FinanceApp.Categorization/Suggestions/DismissCategorySuggestionsCommand.cs create mode 100644 src/FinanceApp.Domain/Categories/CategoryRule.cs create mode 100644 src/FinanceApp.Domain/Categories/CreateCategoryRuleCommand.cs create mode 100644 src/FinanceApp.Domain/Categories/DeleteCategoryRuleCommand.cs create mode 100644 src/FinanceApp.Domain/Credentials/ICredentialStore.cs create mode 100644 src/FinanceApp.Domain/Credentials/MartenCredentialStore.cs create mode 100644 src/FinanceApp.Domain/Credentials/ProviderCredential.cs create mode 100644 src/FinanceApp.Domain/Credentials/SaveProviderCredentialCommand.cs create mode 100644 src/FinanceApp.Domain/Imports/CategorizeImportedTransactionsCommand.cs create mode 100644 src/FinanceApp/Components/Review/Pages/Review.razor create mode 100644 src/FinanceApp/Components/Settings/Pages/Credentials.razor create mode 100644 src/FinanceApp/Components/Settings/Pages/Rules.razor create mode 100644 tests/FinanceApp.Categorization.Tests/Claude/ClaudeCategorizer.Tests.cs create mode 100644 tests/FinanceApp.Categorization.Tests/Rules/RuleMatcher.Tests.cs create mode 100644 tests/FinanceApp.Domain.Tests/Categories/CreateCategoryRuleCommandHandler.Tests.cs create mode 100644 tests/FinanceApp.Domain.Tests/Credentials/MartenCredentialStore.Tests.cs create mode 100644 tests/FinanceApp.Domain.Tests/Credentials/SaveProviderCredentialCommandHandler.Tests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index c22230b..8cb48b3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,6 +17,8 @@ + + +/// Async categorization after an import: learned rules first (free, +/// deterministic), then Claude for the remainder in batches of 50. High +/// confidence auto-applies (flagged "AI" in the grid); low confidence becomes a +/// stored suggestion for the review queue. Any Claude failure leaves the +/// remaining transactions uncategorized — they surface in the review queue and +/// the next import retries; the import itself has long since succeeded. +/// +public static class CategorizeImportedTransactionsCommandHandler +{ + internal const decimal HighConfidenceThreshold = 0.8m; + private const int BatchSize = 50; + private const int FewShotExampleCount = 30; + + public static async Task Handle( + CategorizeImportedTransactionsCommand command, + IDocumentSession session, + IClaudeCategorizer claudeCategorizer, + ILoggerFactory loggerFactory, + CancellationToken cancellationToken + ) + { + var logger = loggerFactory.CreateLogger("FinanceApp.Categorization"); + + var pending = await session + .Query() + .Where(t => t.ImportBatchId == command.ImportBatchId && t.CategoryId == null) + .ToListAsync(cancellationToken); + if (pending.Count == 0) + { + return; + } + + var rules = await session.Query().ToListAsync(cancellationToken); + var unmatched = new List(); + foreach (var transaction in pending) + { + var rule = RuleMatcher.FindMatch( + rules.ToList(), + transaction.Counterparty, + transaction.Description, + transaction.Amount + ); + if (rule is null) + { + unmatched.Add(transaction); + continue; + } + + var stream = await session.Events.FetchForWriting( + transaction.Id, + cancellationToken + ); + if (stream.Aggregate is { CategoryId: null }) + { + stream.AppendOne( + new TransactionCategorized(rule.CategoryId, CategorySource.Rule, null) + ); + } + } + await session.SaveChangesAsync(cancellationToken); + + if (unmatched.Count == 0) + { + return; + } + + var categories = await session.Query().ToListAsync(cancellationToken); + var options = CategoryPaths.Build(categories.ToList()); + var examples = await LoadFewShotExamplesAsync(session, categories, cancellationToken); + + foreach (var batch in unmatched.Chunk(BatchSize)) + { + var toCategorize = batch + .Select(t => new TransactionToCategorize( + t.Id, + t.Counterparty, + t.Description, + t.Amount, + t.Currency + )) + .ToList(); + + var result = await claudeCategorizer.SuggestAsync( + toCategorize, + options, + examples, + cancellationToken + ); + if (result.IsFailure) + { + // Graceful degradation: stay uncategorized → review queue. + logger.LogWarning( + "AI categorization skipped for batch {BatchId}: {Reason}", + command.ImportBatchId, + result.Error + ); + return; + } + + foreach (var suggestion in result.Value!) + { + if (suggestion.CategoryId is not Guid categoryId) + { + continue; // model declined — stays in the review queue + } + + if (suggestion.Confidence >= HighConfidenceThreshold) + { + var stream = await session.Events.FetchForWriting( + suggestion.TransactionId, + cancellationToken + ); + if (stream.Aggregate is { CategoryId: null }) + { + stream.AppendOne( + new TransactionCategorized( + categoryId, + CategorySource.Ai, + suggestion.Confidence + ) + ); + } + } + else + { + session.Store( + new CategorySuggestion + { + Id = suggestion.TransactionId, + CategoryId = categoryId, + Confidence = suggestion.Confidence, + } + ); + } + } + await session.SaveChangesAsync(cancellationToken); + } + } + + private static async Task> LoadFewShotExamplesAsync( + IDocumentSession session, + IReadOnlyList categories, + CancellationToken cancellationToken + ) + { + var byId = categories.ToDictionary(category => category.Id); + var confirmed = await session + .Query() + .Where(t => t.CategorySource == CategorySource.Manual && t.CategoryId != null) + .OrderByDescending(t => t.BookingDate) + .Take(FewShotExampleCount) + .ToListAsync(cancellationToken); + + return confirmed + .Where(t => byId.ContainsKey(t.CategoryId!.Value)) + .Select(t => new FewShotExample( + t.Counterparty, + t.Description, + t.Amount, + CategoryPaths.PathOf(byId[t.CategoryId!.Value], byId) + )) + .ToList(); + } +} diff --git a/src/FinanceApp.Categorization/CategoryPaths.cs b/src/FinanceApp.Categorization/CategoryPaths.cs new file mode 100644 index 0000000..1388ea9 --- /dev/null +++ b/src/FinanceApp.Categorization/CategoryPaths.cs @@ -0,0 +1,27 @@ +using FinanceApp.Categorization.Claude; +using FinanceApp.Domain.Categories; + +namespace FinanceApp.Categorization; + +/// Builds display paths ("Living › Groceries") for the category tree. +public static class CategoryPaths +{ + public static List Build(IReadOnlyList categories) + { + var byId = categories.ToDictionary(category => category.Id); + return categories + .OrderBy(category => + byId.TryGetValue(category.ParentId ?? category.Id, out var parent) + ? parent.SortOrder + : 0 + ) + .ThenBy(category => category.ParentId is null ? -1 : category.SortOrder) + .Select(category => new CategoryOption(category.Id, PathOf(category, byId))) + .ToList(); + } + + public static string PathOf(Category category, IReadOnlyDictionary byId) => + category.ParentId is Guid parentId && byId.TryGetValue(parentId, out var parent) + ? $"{parent.Name} › {category.Name}" + : category.Name; +} diff --git a/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs b/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs new file mode 100644 index 0000000..5a462ab --- /dev/null +++ b/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs @@ -0,0 +1,240 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using FinanceApp.Domain; +using FinanceApp.Domain.Credentials; + +namespace FinanceApp.Categorization.Claude; + +/// +/// Claude Messages API client: batches of transactions, structured output via a +/// forced tool call, temperature 0, Haiku-class model. The API key comes from +/// the encrypted credential store and is never logged. +/// +internal sealed class ClaudeCategorizer(HttpClient httpClient, ICredentialStore credentialStore) + : IClaudeCategorizer +{ + internal const string Model = "claude-haiku-4-5-20251001"; + private const string ToolName = "categorize_transactions"; + + public async Task>> SuggestAsync( + IReadOnlyList transactions, + IReadOnlyList categories, + IReadOnlyList examples, + CancellationToken cancellationToken = default + ) + { + if (transactions.Count == 0) + { + return Result.Ok>([]); + } + + var apiKey = await credentialStore.GetSecretAsync( + CredentialKeys.ClaudeApiKey, + cancellationToken + ); + if (string.IsNullOrWhiteSpace(apiKey)) + { + return Result.Fail>( + "No Claude API key is configured (Settings → API Keys)." + ); + } + + using var request = new HttpRequestMessage(HttpMethod.Post, "v1/messages"); + request.Headers.Add("x-api-key", apiKey); + request.Headers.Add("anthropic-version", "2023-06-01"); + request.Content = JsonContent.Create(BuildRequestBody(transactions, categories, examples)); + + try + { + using var response = await httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return Result.Fail>( + $"Claude API returned {(int)response.StatusCode} {response.StatusCode}." + ); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + return ParseResponse(json, categories.Select(c => c.Id).ToHashSet()); + } + catch (Exception exception) + when (exception is HttpRequestException or TaskCanceledException) + { + return Result.Fail>( + $"Claude API unreachable: {exception.Message}" + ); + } + } + + internal static JsonObject BuildRequestBody( + IReadOnlyList transactions, + IReadOnlyList categories, + IReadOnlyList examples + ) + { + var transactionLines = transactions.Select(t => new JsonObject + { + ["transaction_id"] = t.TransactionId.ToString("D"), + ["counterparty"] = t.Counterparty, + ["description"] = t.Description, + ["amount"] = t.Amount, + ["currency"] = t.Currency, + }); + + return new JsonObject + { + ["model"] = Model, + ["max_tokens"] = 4096, + ["temperature"] = 0, + ["system"] = BuildSystemPrompt(categories, examples), + ["messages"] = new JsonArray( + new JsonObject + { + ["role"] = "user", + ["content"] = new JsonArray( + new JsonObject + { + ["type"] = "text", + ["text"] = + "Categorize these household transactions:\n" + + new JsonArray([.. transactionLines]).ToJsonString(), + } + ), + } + ), + ["tools"] = new JsonArray( + new JsonObject + { + ["name"] = ToolName, + ["description"] = + "Report the category decision for every transaction in the batch.", + ["input_schema"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["categorizations"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["transaction_id"] = new JsonObject { ["type"] = "string" }, + ["category_id"] = new JsonObject + { + ["type"] = new JsonArray("string", "null"), + }, + ["confidence"] = new JsonObject { ["type"] = "number" }, + }, + ["required"] = new JsonArray( + "transaction_id", + "category_id", + "confidence" + ), + }, + }, + }, + ["required"] = new JsonArray("categorizations"), + }, + } + ), + ["tool_choice"] = new JsonObject { ["type"] = "tool", ["name"] = ToolName }, + }; + } + + internal static string BuildSystemPrompt( + IReadOnlyList categories, + IReadOnlyList examples + ) + { + var builder = new System.Text.StringBuilder(); + builder.AppendLine( + "You categorize household bank transactions for a private German/Austrian household." + ); + builder.AppendLine( + "For every transaction pick the best matching category id from the list below, " + + "or null when no category fits. Report a confidence between 0 and 1; use low " + + "confidence when unsure. Negative amounts are expenses, positive amounts income." + ); + builder.AppendLine(); + builder.AppendLine("Available categories (id: path):"); + foreach (var category in categories) + { + builder.AppendLine($"{category.Id:D}: {category.Path}"); + } + + if (examples.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("Confirmed examples from this household's history:"); + foreach (var example in examples) + { + builder.AppendLine( + $"- counterparty: {example.Counterparty ?? "-"} | description: {example.Description} | amount: {example.Amount} => {example.CategoryPath}" + ); + } + } + + return builder.ToString(); + } + + internal static Result> ParseResponse( + string json, + HashSet validCategoryIds + ) + { + JsonNode? root; + try + { + root = JsonNode.Parse(json); + } + catch (JsonException exception) + { + return Result.Fail>( + $"Unreadable Claude response: {exception.Message}" + ); + } + + var toolUse = (root?["content"] as JsonArray)?.FirstOrDefault(block => + block?["type"]?.GetValue() == "tool_use" + ); + if (toolUse?["input"]?["categorizations"] is not JsonArray categorizations) + { + return Result.Fail>( + "Claude response contained no structured categorization output." + ); + } + + var suggestions = new List(); + foreach (var entry in categorizations) + { + if (!Guid.TryParse(entry?["transaction_id"]?.GetValue(), out var transactionId)) + { + continue; + } + + Guid? categoryId = null; + if ( + Guid.TryParse(entry?["category_id"]?.GetValue(), out var parsedCategory) + && validCategoryIds.Contains(parsedCategory) + ) + { + categoryId = parsedCategory; + } + + var confidence = entry?["confidence"]?.GetValue() ?? 0m; + suggestions.Add( + new ClaudeCategorySuggestion( + transactionId, + categoryId, + Math.Clamp(confidence, 0m, 1m) + ) + ); + } + + return Result.Ok>(suggestions); + } +} diff --git a/src/FinanceApp.Categorization/Claude/IClaudeCategorizer.cs b/src/FinanceApp.Categorization/Claude/IClaudeCategorizer.cs new file mode 100644 index 0000000..6f779dc --- /dev/null +++ b/src/FinanceApp.Categorization/Claude/IClaudeCategorizer.cs @@ -0,0 +1,44 @@ +using FinanceApp.Domain; + +namespace FinanceApp.Categorization.Claude; + +/// +/// Claude API categorization fallback. Failures return +/// failures — never exceptions — so the pipeline degrades gracefully: +/// transactions simply stay in the review queue. +/// +public interface IClaudeCategorizer +{ + Task>> SuggestAsync( + IReadOnlyList transactions, + IReadOnlyList categories, + IReadOnlyList examples, + CancellationToken cancellationToken = default + ); +} + +public sealed record TransactionToCategorize( + Guid TransactionId, + string? Counterparty, + string Description, + decimal Amount, + string Currency +); + +/// A category the model may choose, with its display path (e.g. "Living › Groceries"). +public sealed record CategoryOption(Guid Id, string Path); + +/// Confirmed historical categorization used as a few-shot example. +public sealed record FewShotExample( + string? Counterparty, + string Description, + decimal Amount, + string CategoryPath +); + +/// is null when the model declined to choose. +public sealed record ClaudeCategorySuggestion( + Guid TransactionId, + Guid? CategoryId, + decimal Confidence +); diff --git a/src/FinanceApp.Categorization/FinanceApp.Categorization.csproj b/src/FinanceApp.Categorization/FinanceApp.Categorization.csproj index d0578a9..deab492 100644 --- a/src/FinanceApp.Categorization/FinanceApp.Categorization.csproj +++ b/src/FinanceApp.Categorization/FinanceApp.Categorization.csproj @@ -8,6 +8,13 @@ + + + + + + + diff --git a/src/FinanceApp.Categorization/Initialization.cs b/src/FinanceApp.Categorization/Initialization.cs index a387848..a1e29a1 100644 --- a/src/FinanceApp.Categorization/Initialization.cs +++ b/src/FinanceApp.Categorization/Initialization.cs @@ -1,15 +1,26 @@ +using FinanceApp.Categorization.Claude; using Microsoft.Extensions.DependencyInjection; +using Wolverine.Attributes; + +[assembly: WolverineModule] namespace FinanceApp.Categorization; public static class Initialization { /// - /// Registers the categorization services. Empty in Phase 0 — the rules engine - /// and the Claude API client arrive with Phase 2. + /// Registers the categorization services: the Claude API client (key from + /// the encrypted credential store) and, via Wolverine module discovery, the + /// async categorization pipeline. The rules engine is pure logic. /// public static IServiceCollection AddCategorization(this IServiceCollection services) { + services.AddHttpClient(client => + { + client.BaseAddress = new Uri("https://api.anthropic.com/"); + client.Timeout = TimeSpan.FromSeconds(90); + }); + return services; } } diff --git a/src/FinanceApp.Categorization/Rules/RuleMatcher.cs b/src/FinanceApp.Categorization/Rules/RuleMatcher.cs new file mode 100644 index 0000000..6efdd9b --- /dev/null +++ b/src/FinanceApp.Categorization/Rules/RuleMatcher.cs @@ -0,0 +1,70 @@ +using FinanceApp.Domain.Categories; + +namespace FinanceApp.Categorization.Rules; + +/// +/// Deterministic rule matching: all set conditions must hold (case-insensitive +/// contains for text, inclusive bounds for amounts). The most specific matching +/// rule wins; ties go to the newest rule (later corrections refine earlier ones). +/// +public static class RuleMatcher +{ + public static CategoryRule? FindMatch( + IReadOnlyList rules, + string? counterparty, + string description, + decimal amount + ) => + rules + .Where(rule => Matches(rule, counterparty, description, amount)) + .OrderByDescending(rule => rule.Specificity) + .ThenByDescending(rule => rule.CreatedAt) + .FirstOrDefault(); + + internal static bool Matches( + CategoryRule rule, + string? counterparty, + string description, + decimal amount + ) + { + // A rule without any text condition would match everything — ignore it. + if ( + string.IsNullOrWhiteSpace(rule.CounterpartyContains) + && string.IsNullOrWhiteSpace(rule.DescriptionContains) + ) + { + return false; + } + + if ( + rule.CounterpartyContains is { } counterpartyPattern + && ( + counterparty is null + || !counterparty.Contains(counterpartyPattern, StringComparison.OrdinalIgnoreCase) + ) + ) + { + return false; + } + + if ( + rule.DescriptionContains is { } descriptionPattern + && !description.Contains(descriptionPattern, StringComparison.OrdinalIgnoreCase) + ) + { + return false; + } + + if (rule.MinAmount is decimal min && amount < min) + { + return false; + } + if (rule.MaxAmount is decimal max && amount > max) + { + return false; + } + + return true; + } +} diff --git a/src/FinanceApp.Categorization/Suggestions/ApplyCategorySuggestionsCommand.cs b/src/FinanceApp.Categorization/Suggestions/ApplyCategorySuggestionsCommand.cs new file mode 100644 index 0000000..789de4f --- /dev/null +++ b/src/FinanceApp.Categorization/Suggestions/ApplyCategorySuggestionsCommand.cs @@ -0,0 +1,53 @@ +using FinanceApp.Domain; +using FinanceApp.Domain.Transactions; +using Marten; + +namespace FinanceApp.Categorization.Suggestions; + +/// +/// Bulk-confirms AI suggestions from the review queue. Confirmation is a human +/// decision, so the resulting event carries source Manual — these transactions +/// also feed the few-shot examples for future AI batches. +/// +public sealed record ApplyCategorySuggestionsCommand(IReadOnlyList TransactionIds); + +public static class ApplyCategorySuggestionsCommandHandler +{ + public static async Task Handle( + ApplyCategorySuggestionsCommand command, + IDocumentSession session, + CancellationToken cancellationToken + ) + { + var applied = 0; + foreach (var transactionId in command.TransactionIds.Distinct()) + { + var suggestion = await session.LoadAsync( + transactionId, + cancellationToken + ); + if (suggestion is null) + { + continue; + } + + var stream = await session.Events.FetchForWriting( + transactionId, + cancellationToken + ); + if (stream.Aggregate is { CategoryId: null }) + { + stream.AppendOne( + new TransactionCategorized(suggestion.CategoryId, CategorySource.Manual, null) + ); + applied++; + } + session.Delete(suggestion); + } + + await session.SaveChangesAsync(cancellationToken); + return applied == 0 && command.TransactionIds.Count > 0 + ? Result.Fail("No pending suggestions found for the selected transactions.") + : Result.Ok(); + } +} diff --git a/src/FinanceApp.Categorization/Suggestions/CategorySuggestion.cs b/src/FinanceApp.Categorization/Suggestions/CategorySuggestion.cs new file mode 100644 index 0000000..aa99db8 --- /dev/null +++ b/src/FinanceApp.Categorization/Suggestions/CategorySuggestion.cs @@ -0,0 +1,21 @@ +namespace FinanceApp.Categorization.Suggestions; + +/// +/// A low-confidence AI category suggestion awaiting human review. One per +/// transaction (the transaction id is the document identity); deleted when the +/// suggestion is accepted, dismissed, or the transaction gets categorized any +/// other way. High-confidence suggestions are applied directly as events and +/// never stored here. +/// +public sealed class CategorySuggestion +{ + /// Transaction id. + public required Guid Id { get; init; } + + public required Guid CategoryId { get; init; } + + /// Model confidence in [0, 1]. + public required decimal Confidence { get; init; } + + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/src/FinanceApp.Categorization/Suggestions/DismissCategorySuggestionsCommand.cs b/src/FinanceApp.Categorization/Suggestions/DismissCategorySuggestionsCommand.cs new file mode 100644 index 0000000..0524d62 --- /dev/null +++ b/src/FinanceApp.Categorization/Suggestions/DismissCategorySuggestionsCommand.cs @@ -0,0 +1,24 @@ +using FinanceApp.Domain; +using Marten; + +namespace FinanceApp.Categorization.Suggestions; + +/// Discards AI suggestions; the transactions stay in the review queue. +public sealed record DismissCategorySuggestionsCommand(IReadOnlyList TransactionIds); + +public static class DismissCategorySuggestionsCommandHandler +{ + public static async Task Handle( + DismissCategorySuggestionsCommand command, + IDocumentSession session, + CancellationToken cancellationToken + ) + { + foreach (var transactionId in command.TransactionIds.Distinct()) + { + session.Delete(transactionId); + } + await session.SaveChangesAsync(cancellationToken); + return Result.Ok(); + } +} diff --git a/src/FinanceApp.Categorization/packages.lock.json b/src/FinanceApp.Categorization/packages.lock.json index 27a4222..46158c5 100644 --- a/src/FinanceApp.Categorization/packages.lock.json +++ b/src/FinanceApp.Categorization/packages.lock.json @@ -2,11 +2,547 @@ "version": 2, "dependencies": { "net10.0": { + "Marten": { + "type": "Direct", + "requested": "[9.12.0, )", + "resolved": "9.12.0", + "contentHash": "RIyPzIN6ehfDmBWPs7zyMryy13uOPZHQBA8QuBwO0PHjzpyonP/rBoJYrgiJGXcaiWFVxqHj3QfPKuLjXsA5uQ==", + "dependencies": { + "JasperFx": "2.18.1", + "JasperFx.Events": "2.18.1", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.IO.RecyclableMemoryStream": "3.0.1", + "Weasel.Postgresql": "9.3.0" + } + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", "requested": "[10.0.9, )", "resolved": "10.0.9", "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" + }, + "Microsoft.Extensions.Http": { + "type": "Direct", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "5jdmuLRg1HO7x/v8McOf6ndhkMQWImkRBtJpXM8+xIocXNdLqCItyyFLKusdILc62TDzHuLlqaWoxtOA5FFOPg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "WolverineFx.Marten": { + "type": "Direct", + "requested": "[6.16.0, )", + "resolved": "6.16.0", + "contentHash": "d7xWZTHwOxKRkS249JCbf/ot8/Mdk8CpD3oYj+mTvrKcT7G1tRRVAfAudJCjgozWgcuHOK+uH8o0Wn9tsKgYdQ==", + "dependencies": { + "Marten": "9.11.0", + "WolverineFx.Postgresql": "6.16.0" + } + }, + "DistributedLock.Core": { + "type": "Transitive", + "resolved": "1.0.8", + "contentHash": "LAOsY8WxX8JU/n3lfXFz+f2pfnv0+4bHkCrOO3bwa28u9HrS3DlxSG6jf+u76SqesKs+KehZi0CndkfaUXBKvg==" + }, + "DistributedLock.Postgres": { + "type": "Transitive", + "resolved": "1.3.0", + "contentHash": "CyjXmbhFgG30qPg4DwGnsmZO63y5DBRo+PI2xW0NwtFrsYsMVt/T1RcEUlb36JMKhDkf+euhnkanv/nU+W35qA==", + "dependencies": { + "DistributedLock.Core": "[1.0.8, 1.1.0)", + "Npgsql": "8.0.6" + } + }, + "FastExpressionCompiler": { + "type": "Transitive", + "resolved": "5.4.1", + "contentHash": "nqMykMspK5cQd35Y8ulQzAujxCcCwZVUne32pC5xNpXqrbPCuKOPMWkKHJ3LveAZPm6dsVvump0TJ1SZ9RzfTQ==" + }, + "ImTools": { + "type": "Transitive", + "resolved": "4.0.0", + "contentHash": "dms0JfLKC9AQbQX/EdpRjn59EjEvaCxIgv1z9YxbEQb5SiOVoo7vlIKdEySeEwUkWhN05rZnNpUpG+aw5VqPVQ==" + }, + "JasperFx": { + "type": "Transitive", + "resolved": "2.18.1", + "contentHash": "i8k7awEkuDdZV4kUzIQT54h5pydEzBpH4CjD5ds7b7dHxK0KYvIyKWDWZqMHHJVbVqXEQHtQHQNTeqUi2zOLZw==", + "dependencies": { + "FastExpressionCompiler": "5.4.1", + "ImTools": "4.0.0", + "Microsoft.Bcl.TimeProvider": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting": "10.0.0", + "Microsoft.Extensions.ObjectPool": "10.0.0", + "Polly.Core": "8.6.5", + "Spectre.Console": "0.55.0" + } + }, + "JasperFx.Events": { + "type": "Transitive", + "resolved": "2.18.1", + "contentHash": "kBK14ziH5UV7jYrsclg6KPpB8F3vmCXhkMDWw+oPnBaxYC4xeB/5STrt+gn6V4VlbR1Xn/wcd9ZVAx3teDls9w==", + "dependencies": { + "JasperFx": "2.18.1" + } + }, + "JasperFx.SourceGenerator": { + "type": "Transitive", + "resolved": "2.16.0", + "contentHash": "wwjl3taQ1Z40ESBTIAYwf88nms3sXp6osyAzIAjXugrUCj3D4AIYEYQGxHAPF6Yk6o6Roouw/pnMqJuKS2jM0g==" + }, + "Microsoft.Bcl.TimeProvider": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "bUubrBD6tRJE3V1kvRloYc6NymH3R5oFKjAS9e0ELNx6u0ZR+zjps9dDQyjgqN/rArzl7f+21KGszj3YRN7F2Q==" + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "Zcoy6H9mSoGyvr7UvlGokEZrlZkcPCICPZr8mCsSt9U/N8eeCwCXwKF5bShdA66R0obxBCwP4AxomQHvVkC/uA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "krK19MKp0BNiR9rpBDW7PKSrTMLVlifS9am3CVc4O1Jq6GWz0o4F+sw5OSL4L3mVd56W8l6JRgghUa2KB51vOw==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "woZsWLhOQsASuxbmgiZJqiGUBNo3IjRdXC92xt8rRokza+P6/nIsnzq7sm9Or6ZYcRl2kL1ufj8HVzp1QlPTXw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "qGhRPd3VxfLV9UqatVOiD9mAeUbj2KiMwGFYC5uXlzExiZQoe4X/hdmzGIU7BQjNLTqCnnbTHVyBglG3668/HA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Tp/+LPb70RyjjtLg9m5C959eP4KrUpJHThZfAegZVpsfmGvzfuNkuYbI/ft+LvXhMSyUcAeOPaN6rzTccwnZAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "CRj5clwZciVs46GMhAthkFq3+JiNM15Bz9CRlCZLBmRdggD6RwoBphRJ+EUDK2f+cZZ1L2zqVaQrn1KueoU5Kg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "TmFegsI/uCdwMBD4yKpmO+OkjVNHQL49Dh/ep83NI5rPUEoBK9OdsJo1zURc1A2FuS/R/Pos3wsTjlyLnguBLA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LqCTyF0twrG4tyEN6PpSC5ewRBDwCBazRUfCOdRddwaQ3n2S57GDDeYOlTLcbV/V2dxSSZWg5Ofr48h6BsBmxw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BIOPTEAZoeWbHlDT9Zudu+rpecZizFwhdIFRiyZKDml7JbayXmfTXKUt+ezifsSXfBkWDdJM10oDOxo8pufEng==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "B4qHB6gQ2B3I52YRohSV7wetp01BQzi8jDmrtiVm6e4l8vH5vjqwxWcR5wumGWjdBkj1asJLLsDIocdyTQSP0A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Json": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "NijozhERJDIaJ4k5TSMy1jOi0cSC2HfkvRD/Sl+kGSSKgVbFnF4GxgtMN/MrzHB8D1JxIrD4xSer9Blh9v3axQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "NLXI3PbTe39q6/sgs7JYhmfPf7bMzReUoAJ0q9Po6yhfM+0anZa7PrEva4W2SdiLWGyB9eKZS9THGt2BP40xJg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "86RgyFsmVslW4Nu28IXgt8tLglynGQrwjk/xhGZaTe8j6YIeR1Ywoc42hSHsBSl920CQdfqq2dBohZiGm3AkUA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "jAhZbzDa117otUBMuQQ6JzSfuDbBBrfOs5jw5l7l9hKpzt+LjYKVjSauXG2yV9u7BqUSLUtKLwcerDQDeQ+0Xw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "UZUQ74lQMmvcprlG8w+XpxBbyRDQqfb7GAnccITw32hdkUBlmm9yNC4xl4aR9YjgV3ounZcub194sdmLSfBmPA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "5hfVl/e+bx1px2UkN+1xXhd3hu7Ui6ENItBzckFaRDQXfr+SHT/7qrCDrlQekCF/PBtEu2vtk87U2+gDEF8EhQ==" + }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "yKJiVdXkSfe9foojGpBRbuDPQI8YD71IO/aE8ehGjRHE0VkEF/YWkW6StthwuFF146pc2lypZrpk/Tks6Plwhw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.0", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0", + "Microsoft.Extensions.Configuration.Json": "10.0.0", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.0", + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "Microsoft.Extensions.Logging.Console": "10.0.0", + "Microsoft.Extensions.Logging.Debug": "10.0.0", + "Microsoft.Extensions.Logging.EventLog": "10.0.0", + "Microsoft.Extensions.Logging.EventSource": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "N7Gm9SjugYjmmnhwbBKC9DFqGqjfJvh6YfOJgtwh0AW0Xpok3dIVors1ik050XmUxKAgAc7nNngDIJyFb06K2g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "j8zcwhS6bYB6FEfaY3nYSgHdpiL2T+/V3xjpHtslVAegyI1JUbB9yAt/BFdvZdsNbY0Udm4xFtvfT/hUwcOOOg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "treWetuksp8LVb09fCJ5zNhNJjyDkqzVm83XxcrlWQnAdXznR140UUXo8PyEPBvFlHhjKhFQZEOP3Sk/ByCvEw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "A/4vBtVaySLBGj4qluye+KSbeVCCMa6GcTbxf2YgnSDHs9b9105+VojBJ1eJPel8F1ny0JOh+Ci3vgCKn69tNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "EWda5nSXhzQZr3yJ3+XgIApOek+Hm+txhWCEzWNVPp/OfimL4qmvctgXu87m+S2RXw/AoUP8aLMNicJ2KWblVA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "System.Diagnostics.EventLog": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "+Qc+kgoJi1w2A/Jm+7h04LcK2JoJkwAxKg7kBakkNRcemTmRGocqPa7rVNVGorTYruFrUS25GwkFNtOECnjhXg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "hyNdX4c2UwkRkzb9byw0H2DQkRzwBM3mzY2sCM9egwzTyg8dvQJmp5noQHGEaaCORQrNK3DD2gREBsc2DlXS4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Y4E24zffF/aPS0igNvY6ZzAQfbxd6AYdC9L4brnH+uK0yYYHIR6FeGVQVVjAOo8wub1EQDl2B90lCcpqoTF7Yw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "s/s20YTVY9r9TPfTrN5g8zPF1YhwxyqO6PxUkrYTGI2B+OGPe9AdajWZrLhFqXIvqIW23fnUE4+ztrUWNU1+9g==" + }, + "NetTopologySuite": { + "type": "Transitive", + "resolved": "2.5.0", + "contentHash": "5/+2O2ADomEdUn09mlSigACdqvAf0m/pVPGtIPEPQWnyrVykYY0NlfXLIdkMgi41kvH9kNrPqYaFBTZtHYH7Xw==" + }, + "NetTopologySuite.IO.PostGis": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "3W8XTFz8iP6GQ5jDXK1/LANHiU+988k1kmmuPWNKcJLpmSg6CvFpbTpz+s4+LBzkAp64wHGOldSlkSuzYfrIKA==", + "dependencies": { + "NetTopologySuite": "[2.0.0, 3.0.0-A)" + } + }, + "NewId": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "jegBasNndmG21G3BmT51suFDCIz/sLN81j+IxmkZ4iWoaDi8LygeHiyNnYbXz5OQh9nCRJFIx1+PJrlYi1Gc9Q==" + }, + "Npgsql": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "68BASXH0FAEuL/J4J0eRfYC8/3vzqQTmoW8zDzNf0JgaVxc7LZeEkS6jaG0ib3voFLxY5ZiCwJG+uQM+mzuu0Q==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.2" + } + }, + "Npgsql.NetTopologySuite": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "7AwBLPz8EPmgAXveZ55K0Tz/9bNb8j4VI4pkTOQjyHrce8wWfAA9pefm3REidy+Kt2UFiAWef34YVi7sb/kB4A==", + "dependencies": { + "NetTopologySuite": "2.5.0", + "NetTopologySuite.IO.PostGIS": "2.1.0", + "Npgsql": "9.0.4" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.6.5", + "contentHash": "t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg==" + }, + "Spectre.Console": { + "type": "Transitive", + "resolved": "0.55.0", + "contentHash": "2TVhUHFsDux0Z+y2Hv4bFsOMuON4SuotEEKkMparFkp5Rm259naaaEQrN4Sv6C1cbsiGKPjfkJDGgUp+hZPjTw==", + "dependencies": { + "Spectre.Console.Ansi": "0.55.0" + } + }, + "Spectre.Console.Ansi": { + "type": "Transitive", + "resolved": "0.55.0", + "contentHash": "GIfgOvuF8MpU52bprhwtDEtx0gmVIGOepM8bw0lH/TSMD0rg1Xp+5Y53kPG8rFVdcpbNANlhDQ5mEVVPO3/2Tg==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "uaFRda9NjtbJRkdx311eXlAA3n2em7223c1A8d1VWyl+4FL9vkG7y2lpPfBU9HYdj/9KgdRNdn1vFK8ZYCYT/A==" + }, + "Weasel.Core": { + "type": "Transitive", + "resolved": "9.3.0", + "contentHash": "Lrf6a0N8T89q2z4IaBF6413zzQsABe+LvL9D5K5ig8JjpU7C1gOcgwPEeldpdxcj3q7mSaYx9LKW72TC2wLpbw==", + "dependencies": { + "JasperFx": "2.13.1" + } + }, + "Weasel.Postgresql": { + "type": "Transitive", + "resolved": "9.3.0", + "contentHash": "vbcpQFemPq88d8pM6Mcq8c/PkcNMjpMdal/AGThqsMliNnIVTiHFdPypSzBUthHTv8UV8fxF+ze9C7PHtq1Rrw==", + "dependencies": { + "DistributedLock.Postgres": "1.3.0", + "JasperFx.Events": "2.0.0", + "Npgsql": "9.0.4", + "Npgsql.NetTopologySuite": "9.0.4", + "Weasel.Core": "9.3.0" + } + }, + "WolverineFx": { + "type": "Transitive", + "resolved": "6.16.0", + "contentHash": "zDNCik/B9eou+cGkpYmzOlRe+aXvzBMkEFDA0i7/DgLOncbbX12gLsugojwoLHPLUCI4Grwk4ymPj5+gYK410g==", + "dependencies": { + "JasperFx": "2.16.0", + "JasperFx.Events": "2.16.0", + "JasperFx.SourceGenerator": "2.16.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.Configuration.Json": "10.0.0", + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.ObjectPool": "10.0.2", + "NewId": "4.0.1" + } + }, + "WolverineFx.Postgresql": { + "type": "Transitive", + "resolved": "6.16.0", + "contentHash": "u3p2opC06FUYd3CQRiN4x/MSl3nbHbcpzxt69RSi7ZPq311g00wCyUJ4wHb+SSGN1a3yFqjihEoSXGvqXAThSA==", + "dependencies": { + "Weasel.Postgresql": "9.3.0", + "WolverineFx.RDBMS": "6.16.0" + } + }, + "WolverineFx.RDBMS": { + "type": "Transitive", + "resolved": "6.16.0", + "contentHash": "IYfSUZkSSwMGtcq85Pe2Nom0TWzLmSxmgNvHT8pLl4LmwBpgOWc2bVj1VYJFopS2qMZxTsWTQYnLfTIQN0CSdA==", + "dependencies": { + "Weasel.Core": "9.3.0", + "WolverineFx": "6.16.0" + } + }, + "financeapp.domain": { + "type": "Project", + "dependencies": { + "Marten": "[9.12.0, )", + "Microsoft.AspNetCore.DataProtection.Abstractions": "[10.0.9, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", + "WolverineFx.Marten": "[6.16.0, )" + } + }, + "Microsoft.AspNetCore.DataProtection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "KZP7FpVQkP29U0icd2xNdnAy9zUfhqX6JmYzhjBAekVYltyPfAZwNkY9EY078d8+Silvt3kvuecspzlfmbtWvg==" } } } diff --git a/src/FinanceApp.Connectors/packages.lock.json b/src/FinanceApp.Connectors/packages.lock.json index 866447d..76ed707 100644 --- a/src/FinanceApp.Connectors/packages.lock.json +++ b/src/FinanceApp.Connectors/packages.lock.json @@ -496,6 +496,7 @@ "type": "Project", "dependencies": { "Marten": "[9.12.0, )", + "Microsoft.AspNetCore.DataProtection.Abstractions": "[10.0.9, )", "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", "WolverineFx.Marten": "[6.16.0, )" } @@ -513,6 +514,12 @@ "Weasel.Postgresql": "9.3.0" } }, + "Microsoft.AspNetCore.DataProtection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "KZP7FpVQkP29U0icd2xNdnAy9zUfhqX6JmYzhjBAekVYltyPfAZwNkY9EY078d8+Silvt3kvuecspzlfmbtWvg==" + }, "WolverineFx.Marten": { "type": "CentralTransitive", "requested": "[6.16.0, )", diff --git a/src/FinanceApp.Domain/Categories/CategoryRule.cs b/src/FinanceApp.Domain/Categories/CategoryRule.cs new file mode 100644 index 0000000..e9506eb --- /dev/null +++ b/src/FinanceApp.Domain/Categories/CategoryRule.cs @@ -0,0 +1,38 @@ +namespace FinanceApp.Domain.Categories; + +/// +/// Deterministic categorization rule: all set conditions must match +/// (case-insensitive contains for the text patterns, inclusive bounds for the +/// amount range). Rules win over AI suggestions and cost nothing per import. +/// +public sealed class CategoryRule +{ + public Guid Id { get; init; } = Guid.NewGuid(); + + public required Guid CategoryId { get; set; } + + public string? CounterpartyContains { get; set; } + + public string? DescriptionContains { get; set; } + + public decimal? MinAmount { get; set; } + + public decimal? MaxAmount { get; set; } + + public required CategoryRuleSource Source { get; init; } + + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + + /// Number of set conditions — more specific rules win on conflicts. + public int Specificity => + (string.IsNullOrWhiteSpace(CounterpartyContains) ? 0 : 1) + + (string.IsNullOrWhiteSpace(DescriptionContains) ? 0 : 1) + + (MinAmount is null ? 0 : 1) + + (MaxAmount is null ? 0 : 1); +} + +public enum CategoryRuleSource +{ + Manual, + LearnedFromCorrection, +} diff --git a/src/FinanceApp.Domain/Categories/CreateCategoryRuleCommand.cs b/src/FinanceApp.Domain/Categories/CreateCategoryRuleCommand.cs new file mode 100644 index 0000000..d417988 --- /dev/null +++ b/src/FinanceApp.Domain/Categories/CreateCategoryRuleCommand.cs @@ -0,0 +1,70 @@ +using Marten; + +namespace FinanceApp.Domain.Categories; + +public sealed record CreateCategoryRuleCommand( + Guid CategoryId, + string? CounterpartyContains, + string? DescriptionContains, + decimal? MinAmount, + decimal? MaxAmount, + CategoryRuleSource Source +); + +public static class CreateCategoryRuleCommandHandler +{ + public static async Task> Handle( + CreateCategoryRuleCommand command, + IDocumentSession session, + CancellationToken cancellationToken + ) + { + var counterparty = Normalize(command.CounterpartyContains); + var description = Normalize(command.DescriptionContains); + if (counterparty is null && description is null) + { + return Result.Fail( + "A rule needs at least a counterparty or description pattern." + ); + } + if (command.MinAmount is decimal min && command.MaxAmount is decimal max && min > max) + { + return Result.Fail("Minimum amount must not exceed maximum amount."); + } + + var category = await session.LoadAsync(command.CategoryId, cancellationToken); + if (category is null) + { + return Result.Fail("Category not found."); + } + + var duplicate = await session + .Query() + .Where(rule => + rule.CategoryId == command.CategoryId + && rule.CounterpartyContains == counterparty + && rule.DescriptionContains == description + ) + .FirstOrDefaultAsync(cancellationToken); + if (duplicate is not null) + { + return Result.Ok(duplicate); + } + + var newRule = new CategoryRule + { + CategoryId = command.CategoryId, + CounterpartyContains = counterparty, + DescriptionContains = description, + MinAmount = command.MinAmount, + MaxAmount = command.MaxAmount, + Source = command.Source, + }; + session.Store(newRule); + await session.SaveChangesAsync(cancellationToken); + return Result.Ok(newRule); + } + + private static string? Normalize(string? pattern) => + string.IsNullOrWhiteSpace(pattern) ? null : pattern.Trim(); +} diff --git a/src/FinanceApp.Domain/Categories/DeleteCategoryRuleCommand.cs b/src/FinanceApp.Domain/Categories/DeleteCategoryRuleCommand.cs new file mode 100644 index 0000000..b905181 --- /dev/null +++ b/src/FinanceApp.Domain/Categories/DeleteCategoryRuleCommand.cs @@ -0,0 +1,25 @@ +using Marten; + +namespace FinanceApp.Domain.Categories; + +public sealed record DeleteCategoryRuleCommand(Guid RuleId); + +public static class DeleteCategoryRuleCommandHandler +{ + public static async Task Handle( + DeleteCategoryRuleCommand command, + IDocumentSession session, + CancellationToken cancellationToken + ) + { + var rule = await session.LoadAsync(command.RuleId, cancellationToken); + if (rule is null) + { + return Result.Fail("Rule not found."); + } + + session.Delete(rule); + await session.SaveChangesAsync(cancellationToken); + return Result.Ok(); + } +} diff --git a/src/FinanceApp.Domain/Credentials/ICredentialStore.cs b/src/FinanceApp.Domain/Credentials/ICredentialStore.cs new file mode 100644 index 0000000..ca71116 --- /dev/null +++ b/src/FinanceApp.Domain/Credentials/ICredentialStore.cs @@ -0,0 +1,21 @@ +namespace FinanceApp.Domain.Credentials; + +/// +/// Encrypted provider credential storage. Secrets never round-trip to the UI — +/// exposes only metadata. +/// +public interface ICredentialStore +{ + /// Decrypted secret, or null when no credential is stored. + Task GetSecretAsync(string key, CancellationToken cancellationToken = default); + + Task GetInfoAsync(string key, CancellationToken cancellationToken = default); + + Task SaveSecretAsync(string key, string secret, CancellationToken cancellationToken = default); +} + +public sealed record CredentialInfo( + string Key, + DateTimeOffset CreatedAt, + DateTimeOffset? RotatedAt +); diff --git a/src/FinanceApp.Domain/Credentials/MartenCredentialStore.cs b/src/FinanceApp.Domain/Credentials/MartenCredentialStore.cs new file mode 100644 index 0000000..337b2bf --- /dev/null +++ b/src/FinanceApp.Domain/Credentials/MartenCredentialStore.cs @@ -0,0 +1,63 @@ +using Marten; +using Microsoft.AspNetCore.DataProtection; + +namespace FinanceApp.Domain.Credentials; + +internal sealed class MartenCredentialStore( + IDocumentStore store, + IDataProtectionProvider dataProtectionProvider +) : ICredentialStore +{ + public async Task GetSecretAsync( + string key, + CancellationToken cancellationToken = default + ) + { + await using var session = store.QuerySession(); + var credential = await session.LoadAsync(key, cancellationToken); + return credential is null ? null : Protector(key).Unprotect(credential.ProtectedPayload); + } + + public async Task GetInfoAsync( + string key, + CancellationToken cancellationToken = default + ) + { + await using var session = store.QuerySession(); + var credential = await session.LoadAsync(key, cancellationToken); + return credential is null + ? null + : new CredentialInfo(credential.Id, credential.CreatedAt, credential.RotatedAt); + } + + public async Task SaveSecretAsync( + string key, + string secret, + CancellationToken cancellationToken = default + ) + { + await using var session = store.LightweightSession(); + var existing = await session.LoadAsync(key, cancellationToken); + if (existing is null) + { + session.Store( + new ProviderCredential + { + Id = key, + ProtectedPayload = Protector(key).Protect(secret), + } + ); + } + else + { + existing.ProtectedPayload = Protector(key).Protect(secret); + existing.RotatedAt = DateTimeOffset.UtcNow; + session.Store(existing); + } + await session.SaveChangesAsync(cancellationToken); + } + + /// Per-credential purpose string, isolating payloads from each other. + private IDataProtector Protector(string key) => + dataProtectionProvider.CreateProtector($"FinanceApp.ProviderCredential.{key}"); +} diff --git a/src/FinanceApp.Domain/Credentials/ProviderCredential.cs b/src/FinanceApp.Domain/Credentials/ProviderCredential.cs new file mode 100644 index 0000000..5a01dbd --- /dev/null +++ b/src/FinanceApp.Domain/Credentials/ProviderCredential.cs @@ -0,0 +1,25 @@ +namespace FinanceApp.Domain.Credentials; + +/// +/// One provider secret (Wise token, Enable Banking key, Claude API key, …), +/// stored DataProtection-encrypted with a per-credential purpose string. The +/// payload is never logged and never written to config files; the key ring +/// persistence comes from app-foundation. +/// +public sealed class ProviderCredential +{ + /// Credential key (see ) — the document identity. + public required string Id { get; init; } + + public required string ProtectedPayload { get; set; } + + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; + + public DateTimeOffset? RotatedAt { get; set; } +} + +/// Well-known credential keys. +public static class CredentialKeys +{ + public const string ClaudeApiKey = "claude-api-key"; +} diff --git a/src/FinanceApp.Domain/Credentials/SaveProviderCredentialCommand.cs b/src/FinanceApp.Domain/Credentials/SaveProviderCredentialCommand.cs new file mode 100644 index 0000000..cde62b2 --- /dev/null +++ b/src/FinanceApp.Domain/Credentials/SaveProviderCredentialCommand.cs @@ -0,0 +1,30 @@ +namespace FinanceApp.Domain.Credentials; + +/// Stores or rotates a provider secret. The payload is never logged. +public sealed record SaveProviderCredentialCommand(string Key, string Secret); + +public static class SaveProviderCredentialCommandHandler +{ + public static async Task Handle( + SaveProviderCredentialCommand command, + ICredentialStore credentialStore, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(command.Key)) + { + return Result.Fail("Credential key is required."); + } + if (string.IsNullOrWhiteSpace(command.Secret)) + { + return Result.Fail("The secret must not be empty."); + } + + await credentialStore.SaveSecretAsync( + command.Key, + command.Secret.Trim(), + cancellationToken + ); + return Result.Ok(); + } +} diff --git a/src/FinanceApp.Domain/FinanceApp.Domain.csproj b/src/FinanceApp.Domain/FinanceApp.Domain.csproj index caf5b10..bf3e5f1 100644 --- a/src/FinanceApp.Domain/FinanceApp.Domain.csproj +++ b/src/FinanceApp.Domain/FinanceApp.Domain.csproj @@ -8,6 +8,7 @@ + diff --git a/src/FinanceApp.Domain/Imports/CategorizeImportedTransactionsCommand.cs b/src/FinanceApp.Domain/Imports/CategorizeImportedTransactionsCommand.cs new file mode 100644 index 0000000..31908e8 --- /dev/null +++ b/src/FinanceApp.Domain/Imports/CategorizeImportedTransactionsCommand.cs @@ -0,0 +1,9 @@ +namespace FinanceApp.Domain.Imports; + +/// +/// Published after a successful import; handled asynchronously by the +/// categorization pipeline (rules first, Claude fallback). Kept in the Domain +/// assembly so the import handler can publish it without referencing the +/// categorization module. Categorization failures never fail imports. +/// +public sealed record CategorizeImportedTransactionsCommand(Guid ImportBatchId); diff --git a/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs b/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs index fd054c8..43f6f2e 100644 --- a/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs +++ b/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs @@ -1,6 +1,7 @@ using FinanceApp.Domain.Accounts; using FinanceApp.Domain.Transactions; using Marten; +using Wolverine; namespace FinanceApp.Domain.Imports; @@ -24,6 +25,7 @@ public static class ImportStatementCommandHandler public static async Task> Handle( ImportStatementCommand command, IDocumentSession session, + IMessageBus messageBus, CancellationToken cancellationToken ) { @@ -87,6 +89,14 @@ CancellationToken cancellationToken session.Store(batch); await session.SaveChangesAsync(cancellationToken); + + if (newRows.Count > 0) + { + // Async fire-and-forget: categorization failures (rules or Claude + // API) must never fail an import. + await messageBus.PublishAsync(new CategorizeImportedTransactionsCommand(batch.Id)); + } + return Result.Ok(batch); } diff --git a/src/FinanceApp.Domain/Initialization.cs b/src/FinanceApp.Domain/Initialization.cs index d020bde..d1591be 100644 --- a/src/FinanceApp.Domain/Initialization.cs +++ b/src/FinanceApp.Domain/Initialization.cs @@ -1,4 +1,5 @@ using FinanceApp.Domain.Categories; +using FinanceApp.Domain.Credentials; using FinanceApp.Domain.Transactions; using JasperFx.Events.Projections; using Marten; @@ -29,6 +30,8 @@ public static IServiceCollection AddFinanceDomain(this IServiceCollection servic services.InitializeMartenWith(new DefaultCategorySeed()); + services.AddSingleton(); + return services; } } diff --git a/src/FinanceApp.Domain/packages.lock.json b/src/FinanceApp.Domain/packages.lock.json index dc5adca..294d171 100644 --- a/src/FinanceApp.Domain/packages.lock.json +++ b/src/FinanceApp.Domain/packages.lock.json @@ -15,6 +15,12 @@ "Weasel.Postgresql": "9.3.0" } }, + "Microsoft.AspNetCore.DataProtection.Abstractions": { + "type": "Direct", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "KZP7FpVQkP29U0icd2xNdnAy9zUfhqX6JmYzhjBAekVYltyPfAZwNkY9EY078d8+Silvt3kvuecspzlfmbtWvg==" + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Direct", "requested": "[10.0.9, )", diff --git a/src/FinanceApp/Components/FinanceNavMenu.razor b/src/FinanceApp/Components/FinanceNavMenu.razor index 00caab3..b7cd063 100644 --- a/src/FinanceApp/Components/FinanceNavMenu.razor +++ b/src/FinanceApp/Components/FinanceNavMenu.razor @@ -3,7 +3,10 @@ Administrator role in this private app. *@ + + + diff --git a/src/FinanceApp/Components/Review/Pages/Review.razor b/src/FinanceApp/Components/Review/Pages/Review.razor new file mode 100644 index 0000000..6e0b340 --- /dev/null +++ b/src/FinanceApp/Components/Review/Pages/Review.razor @@ -0,0 +1,205 @@ +@page "/review" +@attribute [Authorize] + +@inject IQuerySession QuerySession +@inject IMessageBus MessageBus +@inject NotificationService NotificationService + +Review – Finance + +
+ + + Review queue + + Uncategorized transactions and low-confidence AI suggestions. High-confidence + suggestions are applied automatically and flagged "Ai" in the transactions grid. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +@code { + private const int MaxRows = 500; + + private List items = []; + private Dictionary suggestions = []; + private List accounts = []; + private List categoryChoices = []; + private IList selected = []; + private Guid? bulkCategoryId; + + private IEnumerable SelectedWithSuggestion => + selected.Where(transaction => suggestions.ContainsKey(transaction.Id)); + + protected override async Task OnInitializedAsync() + { + accounts = (await QuerySession.Query().ToListAsync()).ToList(); + var categories = await QuerySession.Query().ToListAsync(); + categoryChoices = CategoryChoice.Build(categories.ToList()); + await ReloadAsync(); + } + + private async Task ReloadAsync() + { + items = ( + await QuerySession + .Query() + .Where(t => t.CategoryId == null && t.TransferCounterpartId == null) + .OrderByDescending(t => t.BookingDate) + .Take(MaxRows) + .ToListAsync() + ).ToList(); + + var ids = items.Select(t => t.Id).ToArray(); + suggestions = ( + await QuerySession.Query().Where(s => s.Id.IsOneOf(ids)).ToListAsync() + ).ToDictionary(s => s.Id); + selected = []; + } + + private string AccountName(Guid accountId) => + accounts.FirstOrDefault(a => a.Id == accountId)?.Name ?? "?"; + + private string CategoryLabel(Guid categoryId) => + categoryChoices.FirstOrDefault(c => c.Id == categoryId)?.Label ?? "?"; + + private async Task AcceptOneAsync(Guid transactionId) => + await RunAsync(new ApplyCategorySuggestionsCommand([transactionId]), "Suggestion accepted."); + + private async Task AcceptSelectedAsync() => + await RunAsync( + new ApplyCategorySuggestionsCommand(SelectedWithSuggestion.Select(t => t.Id).ToList()), + "Suggestions accepted." + ); + + private async Task DismissSelectedAsync() => + await RunAsync( + new DismissCategorySuggestionsCommand(SelectedWithSuggestion.Select(t => t.Id).ToList()), + "Suggestions dismissed." + ); + + private async Task BulkCategorizeAsync() + { + if (bulkCategoryId is not Guid categoryId) + { + return; + } + + foreach (var transaction in selected) + { + var result = await MessageBus.InvokeAsync( + new CategorizeTransactionCommand(transaction.Id, categoryId) + ); + if (result.IsFailure) + { + NotificationService.Notify(NotificationSeverity.Error, "Failed", result.Error); + break; + } + } + await ReloadAsync(); + } + + private async Task RunAsync(object command, string successMessage) + { + var result = await MessageBus.InvokeAsync(command); + if (result.IsFailure) + { + NotificationService.Notify(NotificationSeverity.Error, "Failed", result.Error); + } + else + { + NotificationService.Notify(NotificationSeverity.Success, "Done", successMessage); + } + await ReloadAsync(); + } + + private sealed record CategoryChoice(Guid Id, string Label) + { + public static List Build(IReadOnlyList categories) + { + var byId = categories.ToDictionary(c => c.Id); + return categories + .OrderBy(c => byId.TryGetValue(c.ParentId ?? c.Id, out var p) ? p.SortOrder : 0) + .ThenBy(c => c.ParentId is null ? -1 : c.SortOrder) + .Select(c => new CategoryChoice(c.Id, CategoryPaths.PathOf(c, byId))) + .ToList(); + } + } +} diff --git a/src/FinanceApp/Components/Settings/Pages/Credentials.razor b/src/FinanceApp/Components/Settings/Pages/Credentials.razor new file mode 100644 index 0000000..660a4fd --- /dev/null +++ b/src/FinanceApp/Components/Settings/Pages/Credentials.razor @@ -0,0 +1,78 @@ +@page "/settings/credentials" +@attribute [Authorize] + +@inject ICredentialStore CredentialStore +@inject IMessageBus MessageBus +@inject NotificationService NotificationService + +API Keys – Finance + +
+ + + API Keys + + Secrets are stored encrypted in the database (DataProtection) and never + shown again after saving. Wise and Enable Banking credentials arrive with Phase 3. + + + + + + Claude API key + @if (claudeInfo is not null) + { + + } + else + { + + } + + + Used for AI categorization of imported transactions. Without a key, + uncategorized transactions simply stay in the review queue. + + + + + + + + + +
+ +@code { + private CredentialInfo? claudeInfo; + private string claudeKeyInput = ""; + + protected override async Task OnInitializedAsync() + { + claudeInfo = await CredentialStore.GetInfoAsync(CredentialKeys.ClaudeApiKey); + } + + private async Task SaveClaudeKeyAsync() + { + var result = await MessageBus.InvokeAsync( + new SaveProviderCredentialCommand(CredentialKeys.ClaudeApiKey, claudeKeyInput) + ); + if (result.IsFailure) + { + NotificationService.Notify(NotificationSeverity.Error, "Saving failed", result.Error); + return; + } + + claudeKeyInput = ""; + claudeInfo = await CredentialStore.GetInfoAsync(CredentialKeys.ClaudeApiKey); + NotificationService.Notify(NotificationSeverity.Success, "Saved", "Claude API key stored encrypted."); + } +} diff --git a/src/FinanceApp/Components/Settings/Pages/Rules.razor b/src/FinanceApp/Components/Settings/Pages/Rules.razor new file mode 100644 index 0000000..61e6ebf --- /dev/null +++ b/src/FinanceApp/Components/Settings/Pages/Rules.razor @@ -0,0 +1,162 @@ +@page "/settings/rules" +@attribute [Authorize] + +@inject IQuerySession QuerySession +@inject IMessageBus MessageBus +@inject NotificationService NotificationService + +Rules – Finance + +
+ + + Categorization rules + + Rules run before the AI on every import. All set conditions must match + (text matching is case-insensitive "contains"). + + + + + + + + + + + + + + + + + + + + + + + + Add rule + + + + + + + + + + + + + + + + + + + + + + +
+ +@code { + private List rules = []; + private List categoryChoices = []; + private InputModel form = new(); + + protected override async Task OnInitializedAsync() + { + var categories = await QuerySession.Query().ToListAsync(); + categoryChoices = CategoryChoice.Build(categories.ToList()); + await ReloadAsync(); + } + + private async Task ReloadAsync() + { + rules = ( + await QuerySession + .Query() + .OrderByDescending(r => r.CreatedAt) + .ToListAsync() + ).ToList(); + } + + private string CategoryLabel(Guid categoryId) => + categoryChoices.FirstOrDefault(c => c.Id == categoryId)?.Label ?? "?"; + + private async Task CreateAsync() + { + if (form.CategoryId is not Guid categoryId) + { + NotificationService.Notify(NotificationSeverity.Warning, "Missing category", "Pick a category first."); + return; + } + + var result = await MessageBus.InvokeAsync>( + new CreateCategoryRuleCommand( + categoryId, + form.Counterparty, + form.Description, + form.MinAmount, + form.MaxAmount, + CategoryRuleSource.Manual + ) + ); + if (result.IsFailure) + { + NotificationService.Notify(NotificationSeverity.Error, "Creating failed", result.Error); + return; + } + + form = new InputModel(); + await ReloadAsync(); + } + + private async Task DeleteAsync(Guid ruleId) + { + var result = await MessageBus.InvokeAsync(new DeleteCategoryRuleCommand(ruleId)); + if (result.IsFailure) + { + NotificationService.Notify(NotificationSeverity.Error, "Delete failed", result.Error); + return; + } + await ReloadAsync(); + } + + private sealed class InputModel + { + public Guid? CategoryId { get; set; } + public string Counterparty { get; set; } = ""; + public string Description { get; set; } = ""; + public decimal? MinAmount { get; set; } + public decimal? MaxAmount { get; set; } + } + + private sealed record CategoryChoice(Guid Id, string Label) + { + public static List Build(IReadOnlyList categories) + { + var byId = categories.ToDictionary(c => c.Id); + return categories + .OrderBy(c => byId.TryGetValue(c.ParentId ?? c.Id, out var p) ? p.SortOrder : 0) + .ThenBy(c => c.ParentId is null ? -1 : c.SortOrder) + .Select(c => new CategoryChoice(c.Id, CategoryPaths.PathOf(c, byId))) + .ToList(); + } + } +} diff --git a/src/FinanceApp/Components/Transactions/Pages/Transactions.razor b/src/FinanceApp/Components/Transactions/Pages/Transactions.razor index e8d7870..f3a884d 100644 --- a/src/FinanceApp/Components/Transactions/Pages/Transactions.razor +++ b/src/FinanceApp/Components/Transactions/Pages/Transactions.razor @@ -12,6 +12,20 @@ Transactions + @if (ruleOffer is not null) + { + ruleOffer = null)> + + + Always categorize @ruleOffer.Counterparty as + @ruleOffer.CategoryLabel? + + + ruleOffer = null) /> + + + } + @@ -133,6 +147,7 @@ private List transactions = []; private IList selected = []; + private RuleOffer? ruleOffer; private Guid? accountFilter; private AccountOwner? ownerFilter; private Guid? categoryFilter; @@ -213,9 +228,50 @@ NotificationService.Notify(NotificationSeverity.Error, "Categorization failed", result.Error); return; } + + // Corrections feed rule learning: offer a counterparty rule so the next + // import categorizes this merchant without the AI. + if (!string.IsNullOrWhiteSpace(transaction.Counterparty)) + { + var label = categoryChoices.FirstOrDefault(c => c.Id == newCategoryId)?.Label ?? "?"; + ruleOffer = new RuleOffer(transaction.Counterparty, newCategoryId, label); + } + await ReloadAsync(); } + private async Task CreateRuleFromOfferAsync() + { + if (ruleOffer is null) + { + return; + } + + var result = await MessageBus.InvokeAsync>( + new CreateCategoryRuleCommand( + ruleOffer.CategoryId, + ruleOffer.Counterparty, + DescriptionContains: null, + MinAmount: null, + MaxAmount: null, + CategoryRuleSource.LearnedFromCorrection + ) + ); + if (result.IsFailure) + { + NotificationService.Notify(NotificationSeverity.Error, "Rule creation failed", result.Error); + } + else + { + NotificationService.Notify( + NotificationSeverity.Success, + "Rule created", + $"'{ruleOffer.Counterparty}' → {ruleOffer.CategoryLabel}" + ); + } + ruleOffer = null; + } + private async Task LinkSelectedAsTransferAsync() { if (selected.Count != 2) @@ -246,6 +302,8 @@ await ReloadAsync(); } + private sealed record RuleOffer(string Counterparty, Guid CategoryId, string CategoryLabel); + private sealed record CategoryChoice(Guid Id, string Label) { public static List Build(IReadOnlyList categories) diff --git a/src/FinanceApp/_Imports.razor b/src/FinanceApp/_Imports.razor index 7c9da93..8e5cc5a 100644 --- a/src/FinanceApp/_Imports.razor +++ b/src/FinanceApp/_Imports.razor @@ -17,7 +17,10 @@ @using FinanceApp.Domain.Imports @using FinanceApp.Domain.Providers @using FinanceApp.Domain.Transactions +@using FinanceApp.Domain.Credentials @using FinanceApp.Connectors.Parsing +@using FinanceApp.Categorization +@using FinanceApp.Categorization.Suggestions @using Marten @using Wolverine @using AndreGoepel.AppFoundation diff --git a/src/FinanceApp/packages.lock.json b/src/FinanceApp/packages.lock.json index 6623283..fda543e 100644 --- a/src/FinanceApp/packages.lock.json +++ b/src/FinanceApp/packages.lock.json @@ -678,7 +678,12 @@ } }, "financeapp.categorization": { - "type": "Project" + "type": "Project", + "dependencies": { + "FinanceApp.Domain": "[1.0.0, )", + "Marten": "[9.12.0, )", + "WolverineFx.Marten": "[6.16.0, )" + } }, "financeapp.connectors": { "type": "Project", diff --git a/tests/FinanceApp.Categorization.Tests/Claude/ClaudeCategorizer.Tests.cs b/tests/FinanceApp.Categorization.Tests/Claude/ClaudeCategorizer.Tests.cs new file mode 100644 index 0000000..b8e24d1 --- /dev/null +++ b/tests/FinanceApp.Categorization.Tests/Claude/ClaudeCategorizer.Tests.cs @@ -0,0 +1,242 @@ +using System.Net; +using FinanceApp.Categorization.Claude; +using FinanceApp.Domain.Credentials; +using NSubstitute; + +namespace FinanceApp.Categorization.Tests.Claude; + +public class ClaudeCategorizerTests +{ + private static readonly Guid CategoryA = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + private static readonly Guid TransactionA = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + private static string ToolUseResponse(string categorizationsJson) => + $$""" + { + "content": [ + { "type": "text", "text": "thinking" }, + { + "type": "tool_use", + "name": "categorize_transactions", + "input": { "categorizations": {{categorizationsJson}} } + } + ] + } + """; + + #region ParseResponse + + [Fact] + public void ParseResponse_ValidToolUse_ReturnsSuggestions() + { + // Arrange + var json = ToolUseResponse( + $$"""[{ "transaction_id": "{{TransactionA}}", "category_id": "{{CategoryA}}", "confidence": 0.93 }]""" + ); + + // Act + var result = ClaudeCategorizer.ParseResponse(json, [CategoryA]); + + // Assert + Assert.True(result.IsSuccess); + var suggestion = Assert.Single(result.Value!); + Assert.Equal(TransactionA, suggestion.TransactionId); + Assert.Equal(CategoryA, suggestion.CategoryId); + Assert.Equal(0.93m, suggestion.Confidence); + } + + [Fact] + public void ParseResponse_UnknownCategoryId_BecomesNull() + { + // Arrange + var json = ToolUseResponse( + $$"""[{ "transaction_id": "{{TransactionA}}", "category_id": "{{Guid.NewGuid()}}", "confidence": 0.9 }]""" + ); + + // Act + var result = ClaudeCategorizer.ParseResponse(json, [CategoryA]); + + // Assert + Assert.Null(Assert.Single(result.Value!).CategoryId); + } + + [Fact] + public void ParseResponse_ConfidenceOutsideRange_IsClamped() + { + // Arrange + var json = ToolUseResponse( + $$"""[{ "transaction_id": "{{TransactionA}}", "category_id": "{{CategoryA}}", "confidence": 1.7 }]""" + ); + + // Act + var result = ClaudeCategorizer.ParseResponse(json, [CategoryA]); + + // Assert + Assert.Equal(1m, Assert.Single(result.Value!).Confidence); + } + + [Fact] + public void ParseResponse_WithoutToolUseBlock_Fails() + { + // Act + var result = ClaudeCategorizer.ParseResponse( + """{ "content": [ { "type": "text", "text": "sorry" } ] }""", + [CategoryA] + ); + + // Assert + Assert.True(result.IsFailure); + Assert.Contains("no structured", result.Error); + } + + [Fact] + public void ParseResponse_InvalidJson_Fails() + { + // Act + var result = ClaudeCategorizer.ParseResponse("not json", [CategoryA]); + + // Assert + Assert.True(result.IsFailure); + } + + #endregion + + #region BuildSystemPrompt / BuildRequestBody + + [Fact] + public void BuildSystemPrompt_ContainsCategoriesAndExamples() + { + // Act + var prompt = ClaudeCategorizer.BuildSystemPrompt( + [new CategoryOption(CategoryA, "Living › Groceries")], + [new FewShotExample("REWE", "REWE SAGT DANKE", -23.45m, "Living › Groceries")] + ); + + // Assert + Assert.Contains(CategoryA.ToString("D"), prompt); + Assert.Contains("Living › Groceries", prompt); + Assert.Contains("REWE SAGT DANKE", prompt); + } + + [Fact] + public void BuildRequestBody_ForcesToolChoiceWithTemperatureZero() + { + // Act + var body = ClaudeCategorizer.BuildRequestBody( + [new TransactionToCategorize(TransactionA, "REWE", "Einkauf", -12.34m, "EUR")], + [new CategoryOption(CategoryA, "Living › Groceries")], + [] + ); + + // Assert + Assert.Equal(ClaudeCategorizer.Model, body["model"]!.GetValue()); + Assert.Equal(0, body["temperature"]!.GetValue()); + Assert.Equal("tool", body["tool_choice"]!["type"]!.GetValue()); + Assert.Contains(TransactionA.ToString("D"), body.ToJsonString()); + } + + #endregion + + #region SuggestAsync + + [Fact] + public async Task SuggestAsync_WithoutApiKey_FailsGracefully() + { + // Arrange + var categorizer = BuildCategorizer(apiKey: null, HttpStatusCode.OK, "{}"); + + // Act + var result = await categorizer.SuggestAsync( + [new TransactionToCategorize(TransactionA, null, "x", -1m, "EUR")], + [new CategoryOption(CategoryA, "A")], + [] + ); + + // Assert + Assert.True(result.IsFailure); + Assert.Contains("No Claude API key", result.Error); + } + + [Fact] + public async Task SuggestAsync_SuccessResponse_ReturnsSuggestions() + { + // Arrange + var response = ToolUseResponse( + $$"""[{ "transaction_id": "{{TransactionA}}", "category_id": "{{CategoryA}}", "confidence": 0.95 }]""" + ); + var categorizer = BuildCategorizer("sk-ant-test", HttpStatusCode.OK, response); + + // Act + var result = await categorizer.SuggestAsync( + [new TransactionToCategorize(TransactionA, "REWE", "Einkauf", -12.34m, "EUR")], + [new CategoryOption(CategoryA, "Living › Groceries")], + [] + ); + + // Assert + Assert.True(result.IsSuccess); + Assert.Equal(CategoryA, Assert.Single(result.Value!).CategoryId); + } + + [Fact] + public async Task SuggestAsync_ApiError_FailsWithStatusCode() + { + // Arrange + var categorizer = BuildCategorizer("sk-ant-test", HttpStatusCode.Unauthorized, "{}"); + + // Act + var result = await categorizer.SuggestAsync( + [new TransactionToCategorize(TransactionA, null, "x", -1m, "EUR")], + [new CategoryOption(CategoryA, "A")], + [] + ); + + // Assert + Assert.True(result.IsFailure); + Assert.Contains("401", result.Error); + } + + [Fact] + public async Task SuggestAsync_EmptyBatch_ReturnsEmptyWithoutApiCall() + { + // Arrange + var categorizer = BuildCategorizer(apiKey: null, HttpStatusCode.OK, "{}"); + + // Act + var result = await categorizer.SuggestAsync([], [], []); + + // Assert + Assert.True(result.IsSuccess); + Assert.Empty(result.Value!); + } + + private static ClaudeCategorizer BuildCategorizer( + string? apiKey, + HttpStatusCode statusCode, + string responseBody + ) + { + var credentials = Substitute.For(); + credentials + .GetSecretAsync(CredentialKeys.ClaudeApiKey, Arg.Any()) + .Returns(apiKey); + var httpClient = new HttpClient(new FakeHandler(statusCode, responseBody)) + { + BaseAddress = new Uri("https://api.anthropic.example/"), + }; + return new ClaudeCategorizer(httpClient, credentials); + } + + private sealed class FakeHandler(HttpStatusCode statusCode, string body) : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) => + Task.FromResult( + new HttpResponseMessage(statusCode) { Content = new StringContent(body) } + ); + } + + #endregion +} diff --git a/tests/FinanceApp.Categorization.Tests/Rules/RuleMatcher.Tests.cs b/tests/FinanceApp.Categorization.Tests/Rules/RuleMatcher.Tests.cs new file mode 100644 index 0000000..877e561 --- /dev/null +++ b/tests/FinanceApp.Categorization.Tests/Rules/RuleMatcher.Tests.cs @@ -0,0 +1,101 @@ +using FinanceApp.Categorization.Rules; +using FinanceApp.Domain.Categories; + +namespace FinanceApp.Categorization.Tests.Rules; + +public class RuleMatcherTests +{ + private static CategoryRule Rule( + string? counterparty = null, + string? description = null, + decimal? min = null, + decimal? max = null, + Guid? categoryId = null, + DateTimeOffset? createdAt = null + ) => + new() + { + CategoryId = categoryId ?? Guid.NewGuid(), + CounterpartyContains = counterparty, + DescriptionContains = description, + MinAmount = min, + MaxAmount = max, + Source = CategoryRuleSource.Manual, + CreatedAt = createdAt ?? DateTimeOffset.UtcNow, + }; + + [Fact] + public void Matches_CounterpartyContains_IsCaseInsensitive() + { + // Arrange + var rule = Rule(counterparty: "rewe"); + + // Act + Assert + Assert.True(RuleMatcher.Matches(rule, "REWE Markt GmbH", "any", -10m)); + Assert.False(RuleMatcher.Matches(rule, "Lidl", "any", -10m)); + Assert.False(RuleMatcher.Matches(rule, null, "any", -10m)); + } + + [Fact] + public void Matches_AmountBounds_AreInclusive() + { + // Arrange + var rule = Rule(description: "abo", min: -50m, max: -10m); + + // Act + Assert + Assert.True(RuleMatcher.Matches(rule, null, "Abo Monat", -10m)); + Assert.True(RuleMatcher.Matches(rule, null, "Abo Monat", -50m)); + Assert.False(RuleMatcher.Matches(rule, null, "Abo Monat", -9.99m)); + Assert.False(RuleMatcher.Matches(rule, null, "Abo Monat", -50.01m)); + } + + [Fact] + public void Matches_RuleWithoutTextCondition_NeverMatches() + { + // Arrange + var rule = Rule(min: -100m, max: 0m); + + // Act + Assert + Assert.False(RuleMatcher.Matches(rule, "anything", "anything", -10m)); + } + + [Fact] + public void FindMatch_MoreSpecificRuleWins() + { + // Arrange + var broad = Rule(counterparty: "amazon"); + var specific = Rule(counterparty: "amazon", description: "prime", min: -20m); + + // Act + var match = RuleMatcher.FindMatch( + [broad, specific], + "AMAZON EU", + "Amazon Prime Abo", + -8.99m + ); + + // Assert + Assert.Same(specific, match); + } + + [Fact] + public void FindMatch_TieBreaksOnNewestRule() + { + // Arrange + var older = Rule(counterparty: "spotify", createdAt: DateTimeOffset.UtcNow.AddDays(-2)); + var newer = Rule(counterparty: "spotify", createdAt: DateTimeOffset.UtcNow); + + // Act + var match = RuleMatcher.FindMatch([older, newer], "Spotify AB", "Premium", -9.99m); + + // Assert + Assert.Same(newer, match); + } + + [Fact] + public void FindMatch_NoMatchingRule_ReturnsNull() + { + // Act + Assert + Assert.Null(RuleMatcher.FindMatch([Rule(counterparty: "rewe")], "Lidl", "Einkauf", -5m)); + } +} diff --git a/tests/FinanceApp.Categorization.Tests/packages.lock.json b/tests/FinanceApp.Categorization.Tests/packages.lock.json index 208d638..60d6903 100644 --- a/tests/FinanceApp.Categorization.Tests/packages.lock.json +++ b/tests/FinanceApp.Categorization.Tests/packages.lock.json @@ -50,6 +50,59 @@ "System.Diagnostics.EventLog": "6.0.0" } }, + "DistributedLock.Core": { + "type": "Transitive", + "resolved": "1.0.8", + "contentHash": "LAOsY8WxX8JU/n3lfXFz+f2pfnv0+4bHkCrOO3bwa28u9HrS3DlxSG6jf+u76SqesKs+KehZi0CndkfaUXBKvg==" + }, + "DistributedLock.Postgres": { + "type": "Transitive", + "resolved": "1.3.0", + "contentHash": "CyjXmbhFgG30qPg4DwGnsmZO63y5DBRo+PI2xW0NwtFrsYsMVt/T1RcEUlb36JMKhDkf+euhnkanv/nU+W35qA==", + "dependencies": { + "DistributedLock.Core": "[1.0.8, 1.1.0)", + "Npgsql": "8.0.6" + } + }, + "FastExpressionCompiler": { + "type": "Transitive", + "resolved": "5.4.1", + "contentHash": "nqMykMspK5cQd35Y8ulQzAujxCcCwZVUne32pC5xNpXqrbPCuKOPMWkKHJ3LveAZPm6dsVvump0TJ1SZ9RzfTQ==" + }, + "ImTools": { + "type": "Transitive", + "resolved": "4.0.0", + "contentHash": "dms0JfLKC9AQbQX/EdpRjn59EjEvaCxIgv1z9YxbEQb5SiOVoo7vlIKdEySeEwUkWhN05rZnNpUpG+aw5VqPVQ==" + }, + "JasperFx": { + "type": "Transitive", + "resolved": "2.18.1", + "contentHash": "i8k7awEkuDdZV4kUzIQT54h5pydEzBpH4CjD5ds7b7dHxK0KYvIyKWDWZqMHHJVbVqXEQHtQHQNTeqUi2zOLZw==", + "dependencies": { + "FastExpressionCompiler": "5.4.1", + "ImTools": "4.0.0", + "Microsoft.Bcl.TimeProvider": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting": "10.0.0", + "Microsoft.Extensions.ObjectPool": "10.0.0", + "Polly.Core": "8.6.5", + "Spectre.Console": "0.55.0" + } + }, + "JasperFx.Events": { + "type": "Transitive", + "resolved": "2.18.1", + "contentHash": "kBK14ziH5UV7jYrsclg6KPpB8F3vmCXhkMDWw+oPnBaxYC4xeB/5STrt+gn6V4VlbR1Xn/wcd9ZVAx3teDls9w==", + "dependencies": { + "JasperFx": "2.18.1" + } + }, + "JasperFx.SourceGenerator": { + "type": "Transitive", + "resolved": "2.16.0", + "contentHash": "wwjl3taQ1Z40ESBTIAYwf88nms3sXp6osyAzIAjXugrUCj3D4AIYEYQGxHAPF6Yk6o6Roouw/pnMqJuKS2jM0g==" + }, "Microsoft.ApplicationInsights": { "type": "Transitive", "resolved": "2.23.0", @@ -60,11 +113,325 @@ "resolved": "6.0.0", "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" }, + "Microsoft.Bcl.TimeProvider": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "bUubrBD6tRJE3V1kvRloYc6NymH3R5oFKjAS9e0ELNx6u0ZR+zjps9dDQyjgqN/rArzl7f+21KGszj3YRN7F2Q==" + }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "18.7.0", "contentHash": "+wFfx9s7D9wegM0RziXMj2kvYDT4qcqXXtyjiQwSZOGQ2wwcOAJQcD6eQXk02jt0MvRNawtp8TJxTrV+wD8X1g==" }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "Zcoy6H9mSoGyvr7UvlGokEZrlZkcPCICPZr8mCsSt9U/N8eeCwCXwKF5bShdA66R0obxBCwP4AxomQHvVkC/uA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "krK19MKp0BNiR9rpBDW7PKSrTMLVlifS9am3CVc4O1Jq6GWz0o4F+sw5OSL4L3mVd56W8l6JRgghUa2KB51vOw==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "woZsWLhOQsASuxbmgiZJqiGUBNo3IjRdXC92xt8rRokza+P6/nIsnzq7sm9Or6ZYcRl2kL1ufj8HVzp1QlPTXw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "qGhRPd3VxfLV9UqatVOiD9mAeUbj2KiMwGFYC5uXlzExiZQoe4X/hdmzGIU7BQjNLTqCnnbTHVyBglG3668/HA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Tp/+LPb70RyjjtLg9m5C959eP4KrUpJHThZfAegZVpsfmGvzfuNkuYbI/ft+LvXhMSyUcAeOPaN6rzTccwnZAg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "CRj5clwZciVs46GMhAthkFq3+JiNM15Bz9CRlCZLBmRdggD6RwoBphRJ+EUDK2f+cZZ1L2zqVaQrn1KueoU5Kg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "TmFegsI/uCdwMBD4yKpmO+OkjVNHQL49Dh/ep83NI5rPUEoBK9OdsJo1zURc1A2FuS/R/Pos3wsTjlyLnguBLA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "LqCTyF0twrG4tyEN6PpSC5ewRBDwCBazRUfCOdRddwaQ3n2S57GDDeYOlTLcbV/V2dxSSZWg5Ofr48h6BsBmxw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BIOPTEAZoeWbHlDT9Zudu+rpecZizFwhdIFRiyZKDml7JbayXmfTXKUt+ezifsSXfBkWDdJM10oDOxo8pufEng==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "B4qHB6gQ2B3I52YRohSV7wetp01BQzi8jDmrtiVm6e4l8vH5vjqwxWcR5wumGWjdBkj1asJLLsDIocdyTQSP0A==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Json": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "NijozhERJDIaJ4k5TSMy1jOi0cSC2HfkvRD/Sl+kGSSKgVbFnF4GxgtMN/MrzHB8D1JxIrD4xSer9Blh9v3axQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "NLXI3PbTe39q6/sgs7JYhmfPf7bMzReUoAJ0q9Po6yhfM+0anZa7PrEva4W2SdiLWGyB9eKZS9THGt2BP40xJg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "86RgyFsmVslW4Nu28IXgt8tLglynGQrwjk/xhGZaTe8j6YIeR1Ywoc42hSHsBSl920CQdfqq2dBohZiGm3AkUA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "jAhZbzDa117otUBMuQQ6JzSfuDbBBrfOs5jw5l7l9hKpzt+LjYKVjSauXG2yV9u7BqUSLUtKLwcerDQDeQ+0Xw==" + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "UZUQ74lQMmvcprlG8w+XpxBbyRDQqfb7GAnccITw32hdkUBlmm9yNC4xl4aR9YjgV3ounZcub194sdmLSfBmPA==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "5hfVl/e+bx1px2UkN+1xXhd3hu7Ui6ENItBzckFaRDQXfr+SHT/7qrCDrlQekCF/PBtEu2vtk87U2+gDEF8EhQ==" + }, + "Microsoft.Extensions.Hosting": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "yKJiVdXkSfe9foojGpBRbuDPQI8YD71IO/aE8ehGjRHE0VkEF/YWkW6StthwuFF146pc2lypZrpk/Tks6Plwhw==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.0", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.0", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0", + "Microsoft.Extensions.Configuration.Json": "10.0.0", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.0", + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Physical": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "Microsoft.Extensions.Logging.Console": "10.0.0", + "Microsoft.Extensions.Logging.Debug": "10.0.0", + "Microsoft.Extensions.Logging.EventLog": "10.0.0", + "Microsoft.Extensions.Logging.EventSource": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "N7Gm9SjugYjmmnhwbBKC9DFqGqjfJvh6YfOJgtwh0AW0Xpok3dIVors1ik050XmUxKAgAc7nNngDIJyFb06K2g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "9S/DFt4cohlMPpzIxjG6kk0L8MuN2vDm9pbMCulxtJzzk82oJHVLBd8vuQxaPskaYQwKqmFmbannf5eoChgjYg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "j8zcwhS6bYB6FEfaY3nYSgHdpiL2T+/V3xjpHtslVAegyI1JUbB9yAt/BFdvZdsNbY0Udm4xFtvfT/hUwcOOOg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "treWetuksp8LVb09fCJ5zNhNJjyDkqzVm83XxcrlWQnAdXznR140UUXo8PyEPBvFlHhjKhFQZEOP3Sk/ByCvEw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "A/4vBtVaySLBGj4qluye+KSbeVCCMa6GcTbxf2YgnSDHs9b9105+VojBJ1eJPel8F1ny0JOh+Ci3vgCKn69tNQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "EWda5nSXhzQZr3yJ3+XgIApOek+Hm+txhWCEzWNVPp/OfimL4qmvctgXu87m+S2RXw/AoUP8aLMNicJ2KWblVA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "System.Diagnostics.EventLog": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "+Qc+kgoJi1w2A/Jm+7h04LcK2JoJkwAxKg7kBakkNRcemTmRGocqPa7rVNVGorTYruFrUS25GwkFNtOECnjhXg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "10.0.2", + "contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "hyNdX4c2UwkRkzb9byw0H2DQkRzwBM3mzY2sCM9egwzTyg8dvQJmp5noQHGEaaCORQrNK3DD2gREBsc2DlXS4A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "Y4E24zffF/aPS0igNvY6ZzAQfbxd6AYdC9L4brnH+uK0yYYHIR6FeGVQVVjAOo8wub1EQDl2B90lCcpqoTF7Yw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Primitives": "10.0.9" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.9", + "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" + }, + "Microsoft.IO.RecyclableMemoryStream": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "s/s20YTVY9r9TPfTrN5g8zPF1YhwxyqO6PxUkrYTGI2B+OGPe9AdajWZrLhFqXIvqIW23fnUE4+ztrUWNU1+9g==" + }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "1.9.1", @@ -114,15 +481,127 @@ "resolved": "5.0.0", "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" }, + "NetTopologySuite": { + "type": "Transitive", + "resolved": "2.5.0", + "contentHash": "5/+2O2ADomEdUn09mlSigACdqvAf0m/pVPGtIPEPQWnyrVykYY0NlfXLIdkMgi41kvH9kNrPqYaFBTZtHYH7Xw==" + }, + "NetTopologySuite.IO.PostGis": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "3W8XTFz8iP6GQ5jDXK1/LANHiU+988k1kmmuPWNKcJLpmSg6CvFpbTpz+s4+LBzkAp64wHGOldSlkSuzYfrIKA==", + "dependencies": { + "NetTopologySuite": "[2.0.0, 3.0.0-A)" + } + }, + "NewId": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "jegBasNndmG21G3BmT51suFDCIz/sLN81j+IxmkZ4iWoaDi8LygeHiyNnYbXz5OQh9nCRJFIx1+PJrlYi1Gc9Q==" + }, "Newtonsoft.Json": { "type": "Transitive", "resolved": "13.0.3", "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" }, + "Npgsql": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "68BASXH0FAEuL/J4J0eRfYC8/3vzqQTmoW8zDzNf0JgaVxc7LZeEkS6jaG0ib3voFLxY5ZiCwJG+uQM+mzuu0Q==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.2" + } + }, + "Npgsql.NetTopologySuite": { + "type": "Transitive", + "resolved": "9.0.4", + "contentHash": "7AwBLPz8EPmgAXveZ55K0Tz/9bNb8j4VI4pkTOQjyHrce8wWfAA9pefm3REidy+Kt2UFiAWef34YVi7sb/kB4A==", + "dependencies": { + "NetTopologySuite": "2.5.0", + "NetTopologySuite.IO.PostGIS": "2.1.0", + "Npgsql": "9.0.4" + } + }, + "Polly.Core": { + "type": "Transitive", + "resolved": "8.6.5", + "contentHash": "t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg==" + }, + "Spectre.Console": { + "type": "Transitive", + "resolved": "0.55.0", + "contentHash": "2TVhUHFsDux0Z+y2Hv4bFsOMuON4SuotEEKkMparFkp5Rm259naaaEQrN4Sv6C1cbsiGKPjfkJDGgUp+hZPjTw==", + "dependencies": { + "Spectre.Console.Ansi": "0.55.0" + } + }, + "Spectre.Console.Ansi": { + "type": "Transitive", + "resolved": "0.55.0", + "contentHash": "GIfgOvuF8MpU52bprhwtDEtx0gmVIGOepM8bw0lH/TSMD0rg1Xp+5Y53kPG8rFVdcpbNANlhDQ5mEVVPO3/2Tg==" + }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + "resolved": "10.0.0", + "contentHash": "uaFRda9NjtbJRkdx311eXlAA3n2em7223c1A8d1VWyl+4FL9vkG7y2lpPfBU9HYdj/9KgdRNdn1vFK8ZYCYT/A==" + }, + "Weasel.Core": { + "type": "Transitive", + "resolved": "9.3.0", + "contentHash": "Lrf6a0N8T89q2z4IaBF6413zzQsABe+LvL9D5K5ig8JjpU7C1gOcgwPEeldpdxcj3q7mSaYx9LKW72TC2wLpbw==", + "dependencies": { + "JasperFx": "2.13.1" + } + }, + "Weasel.Postgresql": { + "type": "Transitive", + "resolved": "9.3.0", + "contentHash": "vbcpQFemPq88d8pM6Mcq8c/PkcNMjpMdal/AGThqsMliNnIVTiHFdPypSzBUthHTv8UV8fxF+ze9C7PHtq1Rrw==", + "dependencies": { + "DistributedLock.Postgres": "1.3.0", + "JasperFx.Events": "2.0.0", + "Npgsql": "9.0.4", + "Npgsql.NetTopologySuite": "9.0.4", + "Weasel.Core": "9.3.0" + } + }, + "WolverineFx": { + "type": "Transitive", + "resolved": "6.16.0", + "contentHash": "zDNCik/B9eou+cGkpYmzOlRe+aXvzBMkEFDA0i7/DgLOncbbX12gLsugojwoLHPLUCI4Grwk4ymPj5+gYK410g==", + "dependencies": { + "JasperFx": "2.16.0", + "JasperFx.Events": "2.16.0", + "JasperFx.SourceGenerator": "2.16.0", + "Microsoft.Extensions.Caching.Memory": "10.0.0", + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.Configuration.Json": "10.0.0", + "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.0", + "Microsoft.Extensions.Hosting": "10.0.0", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.Extensions.ObjectPool": "10.0.2", + "NewId": "4.0.1" + } + }, + "WolverineFx.Postgresql": { + "type": "Transitive", + "resolved": "6.16.0", + "contentHash": "u3p2opC06FUYd3CQRiN4x/MSl3nbHbcpzxt69RSi7ZPq311g00wCyUJ4wHb+SSGN1a3yFqjihEoSXGvqXAThSA==", + "dependencies": { + "Weasel.Postgresql": "9.3.0", + "WolverineFx.RDBMS": "6.16.0" + } + }, + "WolverineFx.RDBMS": { + "type": "Transitive", + "resolved": "6.16.0", + "contentHash": "IYfSUZkSSwMGtcq85Pe2Nom0TWzLmSxmgNvHT8pLl4LmwBpgOWc2bVj1VYJFopS2qMZxTsWTQYnLfTIQN0CSdA==", + "dependencies": { + "Weasel.Core": "9.3.0", + "WolverineFx": "6.16.0" + } }, "xunit.analyzers": { "type": "Transitive", @@ -194,14 +673,70 @@ "financeapp.categorization": { "type": "Project", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )" + "FinanceApp.Domain": "[1.0.0, )", + "Marten": "[9.12.0, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", + "Microsoft.Extensions.Http": "[10.0.9, )", + "WolverineFx.Marten": "[6.16.0, )" } }, + "financeapp.domain": { + "type": "Project", + "dependencies": { + "Marten": "[9.12.0, )", + "Microsoft.AspNetCore.DataProtection.Abstractions": "[10.0.9, )", + "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", + "WolverineFx.Marten": "[6.16.0, )" + } + }, + "Marten": { + "type": "CentralTransitive", + "requested": "[9.12.0, )", + "resolved": "9.12.0", + "contentHash": "RIyPzIN6ehfDmBWPs7zyMryy13uOPZHQBA8QuBwO0PHjzpyonP/rBoJYrgiJGXcaiWFVxqHj3QfPKuLjXsA5uQ==", + "dependencies": { + "JasperFx": "2.18.1", + "JasperFx.Events": "2.18.1", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "Microsoft.IO.RecyclableMemoryStream": "3.0.1", + "Weasel.Postgresql": "9.3.0" + } + }, + "Microsoft.AspNetCore.DataProtection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "KZP7FpVQkP29U0icd2xNdnAy9zUfhqX6JmYzhjBAekVYltyPfAZwNkY9EY078d8+Silvt3kvuecspzlfmbtWvg==" + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", "requested": "[10.0.9, )", "resolved": "10.0.9", "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" + }, + "Microsoft.Extensions.Http": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "5jdmuLRg1HO7x/v8McOf6ndhkMQWImkRBtJpXM8+xIocXNdLqCItyyFLKusdILc62TDzHuLlqaWoxtOA5FFOPg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, + "WolverineFx.Marten": { + "type": "CentralTransitive", + "requested": "[6.16.0, )", + "resolved": "6.16.0", + "contentHash": "d7xWZTHwOxKRkS249JCbf/ot8/Mdk8CpD3oYj+mTvrKcT7G1tRRVAfAudJCjgozWgcuHOK+uH8o0Wn9tsKgYdQ==", + "dependencies": { + "Marten": "9.11.0", + "WolverineFx.Postgresql": "6.16.0" + } } } } diff --git a/tests/FinanceApp.Connectors.Tests/packages.lock.json b/tests/FinanceApp.Connectors.Tests/packages.lock.json index 41cdbd6..313669d 100644 --- a/tests/FinanceApp.Connectors.Tests/packages.lock.json +++ b/tests/FinanceApp.Connectors.Tests/packages.lock.json @@ -681,6 +681,7 @@ "type": "Project", "dependencies": { "Marten": "[9.12.0, )", + "Microsoft.AspNetCore.DataProtection.Abstractions": "[10.0.9, )", "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", "WolverineFx.Marten": "[6.16.0, )" } @@ -698,6 +699,12 @@ "Weasel.Postgresql": "9.3.0" } }, + "Microsoft.AspNetCore.DataProtection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "KZP7FpVQkP29U0icd2xNdnAy9zUfhqX6JmYzhjBAekVYltyPfAZwNkY9EY078d8+Silvt3kvuecspzlfmbtWvg==" + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", "requested": "[10.0.9, )", diff --git a/tests/FinanceApp.Domain.Tests/Categories/CreateCategoryRuleCommandHandler.Tests.cs b/tests/FinanceApp.Domain.Tests/Categories/CreateCategoryRuleCommandHandler.Tests.cs new file mode 100644 index 0000000..7a82dab --- /dev/null +++ b/tests/FinanceApp.Domain.Tests/Categories/CreateCategoryRuleCommandHandler.Tests.cs @@ -0,0 +1,75 @@ +using FinanceApp.Domain.Categories; +using Marten; +using NSubstitute; + +namespace FinanceApp.Domain.Tests.Categories; + +public class CreateCategoryRuleCommandHandlerTests +{ + private readonly IDocumentSession session = Substitute.For(); + + [Fact] + public async Task Handle_WithoutAnyTextPattern_Fails() + { + // Act + var result = await CreateCategoryRuleCommandHandler.Handle( + new CreateCategoryRuleCommand( + Guid.NewGuid(), + CounterpartyContains: " ", + DescriptionContains: null, + MinAmount: -100m, + MaxAmount: 0m, + CategoryRuleSource.Manual + ), + session, + CancellationToken.None + ); + + // Assert + Assert.True(result.IsFailure); + Assert.Contains("at least", result.Error); + } + + [Fact] + public async Task Handle_MinAboveMax_Fails() + { + // Act + var result = await CreateCategoryRuleCommandHandler.Handle( + new CreateCategoryRuleCommand( + Guid.NewGuid(), + "rewe", + null, + MinAmount: 10m, + MaxAmount: -10m, + CategoryRuleSource.Manual + ), + session, + CancellationToken.None + ); + + // Assert + Assert.True(result.IsFailure); + } + + [Fact] + public async Task Handle_UnknownCategory_Fails() + { + // Act + var result = await CreateCategoryRuleCommandHandler.Handle( + new CreateCategoryRuleCommand( + Guid.NewGuid(), + "rewe", + null, + null, + null, + CategoryRuleSource.Manual + ), + session, + CancellationToken.None + ); + + // Assert + Assert.True(result.IsFailure); + Assert.Contains("Category not found", result.Error); + } +} diff --git a/tests/FinanceApp.Domain.Tests/Credentials/MartenCredentialStore.Tests.cs b/tests/FinanceApp.Domain.Tests/Credentials/MartenCredentialStore.Tests.cs new file mode 100644 index 0000000..5a305e2 --- /dev/null +++ b/tests/FinanceApp.Domain.Tests/Credentials/MartenCredentialStore.Tests.cs @@ -0,0 +1,127 @@ +using FinanceApp.Domain.Credentials; +using Marten; +using Microsoft.AspNetCore.DataProtection; +using NSubstitute; + +namespace FinanceApp.Domain.Tests.Credentials; + +public class MartenCredentialStoreTests +{ + private readonly IDocumentStore store = Substitute.For(); + private readonly IDocumentSession session = Substitute.For(); + private readonly IQuerySession querySession = Substitute.For(); + private readonly EphemeralDataProtectionProvider dataProtection = new(); + private readonly MartenCredentialStore credentialStore; + + public MartenCredentialStoreTests() + { + store.LightweightSession().Returns(session); + store.QuerySession().Returns(querySession); + credentialStore = new MartenCredentialStore(store, dataProtection); + } + + [Fact] + public async Task SaveSecretAsync_NewCredential_StoresProtectedPayload() + { + // Arrange + ProviderCredential? stored = null; + session.Store(Arg.Do(credentials => stored = credentials.Single())); + + // Act + await credentialStore.SaveSecretAsync(CredentialKeys.ClaudeApiKey, "sk-ant-secret"); + + // Assert + Assert.NotNull(stored); + Assert.NotEqual("sk-ant-secret", stored.ProtectedPayload); + Assert.Equal( + "sk-ant-secret", + dataProtection + .CreateProtector($"FinanceApp.ProviderCredential.{CredentialKeys.ClaudeApiKey}") + .Unprotect(stored.ProtectedPayload) + ); + await session.Received(1).SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task SaveSecretAsync_ExistingCredential_RotatesPayload() + { + // Arrange + var existing = new ProviderCredential + { + Id = CredentialKeys.ClaudeApiKey, + ProtectedPayload = "old", + }; + session + .LoadAsync( + CredentialKeys.ClaudeApiKey, + Arg.Any() + ) + .Returns(existing); + + // Act + await credentialStore.SaveSecretAsync(CredentialKeys.ClaudeApiKey, "new-secret"); + + // Assert + Assert.NotNull(existing.RotatedAt); + Assert.NotEqual("old", existing.ProtectedPayload); + } + + [Fact] + public async Task GetSecretAsync_MissingCredential_ReturnsNull() + { + // Act + Assert + Assert.Null(await credentialStore.GetSecretAsync("unknown")); + } + + [Fact] + public async Task GetSecretAsync_StoredCredential_ReturnsDecryptedSecret() + { + // Arrange + var protector = dataProtection.CreateProtector( + $"FinanceApp.ProviderCredential.{CredentialKeys.ClaudeApiKey}" + ); + querySession + .LoadAsync( + CredentialKeys.ClaudeApiKey, + Arg.Any() + ) + .Returns( + new ProviderCredential + { + Id = CredentialKeys.ClaudeApiKey, + ProtectedPayload = protector.Protect("sk-ant-secret"), + } + ); + + // Act + var secret = await credentialStore.GetSecretAsync(CredentialKeys.ClaudeApiKey); + + // Assert + Assert.Equal("sk-ant-secret", secret); + } + + [Fact] + public async Task GetInfoAsync_NeverExposesThePayload() + { + // Arrange + querySession + .LoadAsync( + CredentialKeys.ClaudeApiKey, + Arg.Any() + ) + .Returns( + new ProviderCredential + { + Id = CredentialKeys.ClaudeApiKey, + ProtectedPayload = "protected", + } + ); + + // Act + var info = await credentialStore.GetInfoAsync(CredentialKeys.ClaudeApiKey); + + // Assert + Assert.NotNull(info); + Assert.Equal(CredentialKeys.ClaudeApiKey, info.Key); + } +} diff --git a/tests/FinanceApp.Domain.Tests/Credentials/SaveProviderCredentialCommandHandler.Tests.cs b/tests/FinanceApp.Domain.Tests/Credentials/SaveProviderCredentialCommandHandler.Tests.cs new file mode 100644 index 0000000..26efb04 --- /dev/null +++ b/tests/FinanceApp.Domain.Tests/Credentials/SaveProviderCredentialCommandHandler.Tests.cs @@ -0,0 +1,46 @@ +using FinanceApp.Domain.Credentials; +using NSubstitute; + +namespace FinanceApp.Domain.Tests.Credentials; + +public class SaveProviderCredentialCommandHandlerTests +{ + private readonly ICredentialStore store = Substitute.For(); + + [Fact] + public async Task Handle_ValidSecret_SavesTrimmed() + { + // Act + var result = await SaveProviderCredentialCommandHandler.Handle( + new SaveProviderCredentialCommand(CredentialKeys.ClaudeApiKey, " sk-ant-x "), + store, + CancellationToken.None + ); + + // Assert + Assert.True(result.IsSuccess); + await store + .Received(1) + .SaveSecretAsync(CredentialKeys.ClaudeApiKey, "sk-ant-x", Arg.Any()); + } + + [Theory] + [InlineData("", "secret")] + [InlineData("key", "")] + [InlineData("key", " ")] + public async Task Handle_MissingKeyOrSecret_Fails(string key, string secret) + { + // Act + var result = await SaveProviderCredentialCommandHandler.Handle( + new SaveProviderCredentialCommand(key, secret), + store, + CancellationToken.None + ); + + // Assert + Assert.True(result.IsFailure); + await store + .DidNotReceive() + .SaveSecretAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/tests/FinanceApp.Domain.Tests/FinanceApp.Domain.Tests.csproj b/tests/FinanceApp.Domain.Tests/FinanceApp.Domain.Tests.csproj index 3ad94d4..2e36cc5 100644 --- a/tests/FinanceApp.Domain.Tests/FinanceApp.Domain.Tests.csproj +++ b/tests/FinanceApp.Domain.Tests/FinanceApp.Domain.Tests.csproj @@ -6,6 +6,10 @@ false + + + + diff --git a/tests/FinanceApp.Domain.Tests/packages.lock.json b/tests/FinanceApp.Domain.Tests/packages.lock.json index 307f10c..72c7c07 100644 --- a/tests/FinanceApp.Domain.Tests/packages.lock.json +++ b/tests/FinanceApp.Domain.Tests/packages.lock.json @@ -674,6 +674,7 @@ "type": "Project", "dependencies": { "Marten": "[9.12.0, )", + "Microsoft.AspNetCore.DataProtection.Abstractions": "[10.0.9, )", "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", "WolverineFx.Marten": "[6.16.0, )" } @@ -691,6 +692,12 @@ "Weasel.Postgresql": "9.3.0" } }, + "Microsoft.AspNetCore.DataProtection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.9, )", + "resolved": "10.0.9", + "contentHash": "KZP7FpVQkP29U0icd2xNdnAy9zUfhqX6JmYzhjBAekVYltyPfAZwNkY9EY078d8+Silvt3kvuecspzlfmbtWvg==" + }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", "requested": "[10.0.9, )", diff --git a/tests/FinanceApp.Tests/packages.lock.json b/tests/FinanceApp.Tests/packages.lock.json index 577d80c..fc1c9e1 100644 --- a/tests/FinanceApp.Tests/packages.lock.json +++ b/tests/FinanceApp.Tests/packages.lock.json @@ -850,7 +850,12 @@ } }, "financeapp.categorization": { - "type": "Project" + "type": "Project", + "dependencies": { + "FinanceApp.Domain": "[1.0.0, )", + "Marten": "[9.12.0, )", + "WolverineFx.Marten": "[6.16.0, )" + } }, "financeapp.connectors": { "type": "Project", From d3ad5caa23b71949d2aa3592c4d962f53867cc68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Sat, 4 Jul 2026 13:03:11 +0800 Subject: [PATCH 2/6] fix: publish categorization message before SaveChangesAsync so the outbox flushes it --- .../Imports/ImportStatementCommand.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs b/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs index 43f6f2e..87237e9 100644 --- a/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs +++ b/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs @@ -88,15 +88,20 @@ CancellationToken cancellationToken } session.Store(batch); - await session.SaveChangesAsync(cancellationToken); if (newRows.Count > 0) { - // Async fire-and-forget: categorization failures (rules or Claude - // API) must never fail an import. + // Enrol the follow-up categorization in the SAME transactional outbox + // as this import, so it is delivered atomically when SaveChangesAsync + // commits. Publishing AFTER the commit would leave the outgoing + // envelope unflushed in the durable outbox — enqueued but never + // delivered. Categorization failures still never fail the import: the + // categorization handler swallows them (rules/Claude are best-effort). await messageBus.PublishAsync(new CategorizeImportedTransactionsCommand(batch.Id)); } + await session.SaveChangesAsync(cancellationToken); + return Result.Ok(batch); } From bf4319c119df24fc39ccd98e541cb3d4f69acd2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Sat, 4 Jul 2026 16:59:49 +0800 Subject: [PATCH 3/6] fix: opt handler assemblies into Wolverine discovery via ConfigureWolverine (app-foundation 1.1.2) --- Directory.Packages.props | 6 +- ...orizeImportedTransactionsCommandHandler.cs | 6 +- .../Initialization.cs | 10 +- .../Imports/ImportStatementCommand.cs | 19 +- src/FinanceApp.Domain/Initialization.cs | 8 +- .../Components/Import/Pages/Import.razor | 12 +- src/FinanceApp/Program.cs | 13 + src/FinanceApp/packages.lock.json | 52 +-- .../packages.lock.json | 347 +----------------- tests/FinanceApp.Tests/packages.lock.json | 58 +-- 10 files changed, 100 insertions(+), 431 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8cb48b3..882e51c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,9 +4,9 @@ - - - + + + diff --git a/src/FinanceApp.Categorization/CategorizeImportedTransactionsCommandHandler.cs b/src/FinanceApp.Categorization/CategorizeImportedTransactionsCommandHandler.cs index 0b4b023..76c1b38 100644 --- a/src/FinanceApp.Categorization/CategorizeImportedTransactionsCommandHandler.cs +++ b/src/FinanceApp.Categorization/CategorizeImportedTransactionsCommandHandler.cs @@ -6,6 +6,7 @@ using FinanceApp.Domain.Transactions; using Marten; using Microsoft.Extensions.Logging; +using Wolverine.Attributes; namespace FinanceApp.Categorization; @@ -17,13 +18,14 @@ namespace FinanceApp.Categorization; /// remaining transactions uncategorized — they surface in the review queue and /// the next import retries; the import itself has long since succeeded. /// -public static class CategorizeImportedTransactionsCommandHandler +[WolverineHandler] +public class CategorizeImportedTransactionsCommandHandler { internal const decimal HighConfidenceThreshold = 0.8m; private const int BatchSize = 50; private const int FewShotExampleCount = 30; - public static async Task Handle( + public async Task Handle( CategorizeImportedTransactionsCommand command, IDocumentSession session, IClaudeCategorizer claudeCategorizer, diff --git a/src/FinanceApp.Categorization/Initialization.cs b/src/FinanceApp.Categorization/Initialization.cs index a1e29a1..725fcf5 100644 --- a/src/FinanceApp.Categorization/Initialization.cs +++ b/src/FinanceApp.Categorization/Initialization.cs @@ -1,17 +1,15 @@ using FinanceApp.Categorization.Claude; using Microsoft.Extensions.DependencyInjection; -using Wolverine.Attributes; - -[assembly: WolverineModule] namespace FinanceApp.Categorization; public static class Initialization { /// - /// Registers the categorization services: the Claude API client (key from - /// the encrypted credential store) and, via Wolverine module discovery, the - /// async categorization pipeline. The rules engine is pure logic. + /// Registers the categorization services: the Claude API client (key from the + /// encrypted credential store). The handler assembly is opted into Wolverine + /// discovery from Program.cs via + /// AppFoundationOptions.ConfigureWolverine. The rules engine is pure logic. /// public static IServiceCollection AddCategorization(this IServiceCollection services) { diff --git a/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs b/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs index 87237e9..551e0d3 100644 --- a/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs +++ b/src/FinanceApp.Domain/Imports/ImportStatementCommand.cs @@ -1,7 +1,6 @@ using FinanceApp.Domain.Accounts; using FinanceApp.Domain.Transactions; using Marten; -using Wolverine; namespace FinanceApp.Domain.Imports; @@ -25,7 +24,6 @@ public static class ImportStatementCommandHandler public static async Task> Handle( ImportStatementCommand command, IDocumentSession session, - IMessageBus messageBus, CancellationToken cancellationToken ) { @@ -88,20 +86,13 @@ CancellationToken cancellationToken } session.Store(batch); - - if (newRows.Count > 0) - { - // Enrol the follow-up categorization in the SAME transactional outbox - // as this import, so it is delivered atomically when SaveChangesAsync - // commits. Publishing AFTER the commit would leave the outgoing - // envelope unflushed in the durable outbox — enqueued but never - // delivered. Categorization failures still never fail the import: the - // categorization handler swallows them (rules/Claude are best-effort). - await messageBus.PublishAsync(new CategorizeImportedTransactionsCommand(batch.Id)); - } - await session.SaveChangesAsync(cancellationToken); + // Categorization is kicked off by the caller after this returns (a + // top-level PublishAsync from application code, the same path the + // foundation's email uses), not from inside this handler: publishing a + // routed message from within an InvokeAsync'd handler does not reliably + // deliver it. Failures there never affect this import. return Result.Ok(batch); } diff --git a/src/FinanceApp.Domain/Initialization.cs b/src/FinanceApp.Domain/Initialization.cs index d1591be..3a53cb2 100644 --- a/src/FinanceApp.Domain/Initialization.cs +++ b/src/FinanceApp.Domain/Initialization.cs @@ -4,9 +4,6 @@ using JasperFx.Events.Projections; using Marten; using Microsoft.Extensions.DependencyInjection; -using Wolverine.Attributes; - -[assembly: WolverineModule] namespace FinanceApp.Domain; @@ -16,8 +13,9 @@ public static class Initialization /// Registers the finance domain with the Marten store configured by /// app-foundation: the inline projection /// (inline so dedup checks see rows imported in the same session), dedup - /// indexes, and the default category seed. Wolverine discovers the command - /// handlers via the assembly-level . + /// indexes, and the default category seed. The handler assembly is opted into + /// Wolverine discovery from Program.cs via + /// AppFoundationOptions.ConfigureWolverine. /// public static IServiceCollection AddFinanceDomain(this IServiceCollection services) { diff --git a/src/FinanceApp/Components/Import/Pages/Import.razor b/src/FinanceApp/Components/Import/Pages/Import.razor index e870c1d..2a393f7 100644 --- a/src/FinanceApp/Components/Import/Pages/Import.razor +++ b/src/FinanceApp/Components/Import/Pages/Import.razor @@ -277,10 +277,20 @@ return; } + // Kick off async categorization (rules → Claude) as a top-level + // publish from application code — the proven Wolverine delivery path. + // Fire-and-forget: categorization never blocks or fails the import. + if (result.Value!.ImportedCount > 0) + { + await MessageBus.PublishAsync( + new CategorizeImportedTransactionsCommand(result.Value.Id) + ); + } + NotificationService.Notify( NotificationSeverity.Success, "Import complete", - $"{result.Value!.ImportedCount} imported, {result.Value.DuplicateCount} duplicates skipped." + $"{result.Value.ImportedCount} imported, {result.Value.DuplicateCount} duplicates skipped." ); preview = null; await LoadHistoryAsync(); diff --git a/src/FinanceApp/Program.cs b/src/FinanceApp/Program.cs index 53af165..8f5c2a1 100644 --- a/src/FinanceApp/Program.cs +++ b/src/FinanceApp/Program.cs @@ -2,7 +2,9 @@ using AndreGoepel.AppFoundation.Hosting; using AndreGoepel.Marten.Identity.Blazor.Components.Account; using FinanceApp; +using FinanceApp.Categorization; using FinanceApp.Components; +using FinanceApp.Domain.Imports; var builder = WebApplication.CreateBuilder(args); @@ -10,6 +12,17 @@ { options.DatabaseConnectionName = "financeapp-database"; options.WolverineServiceName = "FinanceApp"; + + // The host owns the single UseWolverine call; opt the finance handler + // assemblies into Wolverine's discovery here so command handlers (import, + // categorization, …) are found and routed to. + options.ConfigureWolverine = wolverine => + { + wolverine.Discovery.IncludeAssembly(typeof(ImportStatementCommand).Assembly); + wolverine.Discovery.IncludeAssembly( + typeof(CategorizeImportedTransactionsCommandHandler).Assembly + ); + }; }); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); diff --git a/src/FinanceApp/packages.lock.json b/src/FinanceApp/packages.lock.json index fda543e..076474c 100644 --- a/src/FinanceApp/packages.lock.json +++ b/src/FinanceApp/packages.lock.json @@ -4,12 +4,12 @@ "net10.0": { "AndreGoepel.AppFoundation": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "uYTeGDZaioHx7cZzw/5sDn0euUsn9OMRXGMCdYmGyERuvqFgy6/JSMFD1pZ+NbfWBHnpDa2uKSyxrt7s/gEVWQ==", + "requested": "[1.1.2, )", + "resolved": "1.1.2", + "contentHash": "M+UFLOq+RWueHWmzY74Z5KE9l1VOEPi5sJQLmKJNLbann565CY+uBR2VIgKrm8hUnxqR0LrGzp6G/biso2qlFg==", "dependencies": { - "AndreGoepel.AppFoundation.MailService": "1.1.1", - "AndreGoepel.Marten.Identity.Blazor": "1.1.1", + "AndreGoepel.AppFoundation.MailService": "1.1.2", + "AndreGoepel.Marten.Identity.Blazor": "1.1.3", "Marten": "9.12.0", "Radzen.Blazor": "11.1.0", "WolverineFx.Marten": "6.16.0" @@ -17,14 +17,14 @@ }, "AndreGoepel.AppFoundation.Hosting": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "leOCi9p/043Uh1BaXX+OPKKjeb1dfSKX1iNBtO9kOHSnLTQYK48LWmTmLGLi6JfUF8H8Y3rdiMfELUr6lkUOww==", - "dependencies": { - "AndreGoepel.AppFoundation": "1.1.1", - "AndreGoepel.AppFoundation.MailService": "1.1.1", - "AndreGoepel.AppFoundation.ServiceDefaults": "1.1.1", - "AndreGoepel.Marten.Identity.Blazor": "1.1.1", + "requested": "[1.1.2, )", + "resolved": "1.1.2", + "contentHash": "Pd5zAF5aM9EmDz/Xb3XED7MYGWWulDVpMFAKlx0vyXM5NWWqioLaAZSAPAagEdio6OAZz6lxW2dABRp09lcUIA==", + "dependencies": { + "AndreGoepel.AppFoundation": "1.1.2", + "AndreGoepel.AppFoundation.MailService": "1.1.2", + "AndreGoepel.AppFoundation.ServiceDefaults": "1.1.2", + "AndreGoepel.Marten.Identity.Blazor": "1.1.3", "Marten": "9.12.0", "Microsoft.AspNetCore.HeaderPropagation": "10.0.9", "Radzen.Blazor": "11.1.0", @@ -34,11 +34,11 @@ }, "AndreGoepel.Marten.Identity.Blazor": { "type": "Direct", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "f9AjXVLRWmMOVLoLbK+/WZwYqNJAZSfMv3vUe4rdDgomBXBmXtMGbrOnMXE/dtBgHyxfqrNx/5qUHT0SzYXjKg==", + "requested": "[1.1.3, )", + "resolved": "1.1.3", + "contentHash": "GseXArQ+jtV7mRqKAqSrF3kjrskKxEhZVFcMCN9pAUMG3sXU4nqhWq0EkVW1KZ1GtNbL9XmfDc737AQrmG655Q==", "dependencies": { - "AndreGoepel.Marten.Identity": "1.1.1", + "AndreGoepel.Marten.Identity": "1.1.3", "Marten": "9.12.0", "Radzen.Blazor": "11.0.5" } @@ -75,8 +75,8 @@ }, "AndreGoepel.AppFoundation.MailService": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "kYs8mV76uSXaMJWogzHxtdeYqiNMA04AkBEdqwwPJWuUyj8Yee1VrOnJxGxbHF/m2gjCGdWnkjgN3kFDacAJIQ==", + "resolved": "1.1.2", + "contentHash": "y935BnVxYfkPDHYW9y/ek6etV7/8xmCPSvDtSLm9pIZbHGT9iutQOv/iMKWhCBvkf1/miUaDVG1Gputfw5zzjA==", "dependencies": { "MailKit": "4.17.0", "Marten": "9.12.0", @@ -85,8 +85,8 @@ }, "AndreGoepel.AppFoundation.ServiceDefaults": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "2DDUDhI4BXJ+5zgCpn6kwHEQ6K+mbNi7dOfdETWD/vNLsnK/vk0I+vKxK7kjDvE5ZQUIVI9B2DV7uTtlHODXbw==", + "resolved": "1.1.2", + "contentHash": "hr+AvN9c8tG2viy2iDMFmF2Y8pr9A/NBiQJArhUtSQOA1D0N5IV/qfcuK64C31rn+1rBUkJxb7JCdn265c5Jqg==", "dependencies": { "Microsoft.Extensions.Http.Resilience": "10.7.0", "Microsoft.Extensions.ServiceDiscovery": "10.7.0", @@ -99,10 +99,10 @@ }, "AndreGoepel.Marten.Identity": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "avhegku682O8/9w9o8XBNnGiMOTNUgC6yRuy3O9VvMyXeIyjQyN1OwOMNo/s8uAc58bcfvtgrTB7nLP53ViXAw==", + "resolved": "1.1.3", + "contentHash": "5ThzsH0mJOYYGwBtBh9dRj8Pwc78wvOTYnGyM5io82N4ItEG4+lkmSq2X4JdcxHdptFc0BxLCWPnEpOFCYAxbA==", "dependencies": { - "AndreGoepel.Marten.Identity.Abstractions": "1.1.1", + "AndreGoepel.Marten.Identity.Abstractions": "1.1.3", "Marten": "9.12.0", "Marten.AspNetCore": "9.12.0", "Quartz.Extensions.Hosting": "3.18.2" @@ -110,8 +110,8 @@ }, "AndreGoepel.Marten.Identity.Abstractions": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "iXIPG9CsOMBdlxl2VYojflinQSFwKGxPEfySzFXgLVpR2QW4KW+u4AoFtNg9V1W+5nMEmHkha9CdF6caa+TsAQ==" + "resolved": "1.1.3", + "contentHash": "2GQmYNU6RHXqKenvYqD+QkUKbuJnaC66XkdGEYoiLVSUnoTZZiiAP14QuJ1CpoAL6L8qR17E7WI3VBZsSH49oA==" }, "BouncyCastle.Cryptography": { "type": "Transitive", diff --git a/tests/FinanceApp.Domain.Tests/packages.lock.json b/tests/FinanceApp.Domain.Tests/packages.lock.json index 72c7c07..760c6e9 100644 --- a/tests/FinanceApp.Domain.Tests/packages.lock.json +++ b/tests/FinanceApp.Domain.Tests/packages.lock.json @@ -45,10 +45,7 @@ "Castle.Core": { "type": "Transitive", "resolved": "5.1.1", - "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", - "dependencies": { - "System.Diagnostics.EventLog": "6.0.0" - } + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==" }, "DistributedLock.Core": { "type": "Transitive", @@ -82,10 +79,6 @@ "FastExpressionCompiler": "5.4.1", "ImTools": "4.0.0", "Microsoft.Bcl.TimeProvider": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.0", - "Microsoft.Extensions.Hosting": "10.0.0", - "Microsoft.Extensions.ObjectPool": "10.0.0", "Polly.Core": "8.6.5", "Spectre.Console": "0.55.0" } @@ -123,310 +116,6 @@ "resolved": "18.7.0", "contentHash": "+wFfx9s7D9wegM0RziXMj2kvYDT4qcqXXtyjiQwSZOGQ2wwcOAJQcD6eQXk02jt0MvRNawtp8TJxTrV+wD8X1g==" }, - "Microsoft.Extensions.Caching.Abstractions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "Zcoy6H9mSoGyvr7UvlGokEZrlZkcPCICPZr8mCsSt9U/N8eeCwCXwKF5bShdA66R0obxBCwP4AxomQHvVkC/uA==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "krK19MKp0BNiR9rpBDW7PKSrTMLVlifS9am3CVc4O1Jq6GWz0o4F+sw5OSL4L3mVd56W8l6JRgghUa2KB51vOw==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.Configuration": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.Configuration.Abstractions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.Configuration.Binder": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" - } - }, - "Microsoft.Extensions.Configuration.CommandLine": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "CRj5clwZciVs46GMhAthkFq3+JiNM15Bz9CRlCZLBmRdggD6RwoBphRJ+EUDK2f+cZZ1L2zqVaQrn1KueoU5Kg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" - } - }, - "Microsoft.Extensions.Configuration.EnvironmentVariables": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "TmFegsI/uCdwMBD4yKpmO+OkjVNHQL49Dh/ep83NI5rPUEoBK9OdsJo1zURc1A2FuS/R/Pos3wsTjlyLnguBLA==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" - } - }, - "Microsoft.Extensions.Configuration.FileExtensions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "LqCTyF0twrG4tyEN6PpSC5ewRBDwCBazRUfCOdRddwaQ3n2S57GDDeYOlTLcbV/V2dxSSZWg5Ofr48h6BsBmxw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Physical": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.Configuration.Json": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "BIOPTEAZoeWbHlDT9Zudu+rpecZizFwhdIFRiyZKDml7JbayXmfTXKUt+ezifsSXfBkWDdJM10oDOxo8pufEng==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0" - } - }, - "Microsoft.Extensions.Configuration.UserSecrets": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "B4qHB6gQ2B3I52YRohSV7wetp01BQzi8jDmrtiVm6e4l8vH5vjqwxWcR5wumGWjdBkj1asJLLsDIocdyTQSP0A==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.Json": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Physical": "10.0.0" - } - }, - "Microsoft.Extensions.DependencyInjection": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" - } - }, - "Microsoft.Extensions.Diagnostics": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "xjkxIPgrT0mKTfBwb+CVqZnRchyZgzKIfDQOp8z+WUC6vPe3WokIf71z+hJPkH0YBUYJwa7Z/al1R087ib9oiw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" - } - }, - "Microsoft.Extensions.Diagnostics.Abstractions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0" - } - }, - "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "jAhZbzDa117otUBMuQQ6JzSfuDbBBrfOs5jw5l7l9hKpzt+LjYKVjSauXG2yV9u7BqUSLUtKLwcerDQDeQ+0Xw==" - }, - "Microsoft.Extensions.FileProviders.Abstractions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", - "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.FileProviders.Physical": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "UZUQ74lQMmvcprlG8w+XpxBbyRDQqfb7GAnccITw32hdkUBlmm9yNC4xl4aR9YjgV3ounZcub194sdmLSfBmPA==", - "dependencies": { - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.FileSystemGlobbing": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.FileSystemGlobbing": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "5hfVl/e+bx1px2UkN+1xXhd3hu7Ui6ENItBzckFaRDQXfr+SHT/7qrCDrlQekCF/PBtEu2vtk87U2+gDEF8EhQ==" - }, - "Microsoft.Extensions.Hosting": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "yKJiVdXkSfe9foojGpBRbuDPQI8YD71IO/aE8ehGjRHE0VkEF/YWkW6StthwuFF146pc2lypZrpk/Tks6Plwhw==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.Configuration.CommandLine": "10.0.0", - "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.0", - "Microsoft.Extensions.Configuration.FileExtensions": "10.0.0", - "Microsoft.Extensions.Configuration.Json": "10.0.0", - "Microsoft.Extensions.Configuration.UserSecrets": "10.0.0", - "Microsoft.Extensions.DependencyInjection": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Diagnostics": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Physical": "10.0.0", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Configuration": "10.0.0", - "Microsoft.Extensions.Logging.Console": "10.0.0", - "Microsoft.Extensions.Logging.Debug": "10.0.0", - "Microsoft.Extensions.Logging.EventLog": "10.0.0", - "Microsoft.Extensions.Logging.EventSource": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0" - } - }, - "Microsoft.Extensions.Hosting.Abstractions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" - } - }, - "Microsoft.Extensions.Logging": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" - } - }, - "Microsoft.Extensions.Logging.Configuration": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "j8zcwhS6bYB6FEfaY3nYSgHdpiL2T+/V3xjpHtslVAegyI1JUbB9yAt/BFdvZdsNbY0Udm4xFtvfT/hUwcOOOg==", - "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" - } - }, - "Microsoft.Extensions.Logging.Console": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "treWetuksp8LVb09fCJ5zNhNJjyDkqzVm83XxcrlWQnAdXznR140UUXo8PyEPBvFlHhjKhFQZEOP3Sk/ByCvEw==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Configuration": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0" - } - }, - "Microsoft.Extensions.Logging.Debug": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "A/4vBtVaySLBGj4qluye+KSbeVCCMa6GcTbxf2YgnSDHs9b9105+VojBJ1eJPel8F1ny0JOh+Ci3vgCKn69tNQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" - } - }, - "Microsoft.Extensions.Logging.EventLog": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "EWda5nSXhzQZr3yJ3+XgIApOek+Hm+txhWCEzWNVPp/OfimL4qmvctgXu87m+S2RXw/AoUP8aLMNicJ2KWblVA==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "System.Diagnostics.EventLog": "10.0.0" - } - }, - "Microsoft.Extensions.Logging.EventSource": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "+Qc+kgoJi1w2A/Jm+7h04LcK2JoJkwAxKg7kBakkNRcemTmRGocqPa7rVNVGorTYruFrUS25GwkFNtOECnjhXg==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.ObjectPool": { - "type": "Transitive", - "resolved": "10.0.2", - "contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ==" - }, - "Microsoft.Extensions.Options": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.Options.ConfigurationExtensions": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "tL9cSl3maS5FPzp/3MtlZI21ExWhni0nnUCF8HY4npTsINw45n9SNDbkKXBMtFyUFGSsQep25fHIDN4f/Vp3AQ==", - "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.Extensions.Primitives": "10.0.0" - } - }, - "Microsoft.Extensions.Primitives": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" - }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", "resolved": "3.0.1", @@ -507,10 +196,7 @@ "Npgsql": { "type": "Transitive", "resolved": "9.0.4", - "contentHash": "68BASXH0FAEuL/J4J0eRfYC8/3vzqQTmoW8zDzNf0JgaVxc7LZeEkS6jaG0ib3voFLxY5ZiCwJG+uQM+mzuu0Q==", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "8.0.2" - } + "contentHash": "68BASXH0FAEuL/J4J0eRfYC8/3vzqQTmoW8zDzNf0JgaVxc7LZeEkS6jaG0ib3voFLxY5ZiCwJG+uQM+mzuu0Q==" }, "Npgsql.NetTopologySuite": { "type": "Transitive", @@ -540,11 +226,6 @@ "resolved": "0.55.0", "contentHash": "GIfgOvuF8MpU52bprhwtDEtx0gmVIGOepM8bw0lH/TSMD0rg1Xp+5Y53kPG8rFVdcpbNANlhDQ5mEVVPO3/2Tg==" }, - "System.Diagnostics.EventLog": { - "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "uaFRda9NjtbJRkdx311eXlAA3n2em7223c1A8d1VWyl+4FL9vkG7y2lpPfBU9HYdj/9KgdRNdn1vFK8ZYCYT/A==" - }, "Weasel.Core": { "type": "Transitive", "resolved": "9.3.0", @@ -573,15 +254,6 @@ "JasperFx": "2.16.0", "JasperFx.Events": "2.16.0", "JasperFx.SourceGenerator": "2.16.0", - "Microsoft.Extensions.Caching.Memory": "10.0.0", - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.Configuration.Json": "10.0.0", - "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": "10.0.0", - "Microsoft.Extensions.Hosting": "10.0.0", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", - "Microsoft.Extensions.ObjectPool": "10.0.2", "NewId": "4.0.1" } }, @@ -674,8 +346,6 @@ "type": "Project", "dependencies": { "Marten": "[9.12.0, )", - "Microsoft.AspNetCore.DataProtection.Abstractions": "[10.0.9, )", - "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", "WolverineFx.Marten": "[6.16.0, )" } }, @@ -687,23 +357,10 @@ "dependencies": { "JasperFx": "2.18.1", "JasperFx.Events": "2.18.1", - "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", "Microsoft.IO.RecyclableMemoryStream": "3.0.1", "Weasel.Postgresql": "9.3.0" } }, - "Microsoft.AspNetCore.DataProtection.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.9, )", - "resolved": "10.0.9", - "contentHash": "KZP7FpVQkP29U0icd2xNdnAy9zUfhqX6JmYzhjBAekVYltyPfAZwNkY9EY078d8+Silvt3kvuecspzlfmbtWvg==" - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.9, )", - "resolved": "10.0.9", - "contentHash": "g41l/30G3K4B/d/L8kjux0+30e27c8D0FVQ/PFCpbekgfDpj9mnDhieP67EqXWvl1EWNeZh2rpR4F5B/jcDOHA==" - }, "WolverineFx.Marten": { "type": "CentralTransitive", "requested": "[6.16.0, )", diff --git a/tests/FinanceApp.Tests/packages.lock.json b/tests/FinanceApp.Tests/packages.lock.json index fc1c9e1..decefd5 100644 --- a/tests/FinanceApp.Tests/packages.lock.json +++ b/tests/FinanceApp.Tests/packages.lock.json @@ -57,8 +57,8 @@ }, "AndreGoepel.AppFoundation.MailService": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "kYs8mV76uSXaMJWogzHxtdeYqiNMA04AkBEdqwwPJWuUyj8Yee1VrOnJxGxbHF/m2gjCGdWnkjgN3kFDacAJIQ==", + "resolved": "1.1.2", + "contentHash": "y935BnVxYfkPDHYW9y/ek6etV7/8xmCPSvDtSLm9pIZbHGT9iutQOv/iMKWhCBvkf1/miUaDVG1Gputfw5zzjA==", "dependencies": { "MailKit": "4.17.0", "Marten": "9.12.0", @@ -67,8 +67,8 @@ }, "AndreGoepel.AppFoundation.ServiceDefaults": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "2DDUDhI4BXJ+5zgCpn6kwHEQ6K+mbNi7dOfdETWD/vNLsnK/vk0I+vKxK7kjDvE5ZQUIVI9B2DV7uTtlHODXbw==", + "resolved": "1.1.2", + "contentHash": "hr+AvN9c8tG2viy2iDMFmF2Y8pr9A/NBiQJArhUtSQOA1D0N5IV/qfcuK64C31rn+1rBUkJxb7JCdn265c5Jqg==", "dependencies": { "Microsoft.Extensions.Http.Resilience": "10.7.0", "Microsoft.Extensions.ServiceDiscovery": "10.7.0", @@ -81,10 +81,10 @@ }, "AndreGoepel.Marten.Identity": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "avhegku682O8/9w9o8XBNnGiMOTNUgC6yRuy3O9VvMyXeIyjQyN1OwOMNo/s8uAc58bcfvtgrTB7nLP53ViXAw==", + "resolved": "1.1.3", + "contentHash": "5ThzsH0mJOYYGwBtBh9dRj8Pwc78wvOTYnGyM5io82N4ItEG4+lkmSq2X4JdcxHdptFc0BxLCWPnEpOFCYAxbA==", "dependencies": { - "AndreGoepel.Marten.Identity.Abstractions": "1.1.1", + "AndreGoepel.Marten.Identity.Abstractions": "1.1.3", "Marten": "9.12.0", "Marten.AspNetCore": "9.12.0", "Quartz.Extensions.Hosting": "3.18.2" @@ -92,8 +92,8 @@ }, "AndreGoepel.Marten.Identity.Abstractions": { "type": "Transitive", - "resolved": "1.1.1", - "contentHash": "iXIPG9CsOMBdlxl2VYojflinQSFwKGxPEfySzFXgLVpR2QW4KW+u4AoFtNg9V1W+5nMEmHkha9CdF6caa+TsAQ==" + "resolved": "1.1.3", + "contentHash": "2GQmYNU6RHXqKenvYqD+QkUKbuJnaC66XkdGEYoiLVSUnoTZZiiAP14QuJ1CpoAL6L8qR17E7WI3VBZsSH49oA==" }, "AngleSharp": { "type": "Transitive", @@ -838,9 +838,9 @@ "financeapp": { "type": "Project", "dependencies": { - "AndreGoepel.AppFoundation": "[1.1.1, )", - "AndreGoepel.AppFoundation.Hosting": "[1.1.1, )", - "AndreGoepel.Marten.Identity.Blazor": "[1.1.1, )", + "AndreGoepel.AppFoundation": "[1.1.2, )", + "AndreGoepel.AppFoundation.Hosting": "[1.1.2, )", + "AndreGoepel.Marten.Identity.Blazor": "[1.1.3, )", "FinanceApp.Categorization": "[1.0.0, )", "FinanceApp.Connectors": "[1.0.0, )", "FinanceApp.Domain": "[1.0.0, )", @@ -872,12 +872,12 @@ }, "AndreGoepel.AppFoundation": { "type": "CentralTransitive", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "uYTeGDZaioHx7cZzw/5sDn0euUsn9OMRXGMCdYmGyERuvqFgy6/JSMFD1pZ+NbfWBHnpDa2uKSyxrt7s/gEVWQ==", + "requested": "[1.1.2, )", + "resolved": "1.1.2", + "contentHash": "M+UFLOq+RWueHWmzY74Z5KE9l1VOEPi5sJQLmKJNLbann565CY+uBR2VIgKrm8hUnxqR0LrGzp6G/biso2qlFg==", "dependencies": { - "AndreGoepel.AppFoundation.MailService": "1.1.1", - "AndreGoepel.Marten.Identity.Blazor": "1.1.1", + "AndreGoepel.AppFoundation.MailService": "1.1.2", + "AndreGoepel.Marten.Identity.Blazor": "1.1.3", "Marten": "9.12.0", "Radzen.Blazor": "11.1.0", "WolverineFx.Marten": "6.16.0" @@ -885,14 +885,14 @@ }, "AndreGoepel.AppFoundation.Hosting": { "type": "CentralTransitive", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "leOCi9p/043Uh1BaXX+OPKKjeb1dfSKX1iNBtO9kOHSnLTQYK48LWmTmLGLi6JfUF8H8Y3rdiMfELUr6lkUOww==", - "dependencies": { - "AndreGoepel.AppFoundation": "1.1.1", - "AndreGoepel.AppFoundation.MailService": "1.1.1", - "AndreGoepel.AppFoundation.ServiceDefaults": "1.1.1", - "AndreGoepel.Marten.Identity.Blazor": "1.1.1", + "requested": "[1.1.2, )", + "resolved": "1.1.2", + "contentHash": "Pd5zAF5aM9EmDz/Xb3XED7MYGWWulDVpMFAKlx0vyXM5NWWqioLaAZSAPAagEdio6OAZz6lxW2dABRp09lcUIA==", + "dependencies": { + "AndreGoepel.AppFoundation": "1.1.2", + "AndreGoepel.AppFoundation.MailService": "1.1.2", + "AndreGoepel.AppFoundation.ServiceDefaults": "1.1.2", + "AndreGoepel.Marten.Identity.Blazor": "1.1.3", "Marten": "9.12.0", "Microsoft.AspNetCore.HeaderPropagation": "10.0.9", "Radzen.Blazor": "11.1.0", @@ -902,11 +902,11 @@ }, "AndreGoepel.Marten.Identity.Blazor": { "type": "CentralTransitive", - "requested": "[1.1.1, )", - "resolved": "1.1.1", - "contentHash": "f9AjXVLRWmMOVLoLbK+/WZwYqNJAZSfMv3vUe4rdDgomBXBmXtMGbrOnMXE/dtBgHyxfqrNx/5qUHT0SzYXjKg==", + "requested": "[1.1.3, )", + "resolved": "1.1.3", + "contentHash": "GseXArQ+jtV7mRqKAqSrF3kjrskKxEhZVFcMCN9pAUMG3sXU4nqhWq0EkVW1KZ1GtNbL9XmfDc737AQrmG655Q==", "dependencies": { - "AndreGoepel.Marten.Identity": "1.1.1", + "AndreGoepel.Marten.Identity": "1.1.3", "Marten": "9.12.0", "Radzen.Blazor": "11.0.5" } From c588a0cb9b1e6a590189a03d73ef255c5c95caaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Sat, 4 Jul 2026 17:12:55 +0800 Subject: [PATCH 4/6] fix: make IClaudeCategorizer inline-constructable for Wolverine codegen (named HttpClient + ILogger) --- ...egorizeImportedTransactionsCommandHandler.cs | 4 +--- .../Claude/ClaudeCategorizer.cs | 17 +++++++++++++++-- src/FinanceApp.Categorization/Initialization.cs | 17 ++++++++++++----- .../Claude/ClaudeCategorizer.Tests.cs | 4 +++- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/FinanceApp.Categorization/CategorizeImportedTransactionsCommandHandler.cs b/src/FinanceApp.Categorization/CategorizeImportedTransactionsCommandHandler.cs index 76c1b38..a28f59b 100644 --- a/src/FinanceApp.Categorization/CategorizeImportedTransactionsCommandHandler.cs +++ b/src/FinanceApp.Categorization/CategorizeImportedTransactionsCommandHandler.cs @@ -29,12 +29,10 @@ public async Task Handle( CategorizeImportedTransactionsCommand command, IDocumentSession session, IClaudeCategorizer claudeCategorizer, - ILoggerFactory loggerFactory, + ILogger logger, CancellationToken cancellationToken ) { - var logger = loggerFactory.CreateLogger("FinanceApp.Categorization"); - var pending = await session .Query() .Where(t => t.ImportBatchId == command.ImportBatchId && t.CategoryId == null) diff --git a/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs b/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs index 5a462ab..d7d8ae7 100644 --- a/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs +++ b/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs @@ -11,9 +11,19 @@ namespace FinanceApp.Categorization.Claude; /// forced tool call, temperature 0, Haiku-class model. The API key comes from /// the encrypted credential store and is never logged. /// -internal sealed class ClaudeCategorizer(HttpClient httpClient, ICredentialStore credentialStore) - : IClaudeCategorizer +/// +/// Takes (a named client) rather than a typed +/// HttpClient: Wolverine generates handler code that constructs its +/// dependencies inline and forbids service location, which a typed-client factory +/// registration would require. A plain service depending on the factory is +/// inline-constructable. +/// +internal sealed class ClaudeCategorizer( + IHttpClientFactory httpClientFactory, + ICredentialStore credentialStore +) : IClaudeCategorizer { + internal const string HttpClientName = "claude"; internal const string Model = "claude-haiku-4-5-20251001"; private const string ToolName = "categorize_transactions"; @@ -45,6 +55,9 @@ public async Task>> SuggestAsync( request.Headers.Add("anthropic-version", "2023-06-01"); request.Content = JsonContent.Create(BuildRequestBody(transactions, categories, examples)); + // Factory-created clients are pooled by the factory — do not dispose here. + var httpClient = httpClientFactory.CreateClient(HttpClientName); + try { using var response = await httpClient.SendAsync(request, cancellationToken); diff --git a/src/FinanceApp.Categorization/Initialization.cs b/src/FinanceApp.Categorization/Initialization.cs index 725fcf5..8ab08ae 100644 --- a/src/FinanceApp.Categorization/Initialization.cs +++ b/src/FinanceApp.Categorization/Initialization.cs @@ -13,11 +13,18 @@ public static class Initialization /// public static IServiceCollection AddCategorization(this IServiceCollection services) { - services.AddHttpClient(client => - { - client.BaseAddress = new Uri("https://api.anthropic.com/"); - client.Timeout = TimeSpan.FromSeconds(90); - }); + // A named client (not a typed client) plus a plain service registration, so + // Wolverine can inline-construct the handler's IClaudeCategorizer dependency + // (typed HttpClient registrations require forbidden service location). + services.AddHttpClient( + ClaudeCategorizer.HttpClientName, + client => + { + client.BaseAddress = new Uri("https://api.anthropic.com/"); + client.Timeout = TimeSpan.FromSeconds(90); + } + ); + services.AddScoped(); return services; } diff --git a/tests/FinanceApp.Categorization.Tests/Claude/ClaudeCategorizer.Tests.cs b/tests/FinanceApp.Categorization.Tests/Claude/ClaudeCategorizer.Tests.cs index b8e24d1..20d16c1 100644 --- a/tests/FinanceApp.Categorization.Tests/Claude/ClaudeCategorizer.Tests.cs +++ b/tests/FinanceApp.Categorization.Tests/Claude/ClaudeCategorizer.Tests.cs @@ -224,7 +224,9 @@ string responseBody { BaseAddress = new Uri("https://api.anthropic.example/"), }; - return new ClaudeCategorizer(httpClient, credentials); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + return new ClaudeCategorizer(httpClientFactory, credentials); } private sealed class FakeHandler(HttpStatusCode statusCode, string body) : HttpMessageHandler From db65a2c32b5748d0cd03c626cc14b662fbe56fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Sat, 4 Jul 2026 18:11:22 +0800 Subject: [PATCH 5/6] fix: make ClaudeCategorizer public so Wolverine codegen can construct it inline --- src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs b/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs index d7d8ae7..2403eb6 100644 --- a/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs +++ b/src/FinanceApp.Categorization/Claude/ClaudeCategorizer.cs @@ -16,9 +16,11 @@ namespace FinanceApp.Categorization.Claude; /// HttpClient: Wolverine generates handler code that constructs its /// dependencies inline and forbids service location, which a typed-client factory /// registration would require. A plain service depending on the factory is -/// inline-constructable. +/// inline-constructable. The class is public (not internal) for the +/// same reason: Wolverine's generated handler assembly must be able to reference +/// the concrete type to construct it inline. /// -internal sealed class ClaudeCategorizer( +public sealed class ClaudeCategorizer( IHttpClientFactory httpClientFactory, ICredentialStore credentialStore ) : IClaudeCategorizer From 81db017d70ddeec38cd7c946a63eae95644bcc70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Sat, 4 Jul 2026 18:11:29 +0800 Subject: [PATCH 6/6] fix: opt Claude HttpClient out of Aspire standard resilience The foundation's ServiceDefaults applies AddStandardResilienceHandler to every HttpClient by default; its 10s per-attempt timeout aborted the slower Claude batch calls and threw a Polly TimeoutRejectedException that the client's catch didn't handle, so categorization crashed instead of degrading into the review queue. RemoveAllResilienceHandlers opts the 'claude' client out; it now relies on the 90s HttpClient.Timeout and the existing graceful Result-based degradation. --- Directory.Packages.props | 1 + .../FinanceApp.Categorization.csproj | 1 + .../Initialization.cs | 28 ++- .../packages.lock.json | 159 ++++++++++++++--- src/FinanceApp/packages.lock.json | 20 ++- .../packages.lock.json | 160 +++++++++++++++--- tests/FinanceApp.Tests/packages.lock.json | 20 ++- 7 files changed, 319 insertions(+), 70 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 882e51c..ab35ebc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,7 @@ + + diff --git a/src/FinanceApp.Categorization/Initialization.cs b/src/FinanceApp.Categorization/Initialization.cs index 8ab08ae..f00c019 100644 --- a/src/FinanceApp.Categorization/Initialization.cs +++ b/src/FinanceApp.Categorization/Initialization.cs @@ -16,14 +16,26 @@ public static IServiceCollection AddCategorization(this IServiceCollection servi // A named client (not a typed client) plus a plain service registration, so // Wolverine can inline-construct the handler's IClaudeCategorizer dependency // (typed HttpClient registrations require forbidden service location). - services.AddHttpClient( - ClaudeCategorizer.HttpClientName, - client => - { - client.BaseAddress = new Uri("https://api.anthropic.com/"); - client.Timeout = TimeSpan.FromSeconds(90); - } - ); + // + // Aspire's ServiceDefaults applies a standard resilience handler to every + // HttpClient by default; its 10s per-attempt timeout aborts Claude's slower + // batch calls (and throws a Polly TimeoutRejectedException our catch doesn't + // handle). This external, deliberately-slow API relies on the 90s client + // timeout and the graceful Result-based degradation instead. + // RemoveAllResilienceHandlers is [Experimental] (EXTEXP0001) but is the + // intended way to opt a single client out of the global default. +#pragma warning disable EXTEXP0001 + services + .AddHttpClient( + ClaudeCategorizer.HttpClientName, + client => + { + client.BaseAddress = new Uri("https://api.anthropic.com/"); + client.Timeout = TimeSpan.FromSeconds(90); + } + ) + .RemoveAllResilienceHandlers(); +#pragma warning restore EXTEXP0001 services.AddScoped(); return services; diff --git a/src/FinanceApp.Categorization/packages.lock.json b/src/FinanceApp.Categorization/packages.lock.json index 46158c5..df1a9c4 100644 --- a/src/FinanceApp.Categorization/packages.lock.json +++ b/src/FinanceApp.Categorization/packages.lock.json @@ -35,6 +35,17 @@ "Microsoft.Extensions.Options": "10.0.9" } }, + "Microsoft.Extensions.Http.Resilience": { + "type": "Direct", + "requested": "[10.7.0, )", + "resolved": "10.7.0", + "contentHash": "3Lip0MkBVPtqU1wCM2M2Y8CzsqxA1BanVNMV9LQYMTwBgy25NDe6U9nSk6SfmsxXlh77iwSQuPsyjt5mxrOGmQ==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.7.0", + "Microsoft.Extensions.ObjectPool": "10.0.9", + "Microsoft.Extensions.Resilience": "10.7.0" + } + }, "WolverineFx.Marten": { "type": "Direct", "requested": "[6.16.0, )", @@ -103,6 +114,16 @@ "resolved": "10.0.0", "contentHash": "bUubrBD6tRJE3V1kvRloYc6NymH3R5oFKjAS9e0ELNx6u0ZR+zjps9dDQyjgqN/rArzl7f+21KGszj3YRN7F2Q==" }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "yZ0818pVYQvUFW/1m+f8A1+lIAeSeQxEOdUCMIC+tqcRRKlqNjKPRKvGHPp8xLRbZBLaHfBtDslJa/sofn7J/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" + } + }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", "resolved": "10.0.0", @@ -123,6 +144,15 @@ "Microsoft.Extensions.Primitives": "10.0.0" } }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "qbi6lg6dyvydBvpjXeBx3wLPvmgXWn3nwkKBOsgEck6w++BEsBcv8YhdcczME16Oq+a6wkdtna9/qCEkxsNw5A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.ObjectPool": "10.0.9" + } + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "10.0.9", @@ -209,6 +239,14 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" } }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "WrSK0FGHSjSY4Pg4kd9Du8CUDwrgFTwHH1Y+N7sjyT6RFMU+G3coQ/Uq9PMGdQcy+DVKSMGHjfmWY47ZoYvhNA==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.9" + } + }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", "resolved": "10.0.9", @@ -228,6 +266,14 @@ "Microsoft.Extensions.Options": "10.0.9" } }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "Kc4XbbxtCzvn2P/fKF48xLWPbp3N5DV0MkJlgPJHEb47PUqCJ6kEl/JTKfpmP7QLuwYjx2QxKTdtwXfpjgA6KQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { "type": "Transitive", "resolved": "10.0.0", @@ -235,10 +281,10 @@ }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "resolved": "10.0.9", + "contentHash": "Oxn4vqDk+EwceTMpZxVm7L/UZEAM1qIQlNP1+7tBZckD+P4SKrm/5X4gMTPCTdpnau/xY8Sb4/0d6onomSg4ZA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.FileProviders.Physical": { @@ -287,14 +333,23 @@ }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", + "resolved": "10.0.9", + "contentHash": "Xd/2F+uWblTiUp+ssaDZN2ea4vmnHmW6PXugmqBHumyhqVkyeh6RJ3S2Zo/F+1bXIL/KuGqe2pKv6UiGOc1KeQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "1m1zdBGW5J8MOxdZpJ5mUqdSaamRrYkpmfnEfeDmlZnglpfgDztcKOBU6cVsmn90AB7Ji2w8USvEsJ8PahbBWQ==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.9", + "Microsoft.Extensions.Telemetry": "10.7.0" } }, "Microsoft.Extensions.Logging": { @@ -317,17 +372,17 @@ }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "j8zcwhS6bYB6FEfaY3nYSgHdpiL2T+/V3xjpHtslVAegyI1JUbB9yAt/BFdvZdsNbY0Udm4xFtvfT/hUwcOOOg==", + "resolved": "10.0.9", + "contentHash": "bUth5ip7YsZMXWZS42IRTI0zDrPEqdE+xnsmcL0Pk784grWKApDvc5UoMi2tP2qYJ5ylFzeVDuDu08sFATq1bg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" } }, "Microsoft.Extensions.Logging.Console": { @@ -378,8 +433,8 @@ }, "Microsoft.Extensions.ObjectPool": { "type": "Transitive", - "resolved": "10.0.2", - "contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ==" + "resolved": "10.0.9", + "contentHash": "KZENCkfqO7Ciax6goUWQHDSxKH+x763hkBWMz9KpE87EyKW+EKEas9EFe9i1KgtQShG8KwKxaeJ5gd9sj6TuTQ==" }, "Microsoft.Extensions.Options": { "type": "Transitive", @@ -407,6 +462,42 @@ "resolved": "10.0.9", "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "ldUrtRzWSnfe5945xoSxDXxoWXnIKn19E74Huieyp76XW/BjRk3bhkILSSib1NKdZ2lpi/ReTvOpNGcJ8hkOxw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.9", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.7.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9", + "Microsoft.Extensions.Telemetry.Abstractions": "10.7.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "vZFNVvkz+7IABEv4klzPX/ZdmZQ6TrcReKUfeYIrQ2bbnvEcgZtG1RmOGeWkc1hFg8cq9sAgUc2lHCqlXSqf5A==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.7.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.7.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.9", + "Microsoft.Extensions.ObjectPool": "10.0.9", + "Microsoft.Extensions.Telemetry.Abstractions": "10.7.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "ir1QKShZzfEmqO9LUWESVMUDZdnxYBSKQyulYPMeaye531lAuT8YTotthx+htrrS8hZY52HANeqowLQyCYBCZg==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.7.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.ObjectPool": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", "resolved": "3.0.1", @@ -453,6 +544,25 @@ "resolved": "8.6.5", "contentHash": "t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg==" }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, "Spectre.Console": { "type": "Transitive", "resolved": "0.55.0", @@ -471,6 +581,11 @@ "resolved": "10.0.0", "contentHash": "uaFRda9NjtbJRkdx311eXlAA3n2em7223c1A8d1VWyl+4FL9vkG7y2lpPfBU9HYdj/9KgdRNdn1vFK8ZYCYT/A==" }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, "Weasel.Core": { "type": "Transitive", "resolved": "9.3.0", diff --git a/src/FinanceApp/packages.lock.json b/src/FinanceApp/packages.lock.json index 076474c..82cf0f7 100644 --- a/src/FinanceApp/packages.lock.json +++ b/src/FinanceApp/packages.lock.json @@ -352,15 +352,6 @@ "Microsoft.Extensions.Telemetry": "10.7.0" } }, - "Microsoft.Extensions.Http.Resilience": { - "type": "Transitive", - "resolved": "10.7.0", - "contentHash": "3Lip0MkBVPtqU1wCM2M2Y8CzsqxA1BanVNMV9LQYMTwBgy25NDe6U9nSk6SfmsxXlh77iwSQuPsyjt5mxrOGmQ==", - "dependencies": { - "Microsoft.Extensions.Http.Diagnostics": "10.7.0", - "Microsoft.Extensions.Resilience": "10.7.0" - } - }, "Microsoft.Extensions.Resilience": { "type": "Transitive", "resolved": "10.7.0", @@ -682,6 +673,7 @@ "dependencies": { "FinanceApp.Domain": "[1.0.0, )", "Marten": "[9.12.0, )", + "Microsoft.Extensions.Http.Resilience": "[10.7.0, )", "WolverineFx.Marten": "[6.16.0, )" } }, @@ -698,6 +690,16 @@ "WolverineFx.Marten": "[6.16.0, )" } }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.7.0, )", + "resolved": "10.7.0", + "contentHash": "3Lip0MkBVPtqU1wCM2M2Y8CzsqxA1BanVNMV9LQYMTwBgy25NDe6U9nSk6SfmsxXlh77iwSQuPsyjt5mxrOGmQ==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.7.0", + "Microsoft.Extensions.Resilience": "10.7.0" + } + }, "WolverineFx.Marten": { "type": "CentralTransitive", "requested": "[6.16.0, )", diff --git a/tests/FinanceApp.Categorization.Tests/packages.lock.json b/tests/FinanceApp.Categorization.Tests/packages.lock.json index 60d6903..1e32aa3 100644 --- a/tests/FinanceApp.Categorization.Tests/packages.lock.json +++ b/tests/FinanceApp.Categorization.Tests/packages.lock.json @@ -123,6 +123,16 @@ "resolved": "18.7.0", "contentHash": "+wFfx9s7D9wegM0RziXMj2kvYDT4qcqXXtyjiQwSZOGQ2wwcOAJQcD6eQXk02jt0MvRNawtp8TJxTrV+wD8X1g==" }, + "Microsoft.Extensions.AmbientMetadata.Application": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "yZ0818pVYQvUFW/1m+f8A1+lIAeSeQxEOdUCMIC+tqcRRKlqNjKPRKvGHPp8xLRbZBLaHfBtDslJa/sofn7J/Q==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" + } + }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", "resolved": "10.0.0", @@ -143,6 +153,15 @@ "Microsoft.Extensions.Primitives": "10.0.0" } }, + "Microsoft.Extensions.Compliance.Abstractions": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "qbi6lg6dyvydBvpjXeBx3wLPvmgXWn3nwkKBOsgEck6w++BEsBcv8YhdcczME16Oq+a6wkdtna9/qCEkxsNw5A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.ObjectPool": "10.0.9" + } + }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "10.0.9", @@ -229,6 +248,14 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" } }, + "Microsoft.Extensions.DependencyInjection.AutoActivation": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "WrSK0FGHSjSY4Pg4kd9Du8CUDwrgFTwHH1Y+N7sjyT6RFMU+G3coQ/Uq9PMGdQcy+DVKSMGHjfmWY47ZoYvhNA==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.9" + } + }, "Microsoft.Extensions.Diagnostics": { "type": "Transitive", "resolved": "10.0.9", @@ -248,6 +275,14 @@ "Microsoft.Extensions.Options": "10.0.9" } }, + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "Kc4XbbxtCzvn2P/fKF48xLWPbp3N5DV0MkJlgPJHEb47PUqCJ6kEl/JTKfpmP7QLuwYjx2QxKTdtwXfpjgA6KQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9" + } + }, "Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions": { "type": "Transitive", "resolved": "10.0.0", @@ -255,10 +290,10 @@ }, "Microsoft.Extensions.FileProviders.Abstractions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "resolved": "10.0.9", + "contentHash": "Oxn4vqDk+EwceTMpZxVm7L/UZEAM1qIQlNP1+7tBZckD+P4SKrm/5X4gMTPCTdpnau/xY8Sb4/0d6onomSg4ZA==", "dependencies": { - "Microsoft.Extensions.Primitives": "10.0.0" + "Microsoft.Extensions.Primitives": "10.0.9" } }, "Microsoft.Extensions.FileProviders.Physical": { @@ -307,14 +342,23 @@ }, "Microsoft.Extensions.Hosting.Abstractions": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", + "resolved": "10.0.9", + "contentHash": "Xd/2F+uWblTiUp+ssaDZN2ea4vmnHmW6PXugmqBHumyhqVkyeh6RJ3S2Zo/F+1bXIL/KuGqe2pKv6UiGOc1KeQ==", "dependencies": { - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", - "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.9", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9" + } + }, + "Microsoft.Extensions.Http.Diagnostics": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "1m1zdBGW5J8MOxdZpJ5mUqdSaamRrYkpmfnEfeDmlZnglpfgDztcKOBU6cVsmn90AB7Ji2w8USvEsJ8PahbBWQ==", + "dependencies": { + "Microsoft.Extensions.Http": "10.0.9", + "Microsoft.Extensions.Telemetry": "10.7.0" } }, "Microsoft.Extensions.Logging": { @@ -337,17 +381,17 @@ }, "Microsoft.Extensions.Logging.Configuration": { "type": "Transitive", - "resolved": "10.0.0", - "contentHash": "j8zcwhS6bYB6FEfaY3nYSgHdpiL2T+/V3xjpHtslVAegyI1JUbB9yAt/BFdvZdsNbY0Udm4xFtvfT/hUwcOOOg==", + "resolved": "10.0.9", + "contentHash": "bUth5ip7YsZMXWZS42IRTI0zDrPEqdE+xnsmcL0Pk784grWKApDvc5UoMi2tP2qYJ5ylFzeVDuDu08sFATq1bg==", "dependencies": { - "Microsoft.Extensions.Configuration": "10.0.0", - "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", - "Microsoft.Extensions.Configuration.Binder": "10.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", - "Microsoft.Extensions.Logging": "10.0.0", - "Microsoft.Extensions.Logging.Abstractions": "10.0.0", - "Microsoft.Extensions.Options": "10.0.0", - "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + "Microsoft.Extensions.Configuration": "10.0.9", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.9", + "Microsoft.Extensions.Configuration.Binder": "10.0.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9", + "Microsoft.Extensions.Logging": "10.0.9", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9" } }, "Microsoft.Extensions.Logging.Console": { @@ -398,8 +442,8 @@ }, "Microsoft.Extensions.ObjectPool": { "type": "Transitive", - "resolved": "10.0.2", - "contentHash": "kpCp4m7nwJVBcRKWXYHdVK/W0dkKyyFOjCmKVdO+zKThWvUxP1V+jVEP9FGpqRu4GPl9041SEXu2f+U/l825nQ==" + "resolved": "10.0.9", + "contentHash": "KZENCkfqO7Ciax6goUWQHDSxKH+x763hkBWMz9KpE87EyKW+EKEas9EFe9i1KgtQShG8KwKxaeJ5gd9sj6TuTQ==" }, "Microsoft.Extensions.Options": { "type": "Transitive", @@ -427,6 +471,42 @@ "resolved": "10.0.9", "contentHash": "fmEbAUFsaIKirgLt/lYhuFRBwhcSJN31jjHgCdbQxJiWOum6EdLjkbgGuukSP9z/a+9LibaxII/kF+GwOXgC4g==" }, + "Microsoft.Extensions.Resilience": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "ldUrtRzWSnfe5945xoSxDXxoWXnIKn19E74Huieyp76XW/BjRk3bhkILSSib1NKdZ2lpi/ReTvOpNGcJ8hkOxw==", + "dependencies": { + "Microsoft.Extensions.Diagnostics": "10.0.9", + "Microsoft.Extensions.Diagnostics.ExceptionSummarization": "10.7.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.9", + "Microsoft.Extensions.Telemetry.Abstractions": "10.7.0", + "Polly.Extensions": "8.4.2", + "Polly.RateLimiting": "8.4.2" + } + }, + "Microsoft.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "vZFNVvkz+7IABEv4klzPX/ZdmZQ6TrcReKUfeYIrQ2bbnvEcgZtG1RmOGeWkc1hFg8cq9sAgUc2lHCqlXSqf5A==", + "dependencies": { + "Microsoft.Extensions.AmbientMetadata.Application": "10.7.0", + "Microsoft.Extensions.DependencyInjection.AutoActivation": "10.7.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.9", + "Microsoft.Extensions.ObjectPool": "10.0.9", + "Microsoft.Extensions.Telemetry.Abstractions": "10.7.0" + } + }, + "Microsoft.Extensions.Telemetry.Abstractions": { + "type": "Transitive", + "resolved": "10.7.0", + "contentHash": "ir1QKShZzfEmqO9LUWESVMUDZdnxYBSKQyulYPMeaye531lAuT8YTotthx+htrrS8hZY52HANeqowLQyCYBCZg==", + "dependencies": { + "Microsoft.Extensions.Compliance.Abstractions": "10.7.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.9", + "Microsoft.Extensions.ObjectPool": "10.0.9", + "Microsoft.Extensions.Options": "10.0.9" + } + }, "Microsoft.IO.RecyclableMemoryStream": { "type": "Transitive", "resolved": "3.0.1", @@ -527,6 +607,25 @@ "resolved": "8.6.5", "contentHash": "t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg==" }, + "Polly.Extensions": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "GZ9vRVmR0jV2JtZavt+pGUsQ1O1cuRKG7R7VOZI6ZDy9y6RNPvRvXK1tuS4ffUrv8L0FTea59oEuQzgS0R7zSA==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Polly.Core": "8.4.2" + } + }, + "Polly.RateLimiting": { + "type": "Transitive", + "resolved": "8.4.2", + "contentHash": "ehTImQ/eUyO07VYW2WvwSmU9rRH200SKJ/3jku9rOkyWE0A2JxNFmAVms8dSn49QLSjmjFRRSgfNyOgr/2PSmA==", + "dependencies": { + "Polly.Core": "8.4.2", + "System.Threading.RateLimiting": "8.0.0" + } + }, "Spectre.Console": { "type": "Transitive", "resolved": "0.55.0", @@ -545,6 +644,11 @@ "resolved": "10.0.0", "contentHash": "uaFRda9NjtbJRkdx311eXlAA3n2em7223c1A8d1VWyl+4FL9vkG7y2lpPfBU9HYdj/9KgdRNdn1vFK8ZYCYT/A==" }, + "System.Threading.RateLimiting": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "7mu9v0QDv66ar3DpGSZHg9NuNcxDaaAcnMULuZlaTpP9+hwXhrxNGsF5GmLkSHxFdb5bBc1TzeujsRgTrPWi+Q==" + }, "Weasel.Core": { "type": "Transitive", "resolved": "9.3.0", @@ -677,6 +781,7 @@ "Marten": "[9.12.0, )", "Microsoft.Extensions.DependencyInjection.Abstractions": "[10.0.9, )", "Microsoft.Extensions.Http": "[10.0.9, )", + "Microsoft.Extensions.Http.Resilience": "[10.7.0, )", "WolverineFx.Marten": "[6.16.0, )" } }, @@ -728,6 +833,17 @@ "Microsoft.Extensions.Options": "10.0.9" } }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.7.0, )", + "resolved": "10.7.0", + "contentHash": "3Lip0MkBVPtqU1wCM2M2Y8CzsqxA1BanVNMV9LQYMTwBgy25NDe6U9nSk6SfmsxXlh77iwSQuPsyjt5mxrOGmQ==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.7.0", + "Microsoft.Extensions.ObjectPool": "10.0.9", + "Microsoft.Extensions.Resilience": "10.7.0" + } + }, "WolverineFx.Marten": { "type": "CentralTransitive", "requested": "[6.16.0, )", diff --git a/tests/FinanceApp.Tests/packages.lock.json b/tests/FinanceApp.Tests/packages.lock.json index decefd5..146748b 100644 --- a/tests/FinanceApp.Tests/packages.lock.json +++ b/tests/FinanceApp.Tests/packages.lock.json @@ -384,15 +384,6 @@ "Microsoft.Extensions.Telemetry": "10.7.0" } }, - "Microsoft.Extensions.Http.Resilience": { - "type": "Transitive", - "resolved": "10.7.0", - "contentHash": "3Lip0MkBVPtqU1wCM2M2Y8CzsqxA1BanVNMV9LQYMTwBgy25NDe6U9nSk6SfmsxXlh77iwSQuPsyjt5mxrOGmQ==", - "dependencies": { - "Microsoft.Extensions.Http.Diagnostics": "10.7.0", - "Microsoft.Extensions.Resilience": "10.7.0" - } - }, "Microsoft.Extensions.Resilience": { "type": "Transitive", "resolved": "10.7.0", @@ -854,6 +845,7 @@ "dependencies": { "FinanceApp.Domain": "[1.0.0, )", "Marten": "[9.12.0, )", + "Microsoft.Extensions.Http.Resilience": "[10.7.0, )", "WolverineFx.Marten": "[6.16.0, )" } }, @@ -923,6 +915,16 @@ "Weasel.Postgresql": "9.3.0" } }, + "Microsoft.Extensions.Http.Resilience": { + "type": "CentralTransitive", + "requested": "[10.7.0, )", + "resolved": "10.7.0", + "contentHash": "3Lip0MkBVPtqU1wCM2M2Y8CzsqxA1BanVNMV9LQYMTwBgy25NDe6U9nSk6SfmsxXlh77iwSQuPsyjt5mxrOGmQ==", + "dependencies": { + "Microsoft.Extensions.Http.Diagnostics": "10.7.0", + "Microsoft.Extensions.Resilience": "10.7.0" + } + }, "Microsoft.VisualStudio.Azure.Containers.Tools.Targets": { "type": "CentralTransitive", "requested": "[1.23.0, )",