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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ Notable changes for people who install the app. Every release, with its installe
- **Sharing:** received / paid with no account (cash or outside the app), and "Paid for me" on a person's card.
- **Desktop app:** the MCP server for AI clients (Claude Code, Claude Desktop) runs inside the app; uninstalling on
Windows can delete the data too.
- Accounts and people can be **deleted for good**, after showing what goes with them.
- Accounts, people, custom categories, goals and manually valued assets can be **deleted for good**, after showing
what goes with them (a category in use moves its movements to another one first).

### Changed

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Finance.Application.Abstractions;
using Microsoft.EntityFrameworkCore;
using SharedKernel;

namespace Finance.Application.Buckets;

/// <summary>What deleting an allocation bucket touches.</summary>
public sealed record BucketDeletionPreview(Guid Id, string Name, bool IsSystem, int Transactions, int Recurring,
int LinkedAccounts, int BudgetTargets);

/// <summary>
/// Deleting an allocation bucket for good. Its savings and investment entries, recurring items and the broker
/// accounts linked to it move to another bucket of the same group; its budget targets and monthly checks go. Built-in
/// buckets come back with the app, so they can only be archived.
/// </summary>
public static class BucketDeletion
{
public static readonly Error BuiltIn = Error.Validation("Bucket.Delete",
"Built-in buckets can be archived, not deleted.");

public static readonly Error MoveRequired = Error.Validation("Bucket.MoveTo",
"This bucket is in use: choose another bucket of the same group to move it to.");

public static async Task<Result<BucketDeletionPreview>> PreviewAsync(IFinanceDb db, Guid id, CancellationToken ct)
{
var bucket = await db.Buckets.AsNoTracking().FirstOrDefaultAsync(b => b.Id == id, ct);
if (bucket is null)
{
return BucketEndpoints.NotFound;
}

return new BucketDeletionPreview(bucket.Id, bucket.Name, bucket.IsSystem,
await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.BucketId == id, ct),
await db.RecurringTransactions.CountAsync(r => r.BucketId == id, ct),
await db.Accounts.CountAsync(a => a.AllocationBucketId == id, ct),
await db.Budgets.SelectMany(b => b.Items).CountAsync(i => i.BucketId == id, ct));
}

public static async Task<Result<BucketDeletionPreview>> DeleteAsync(IFinanceDb db, Guid id, Guid? moveTo,
CancellationToken ct)
{
var preview = await PreviewAsync(db, id, ct);
if (preview.IsFailure)
{
return preview;
}

var p = preview.Value;
if (p.IsSystem)
{
return BuiltIn;
}

var bucket = await db.Buckets.FirstAsync(b => b.Id == id, ct);
var target = moveTo is null
? null
: await db.Buckets.FirstOrDefaultAsync(b => b.Id == moveTo && b.Id != id && b.ArchivedAtUtc == null &&
b.Group == bucket.Group, ct);
if ((p.Transactions > 0 || p.Recurring > 0) && target is null)
{
return MoveRequired;
}

foreach (var t in await db.Transactions.IgnoreQueryFilters().Where(t => t.BucketId == id).ToListAsync(ct))
{
t.MoveBucket(id, target!.Id);
}

foreach (var r in await db.RecurringTransactions.Where(r => r.BucketId == id).ToListAsync(ct))
{
r.MoveBucket(id, target!.Id);
}

foreach (var account in await db.Accounts.Where(a => a.AllocationBucketId == id).ToListAsync(ct))
{
account.SetAllocationBucket(target?.Id);
}

foreach (var budget in await db.Budgets.Include(b => b.Items)
.Where(b => b.Items.Any(i => i.BucketId == id)).ToListAsync(ct))
{
budget.RemoveTargetsOf(id);
}

db.AllocationChecks.RemoveRange(await db.AllocationChecks.Where(c => c.BucketId == id).ToListAsync(ct));
db.Buckets.Remove(bucket);
await db.SaveChangesAsync(ct);
return p;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ public static void MapBuckets(this IEndpointRouteBuilder app)
return Results.NoContent();
});

// Deleting for good: what it touches first, then the deletion (in use: moveTo another bucket of its group).
group.MapGet("/{id:guid}/deletion", async (Guid id, IFinanceDb db, CancellationToken ct) =>
(await BucketDeletion.PreviewAsync(db, id, ct)).ToHttp(Results.Ok));
group.MapDelete("/{id:guid}", async (Guid id, Guid? moveTo, IFinanceDb db, CancellationToken ct) =>
(await BucketDeletion.DeleteAsync(db, id, moveTo, ct)).ToHttp(Results.Ok));

group.MapPost("/{id:guid}/archive", async (Guid id, IFinanceDb db, TimeProvider clock, CancellationToken ct) =>
{
var bucket = await db.Buckets.FindAsync([id], ct);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using Finance.Application.Abstractions;
using Microsoft.EntityFrameworkCore;
using SharedKernel;

namespace Finance.Application.Categories;

/// <summary>What deleting a category touches: what must move to another one, and its sub-categories.</summary>
public sealed record CategoryDeletionPreview(Guid Id, string Name, bool IsSystem, int Transactions, int Recurring,
int Subcategories, int BudgetLimits);

/// <summary>
/// Deleting a category for good, unlike archiving it. Its movements and recurring items (whole or one split line)
/// move to another category of the same kind, its sub-categories move up a level and its budget limits go. Built-in
/// categories come back with the app, so they can only be archived (or renamed).
/// </summary>
public static class CategoryDeletion
{
public static readonly Error BuiltIn = Error.Validation("Category.Delete",
"Built-in categories can be renamed or archived, not deleted.");

public static readonly Error MoveRequired = Error.Validation("Category.MoveTo",
"This category is in use: choose another category of the same kind to move it to.");

public static async Task<Result<CategoryDeletionPreview>> PreviewAsync(IFinanceDb db, Guid id, CancellationToken ct)
{
var category = await db.Categories.AsNoTracking().FirstOrDefaultAsync(c => c.Id == id, ct);
if (category is null)
{
return CategoryEndpoints.NotFound;
}

return new CategoryDeletionPreview(category.Id, category.Name, category.IsSystem,
await db.Transactions.IgnoreQueryFilters()
.CountAsync(t => t.CategoryId == id || t.Splits.Any(s => s.CategoryId == id), ct),
await db.RecurringTransactions.CountAsync(r => r.CategoryId == id || r.Splits.Any(s => s.CategoryId == id), ct),
await db.Categories.CountAsync(c => c.ParentId == id, ct),
await db.Budgets.SelectMany(b => b.Items).CountAsync(i => i.CategoryId == id, ct));
}

public static async Task<Result<CategoryDeletionPreview>> DeleteAsync(IFinanceDb db, Guid id, Guid? moveTo,
CancellationToken ct)
{
var preview = await PreviewAsync(db, id, ct);
if (preview.IsFailure)
{
return preview;
}

var p = preview.Value;
if (p.IsSystem)
{
return BuiltIn;
}

var category = await db.Categories.FirstAsync(c => c.Id == id, ct);
var target = moveTo is null
? null
: await db.Categories.FirstOrDefaultAsync(c => c.Id == moveTo && c.Id != id && c.ArchivedAtUtc == null &&
c.Type == category.Type, ct);
if ((p.Transactions > 0 || p.Recurring > 0) && target is null)
{
return MoveRequired;
}

foreach (var t in await db.Transactions.IgnoreQueryFilters()
.Where(t => t.CategoryId == id || t.Splits.Any(s => s.CategoryId == id)).ToListAsync(ct))
{
t.MoveCategory(id, target!.Id);
}

foreach (var r in await db.RecurringTransactions
.Where(r => r.CategoryId == id || r.Splits.Any(s => s.CategoryId == id)).ToListAsync(ct))
{
r.MoveCategory(id, target!.Id);
}

foreach (var child in await db.Categories.Where(c => c.ParentId == id).ToListAsync(ct))
{
child.MoveUnder(category.ParentId);
}

foreach (var budget in await db.Budgets.Include(b => b.Items)
.Where(b => b.Items.Any(i => i.CategoryId == id)).ToListAsync(ct))
{
budget.RemoveTargetsOf(id);
}

db.Categories.Remove(category);
await db.SaveChangesAsync(ct);
return p;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ public static void MapCategories(this IEndpointRouteBuilder app)
});

// Categories are archived rather than deleted so historical transactions keep their meaning.
// Deleting for good: what it touches first, then the deletion (in use: moveTo another category of its kind).
group.MapGet("/{id:guid}/deletion", async (Guid id, IFinanceDb db, CancellationToken ct) =>
(await CategoryDeletion.PreviewAsync(db, id, ct)).ToHttp(Results.Ok));
group.MapDelete("/{id:guid}", async (Guid id, Guid? moveTo, IFinanceDb db, CancellationToken ct) =>
(await CategoryDeletion.DeleteAsync(db, id, moveTo, ct)).ToHttp(Results.Ok));

group.MapPost("/{id:guid}/archive", async (Guid id, IFinanceDb db, TimeProvider clock, CancellationToken ct) =>
{
var category = await db.Categories.FindAsync([id], ct);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ await CheckAccountAsync(db, req.AccountId, ct) is { } error
? ResultHttp.Problem(error)
: (await UpdateAsync(db, id, req, ct)).ToHttp(_ => Results.NoContent())).Validate<GoalRequest>();

// Deleting for good: savings entries that pointed at it simply no longer name a goal.
group.MapDelete("/{id:guid}", async (Guid id, IFinanceDb db, CancellationToken ct) =>
{
var goal = await db.Goals.FirstOrDefaultAsync(g => g.Id == id, ct);
if (goal is null)
{
return ResultHttp.Problem(NotFound);
}

db.Goals.Remove(goal);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});

group.MapPost("/{id:guid}/archive", async (Guid id, IFinanceDb db, TimeProvider clock, CancellationToken ct) =>
{
var goal = await db.Goals.FindAsync([id], ct);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ public static Result<Budget> Create(YearMonth effectiveFrom, IEnumerable<BudgetI
return result.IsSuccess ? budget : Result.Failure<Budget>(result.Error);
}

/// <summary>A category or bucket is being deleted: its limits and targets go (the rest of the plan stays).</summary>
public bool RemoveTargetsOf(Guid id) => _items.RemoveAll(i => i.CategoryId == id || i.BucketId == id) > 0;

public Result ReplaceItems(IEnumerable<BudgetItemSpec> specs)
{
var list = specs.ToList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,8 @@ public void Update(string name, ExpenseNature? nature, string? color, string? ic

public void Archive(DateTimeOffset now) => ArchivedAtUtc = now;

/// <summary>Its parent is being deleted: it moves up to the parent's parent (or the top level).</summary>
public void MoveUnder(Guid? parentId) => ParentId = parentId;

public void Restore() => ArchivedAtUtc = null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,29 @@ public static decimal MonthlyEquivalent(decimal amount, RecurrenceFrequency freq

public void SetActive(bool active) => IsActive = active;

/// <summary>A category is being deleted: the template (or its split lines) uses <paramref name="to"/> instead.</summary>
public void MoveCategory(Guid from, Guid to)
{
if (CategoryId == from)
{
CategoryId = to;
}

foreach (var line in _splits.Where(l => l.CategoryId == from))
{
line.MoveTo(to);
}
}

/// <summary>An allocation bucket is being deleted: the template counts towards <paramref name="to"/> instead.</summary>
public void MoveBucket(Guid from, Guid to)
{
if (BucketId == from)
{
BucketId = to;
}
}

/// <summary>A person no longer shares it (they were deleted): their part becomes the owner's.</summary>
public bool RemoveShare(Guid personId) => _shares.RemoveAll(s => s.PersonId == personId) > 0;

Expand Down Expand Up @@ -287,6 +310,8 @@ private RecurringSplit() { }
public decimal Amount { get; private set; }
public string? Note { get; private set; }

internal void MoveTo(Guid categoryId) => CategoryId = categoryId;

internal static RecurringSplit Create(int position, SplitLine line) => new()
{
Id = Guid.CreateVersion7(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,29 @@ private Result ApplyShare(Transaction expense, Guid personAccountId, decimal amo

public void SoftDelete(DateTimeOffset now) => DeletedAtUtc = now;

/// <summary>A category is being deleted: what was in it (whole or one split line) moves to <paramref name="to"/>.</summary>
public void MoveCategory(Guid from, Guid to)
{
if (CategoryId == from)
{
CategoryId = to;
}

foreach (var line in _splits.Where(l => l.CategoryId == from))
{
line.MoveTo(to);
}
}

/// <summary>An allocation bucket is being deleted: the entry counts towards <paramref name="to"/> instead.</summary>
public void MoveBucket(Guid from, Guid to)
{
if (BucketId == from)
{
BucketId = to;
}
}

public void Restore() => DeletedAtUtc = null;

private Result Apply(TransactionDraft d)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ private TransactionSplit() { }
public ExpenseNature? Nature { get; private set; }
public string? Note { get; private set; }

internal void MoveTo(Guid categoryId) => CategoryId = categoryId;

internal static TransactionSplit Create(int position, SplitLine line, decimal baseAmount, ExpenseNature? nature) => new()
{
Id = Guid.CreateVersion7(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,20 @@ await db.TargetAllocations.AsNoTracking()
(await ValueAssetAsync(db, id, req.Value, req.On ?? DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime),
false, ct)).ToHttp(_ => Results.NoContent()));

// Deleting for good, with its valuations (archiving keeps them).
assets.MapDelete("/{id:guid}", async (Guid id, IInvestmentsDb db, CancellationToken ct) =>
{
var asset = await db.ManualAssets.Include(a => a.Valuations).FirstOrDefaultAsync(a => a.Id == id, ct);
if (asset is null)
{
return ResultHttp.Problem(Error.NotFound("Asset.NotFound", "Asset not found."));
}

db.ManualAssets.Remove(asset);
await db.SaveChangesAsync(ct);
return Results.NoContent();
});

assets.MapPost("/{id:guid}/archive", async (Guid id, IInvestmentsDb db, TimeProvider clock, CancellationToken ct) =>
{
var asset = await db.ManualAssets.FindAsync([id], ct);
Expand Down
Loading
Loading