Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ monthly checklist — and rebuilds it on a database.
| **Budgets** | Versioned by month (changing the split never rewrites past months). Percent of income, fixed EUR, or "remainder" — the spreadsheet's `1 − investments − savings`. Per-category limits (e.g. Restaurants €150). The monthly allocation works like recurring items: each bucket shows done / partial from what was set aside — including deposits synced at a linked broker — and one click records the rest. |
| **Reports** | Monthly summary vs previous month / 12-month average / budget, annual summary (every column of the old "Resumo Anual"), category breakdown, trends. All figures computed on the server with `decimal`. |
| **Recurring** | Salary, rent, subscriptions propose *expected* transactions; nothing is booked until you confirm, edit or skip. |
| **Goals** | Emergency fund, trips, house deposit — progress from linked savings, monthly amount needed. |
| **Goals** | Emergency fund, trips, house deposit — progress from a savings account the goal follows (its balance, interest included) or from savings linked to it; monthly amount needed. Investments never count towards goals. |
| **Excel migration** | Analyze → map → validate → preview → import → undo. Reconciles every month against the workbook's own totals to the cent before anything is written. Idempotent re-imports. |
| **Investments** | Read-only sync from Trading 212 (Public API + CSV), Interactive Brokers (Flex Web Service) and Binance (spot + Simple Earn, with Earn rewards as income; the key is checked to be read-only). Consolidated portfolio by ISIN with per-broker holdings, allocation vs target, dividends with withholding tax, TWR and XIRR, net worth history. [Setup](docs/broker-integrations.md). |
| **Export** | Real .xlsx (Excel tables, native charts, schema-validated), CSV per dataset (Excel-PT dialect), complete JSON archive with restore. |
Expand Down
13 changes: 10 additions & 3 deletions backend/src/Modules/Ai/Ai.Application/Tools/WriteTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,8 @@ private async Task<WriteOutput> UpsertGoalAsync(WriteContext ctx, CancellationTo
var request = new GoalRequest(
name ?? existing?.Name ?? throw new ToolArgumentException("name is required to create a goal."),
target ?? existing?.TargetAmount ?? throw new ToolArgumentException("target_amount is required to create a goal."),
targetDate ?? existing?.TargetDate, existing?.StartingAmount, existing?.ManualCurrentAmount, existing?.Icon);
targetDate ?? existing?.TargetDate, existing?.StartingAmount, existing?.ManualCurrentAmount, existing?.Icon,
existing?.AccountId);
await CheckAsync(goalValidator, request, ct);

var saved = existing is null
Expand All @@ -479,6 +480,12 @@ private async Task<WriteOutput> AddToGoalAsync(WriteContext ctx, CancellationTok
await ActiveGoalAsync(id, ct);

var goal = await finance.Goals.FirstAsync(g => g.Id == id, ct);
if (goal.AccountId is not null)
{
throw new ToolArgumentException(
"This goal follows an account's balance; record a savings transaction into that account instead.");
}

var before = new { startingAmount = goal.StartingAmount, manualCurrentAmount = goal.ManualCurrentAmount };
goal.AddToCurrent(amount);
await finance.SaveChangesAsync(ct);
Expand Down Expand Up @@ -610,11 +617,11 @@ private async Task<FinancialGoalView> ActiveGoalAsync(Guid id, CancellationToken
return goal.ArchivedAtUtc is not null
? throw new ToolRefusedException(403, "This goal is archived; restore it in the app before changing it.")
: new FinancialGoalView(goal.Id, goal.Name, goal.TargetAmount, goal.TargetDate, goal.StartingAmount,
goal.ManualCurrentAmount, goal.Icon);
goal.ManualCurrentAmount, goal.Icon, goal.AccountId);
}

private sealed record FinancialGoalView(Guid Id, string Name, decimal TargetAmount, DateOnly? TargetDate,
decimal StartingAmount, decimal? ManualCurrentAmount, string? Icon);
decimal StartingAmount, decimal? ManualCurrentAmount, string? Icon, Guid? AccountId);

private async Task<(ManualHolding Holding, Account Location)> HoldingAsync(Guid id, CancellationToken ct)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ public async Task<Result<ImportResult>> ImportAsync(Stream stream, CancellationT
var goal = FinancialGoal.Create(name, Dec(g, "targetAmount") ?? 0,
Str(g, "targetDate") is { } td ? DateOnly.Parse(td, System.Globalization.CultureInfo.InvariantCulture) : null,
Dec(g, "startingAmount") ?? 0, Dec(g, "manualCurrentAmount"), Str(g, "icon"));
// Accounts are imported above, so the account a goal follows is already mapped.
goal.LinkAccount(Mapped(g, "accountId", map));
finance.Goals.Add(goal);
map[id] = goal.Id;
goals++;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ public sealed record GoalDto(
decimal? MonthlyNeeded,
string? Icon,
bool Achieved,
bool Archived);
bool Archived,
Guid? AccountId = null,
string? AccountName = null);

/// <param name="AccountId">Optional cash account whose balance becomes the goal's progress.</param>
public sealed record GoalRequest(string Name, decimal TargetAmount, DateOnly? TargetDate, decimal? StartingAmount,
decimal? ManualCurrentAmount, string? Icon);
decimal? ManualCurrentAmount, string? Icon, Guid? AccountId = null);

public sealed class GoalRequestValidator : AbstractValidator<GoalRequest>
{
Expand All @@ -45,6 +48,15 @@ public static class GoalEndpoints
{
public static readonly Error NotFound = Error.NotFound("Goal.NotFound", "Goal not found.");

public static readonly Error AccountNotLinkable = Error.Validation("Goal.Account",
"A goal can follow a savings, bank or cash account in EUR — not a broker, card or loan.");

/// <summary>Accounts whose balance can be a goal's progress: money set aside, in EUR (goals are in EUR).</summary>
public static bool CanFollow(Finance.Domain.Accounts.Account a) =>
a.Kind is Finance.Domain.Accounts.AccountKind.Savings or Finance.Domain.Accounts.AccountKind.Bank
or Finance.Domain.Accounts.AccountKind.Cash or Finance.Domain.Accounts.AccountKind.Other &&
a.Currency == Currency.Base && a.ArchivedAtUtc is null;

public static void MapGoals(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/goals").WithTags("Goals");
Expand All @@ -55,12 +67,19 @@ public static void MapGoals(this IEndpointRouteBuilder app)

group.MapPost("/", async (GoalRequest req, IFinanceDb db, CancellationToken ct) =>
{
if (await CheckAccountAsync(db, req.AccountId, ct) is { } error)
{
return ResultHttp.Problem(error);
}

var goal = await CreateAsync(db, req, ct);
return Results.Created($"/api/goals/{goal.Id}", new { goal.Id });
}).Validate<GoalRequest>();

group.MapPut("/{id:guid}", async (Guid id, GoalRequest req, IFinanceDb db, CancellationToken ct) =>
(await UpdateAsync(db, id, req, ct)).ToHttp(_ => Results.NoContent())).Validate<GoalRequest>();
await CheckAccountAsync(db, req.AccountId, ct) is { } error
? ResultHttp.Problem(error)
: (await UpdateAsync(db, id, req, ct)).ToHttp(_ => Results.NoContent())).Validate<GoalRequest>();

group.MapPost("/{id:guid}/archive", async (Guid id, IFinanceDb db, TimeProvider clock, CancellationToken ct) =>
{
Expand All @@ -76,10 +95,22 @@ public static void MapGoals(this IEndpointRouteBuilder app)
});
}

public static async Task<Error?> CheckAccountAsync(IFinanceDb db, Guid? accountId, CancellationToken ct)
{
if (accountId is not { } id)
{
return null;
}

var account = await db.Accounts.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id, ct);
return account is not null && CanFollow(account) ? null : AccountNotLinkable;
}

public static async Task<FinancialGoal> CreateAsync(IFinanceDb db, GoalRequest req, CancellationToken ct)
{
var goal = FinancialGoal.Create(req.Name, req.TargetAmount, req.TargetDate, req.StartingAmount ?? 0,
req.ManualCurrentAmount, req.Icon);
goal.LinkAccount(req.AccountId);
db.Goals.Add(goal);
await db.SaveChangesAsync(ct);
return goal;
Expand All @@ -96,6 +127,7 @@ public static async Task<Result<FinancialGoal>> UpdateAsync(IFinanceDb db, Guid

goal.Update(req.Name, req.TargetAmount, req.TargetDate, req.StartingAmount ?? 0, req.ManualCurrentAmount,
req.Icon);
goal.LinkAccount(req.AccountId);
await db.SaveChangesAsync(ct);
return goal;
}
Expand All @@ -112,10 +144,17 @@ public static async Task<IReadOnlyList<GoalDto>> ListAsync(IFinanceDb db, DateOn
.GroupBy(t => t.GoalId!.Value)
.Select(g => new { GoalId = g.Key, Total = g.Sum(t => t.BaseAmount) })
.ToDictionaryAsync(x => x.GoalId, x => x.Total, ct);
var accountIds = goals.Where(g => g.AccountId is not null).Select(g => g.AccountId!.Value).ToHashSet();
var accounts = await db.Accounts.AsNoTracking().Where(a => accountIds.Contains(a.Id)).ToListAsync(ct);
var balances = accounts.Count > 0
? await Accounts.AccountBalances.ComputeAsync(db, accounts, null, ct)
: new Dictionary<Guid, decimal>();
var names = accounts.ToDictionary(a => a.Id, a => a.Name);

return goals.Select(g =>
{
var current = g.CurrentAmount(contributions.GetValueOrDefault(g.Id));
decimal? balance = g.AccountId is { } aid && balances.TryGetValue(aid, out var b) ? b : null;
var current = g.CurrentAmount(contributions.GetValueOrDefault(g.Id), balance);
var remaining = Math.Max(0, g.TargetAmount - current);
decimal? monthly = null;
if (g.TargetDate is { } target && remaining > 0)
Expand All @@ -126,7 +165,8 @@ public static async Task<IReadOnlyList<GoalDto>> ListAsync(IFinanceDb db, DateOn

return new GoalDto(g.Id, g.Name, g.TargetAmount, g.TargetDate, g.StartingAmount, g.ManualCurrentAmount,
current, g.TargetAmount == 0 ? 0 : decimal.Round(current / g.TargetAmount, 4), remaining, monthly,
g.Icon, current >= g.TargetAmount, g.ArchivedAtUtc is not null);
g.Icon, current >= g.TargetAmount, g.ArchivedAtUtc is not null, g.AccountId,
g.AccountId is { } id ? names.GetValueOrDefault(id) : null);
}).ToList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
namespace Finance.Domain.Goals;

/// <summary>
/// A savings target. Progress = <see cref="StartingAmount"/> + savings transactions linked to the goal,
/// unless the user tracks it by hand via <see cref="ManualCurrentAmount"/>.
/// A savings target. Progress is, in order: the balance of the cash account it is linked to
/// (<see cref="AccountId"/>, e.g. a savings account kept for it — interest included), else the amount tracked by hand
/// (<see cref="ManualCurrentAmount"/>), else <see cref="StartingAmount"/> + savings transactions linked to the goal.
/// Investments never count: goals are about money set aside, not market value.
/// </summary>
public sealed class FinancialGoal : Entity, IAuditable
{
Expand All @@ -16,6 +18,9 @@ private FinancialGoal() { }
public decimal StartingAmount { get; private set; }
public decimal? ManualCurrentAmount { get; private set; }
public string? Icon { get; private set; }

/// <summary>A cash account (savings, bank, cash) whose balance is the goal's progress.</summary>
public Guid? AccountId { get; private set; }
public DateTimeOffset? AchievedAtUtc { get; private set; }
public DateTimeOffset? ArchivedAtUtc { get; private set; }
public DateTimeOffset CreatedAtUtc { get; set; }
Expand Down Expand Up @@ -43,8 +48,12 @@ public void Update(string name, decimal targetAmount, DateOnly? targetDate, deci
Icon = icon;
}

public decimal CurrentAmount(decimal linkedContributions) =>
ManualCurrentAmount ?? StartingAmount + linkedContributions;
public decimal CurrentAmount(decimal linkedContributions, decimal? accountBalance = null) =>
AccountId is not null && accountBalance is { } balance
? Math.Max(balance, 0)
: ManualCurrentAmount ?? StartingAmount + linkedContributions;

public void LinkAccount(Guid? accountId) => AccountId = accountId;

/// <summary>
/// Records money set aside for the goal outside the ledger: it raises the amount tracked by hand when the goal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
{
e.Property(x => x.Name).HasMaxLength(80);
e.Property(x => x.Icon).HasMaxLength(40);
e.HasOne<Account>().WithMany().HasForeignKey(x => x.AccountId).OnDelete(DeleteBehavior.SetNull);
});
}
}
Loading
Loading