diff --git a/CHANGELOG.md b/CHANGELOG.md index d9b0d3f..1493ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/src/Modules/Finance/Finance.Application/Buckets/BucketDeletion.cs b/backend/src/Modules/Finance/Finance.Application/Buckets/BucketDeletion.cs new file mode 100644 index 0000000..f5bd8d8 --- /dev/null +++ b/backend/src/Modules/Finance/Finance.Application/Buckets/BucketDeletion.cs @@ -0,0 +1,90 @@ +using Finance.Application.Abstractions; +using Microsoft.EntityFrameworkCore; +using SharedKernel; + +namespace Finance.Application.Buckets; + +/// What deleting an allocation bucket touches. +public sealed record BucketDeletionPreview(Guid Id, string Name, bool IsSystem, int Transactions, int Recurring, + int LinkedAccounts, int BudgetTargets); + +/// +/// 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. +/// +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> 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> 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; + } +} diff --git a/backend/src/Modules/Finance/Finance.Application/Buckets/BucketEndpoints.cs b/backend/src/Modules/Finance/Finance.Application/Buckets/BucketEndpoints.cs index 5fa0646..eb90ee0 100644 --- a/backend/src/Modules/Finance/Finance.Application/Buckets/BucketEndpoints.cs +++ b/backend/src/Modules/Finance/Finance.Application/Buckets/BucketEndpoints.cs @@ -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); diff --git a/backend/src/Modules/Finance/Finance.Application/Categories/CategoryDeletion.cs b/backend/src/Modules/Finance/Finance.Application/Categories/CategoryDeletion.cs new file mode 100644 index 0000000..b7ec2e3 --- /dev/null +++ b/backend/src/Modules/Finance/Finance.Application/Categories/CategoryDeletion.cs @@ -0,0 +1,92 @@ +using Finance.Application.Abstractions; +using Microsoft.EntityFrameworkCore; +using SharedKernel; + +namespace Finance.Application.Categories; + +/// What deleting a category touches: what must move to another one, and its sub-categories. +public sealed record CategoryDeletionPreview(Guid Id, string Name, bool IsSystem, int Transactions, int Recurring, + int Subcategories, int BudgetLimits); + +/// +/// 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). +/// +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> 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> 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; + } +} diff --git a/backend/src/Modules/Finance/Finance.Application/Categories/CategoryEndpoints.cs b/backend/src/Modules/Finance/Finance.Application/Categories/CategoryEndpoints.cs index a7f8911..0f15214 100644 --- a/backend/src/Modules/Finance/Finance.Application/Categories/CategoryEndpoints.cs +++ b/backend/src/Modules/Finance/Finance.Application/Categories/CategoryEndpoints.cs @@ -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); diff --git a/backend/src/Modules/Finance/Finance.Application/Goals/GoalEndpoints.cs b/backend/src/Modules/Finance/Finance.Application/Goals/GoalEndpoints.cs index 10684ae..f49d9a1 100644 --- a/backend/src/Modules/Finance/Finance.Application/Goals/GoalEndpoints.cs +++ b/backend/src/Modules/Finance/Finance.Application/Goals/GoalEndpoints.cs @@ -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(); + // 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); diff --git a/backend/src/Modules/Finance/Finance.Domain/Budgets/Budget.cs b/backend/src/Modules/Finance/Finance.Domain/Budgets/Budget.cs index e6a7c89..c6a370f 100644 --- a/backend/src/Modules/Finance/Finance.Domain/Budgets/Budget.cs +++ b/backend/src/Modules/Finance/Finance.Domain/Budgets/Budget.cs @@ -46,6 +46,9 @@ public static Result Create(YearMonth effectiveFrom, IEnumerable(result.Error); } + /// A category or bucket is being deleted: its limits and targets go (the rest of the plan stays). + public bool RemoveTargetsOf(Guid id) => _items.RemoveAll(i => i.CategoryId == id || i.BucketId == id) > 0; + public Result ReplaceItems(IEnumerable specs) { var list = specs.ToList(); diff --git a/backend/src/Modules/Finance/Finance.Domain/Categories/Category.cs b/backend/src/Modules/Finance/Finance.Domain/Categories/Category.cs index 405f664..0d5b691 100644 --- a/backend/src/Modules/Finance/Finance.Domain/Categories/Category.cs +++ b/backend/src/Modules/Finance/Finance.Domain/Categories/Category.cs @@ -76,5 +76,8 @@ public void Update(string name, ExpenseNature? nature, string? color, string? ic public void Archive(DateTimeOffset now) => ArchivedAtUtc = now; + /// Its parent is being deleted: it moves up to the parent's parent (or the top level). + public void MoveUnder(Guid? parentId) => ParentId = parentId; + public void Restore() => ArchivedAtUtc = null; } diff --git a/backend/src/Modules/Finance/Finance.Domain/Recurring/RecurringTransaction.cs b/backend/src/Modules/Finance/Finance.Domain/Recurring/RecurringTransaction.cs index 12b5e82..fd1e9dd 100644 --- a/backend/src/Modules/Finance/Finance.Domain/Recurring/RecurringTransaction.cs +++ b/backend/src/Modules/Finance/Finance.Domain/Recurring/RecurringTransaction.cs @@ -116,6 +116,29 @@ public static decimal MonthlyEquivalent(decimal amount, RecurrenceFrequency freq public void SetActive(bool active) => IsActive = active; + /// A category is being deleted: the template (or its split lines) uses instead. + 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); + } + } + + /// An allocation bucket is being deleted: the template counts towards instead. + public void MoveBucket(Guid from, Guid to) + { + if (BucketId == from) + { + BucketId = to; + } + } + /// A person no longer shares it (they were deleted): their part becomes the owner's. public bool RemoveShare(Guid personId) => _shares.RemoveAll(s => s.PersonId == personId) > 0; @@ -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(), diff --git a/backend/src/Modules/Finance/Finance.Domain/Transactions/Transaction.cs b/backend/src/Modules/Finance/Finance.Domain/Transactions/Transaction.cs index f9bb83b..d98093c 100644 --- a/backend/src/Modules/Finance/Finance.Domain/Transactions/Transaction.cs +++ b/backend/src/Modules/Finance/Finance.Domain/Transactions/Transaction.cs @@ -206,6 +206,29 @@ private Result ApplyShare(Transaction expense, Guid personAccountId, decimal amo public void SoftDelete(DateTimeOffset now) => DeletedAtUtc = now; + /// A category is being deleted: what was in it (whole or one split line) moves to . + 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); + } + } + + /// An allocation bucket is being deleted: the entry counts towards instead. + public void MoveBucket(Guid from, Guid to) + { + if (BucketId == from) + { + BucketId = to; + } + } + public void Restore() => DeletedAtUtc = null; private Result Apply(TransactionDraft d) diff --git a/backend/src/Modules/Finance/Finance.Domain/Transactions/TransactionSplit.cs b/backend/src/Modules/Finance/Finance.Domain/Transactions/TransactionSplit.cs index 531253a..061a5c5 100644 --- a/backend/src/Modules/Finance/Finance.Domain/Transactions/TransactionSplit.cs +++ b/backend/src/Modules/Finance/Finance.Domain/Transactions/TransactionSplit.cs @@ -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(), diff --git a/backend/src/Modules/Investments/Investments.Application/InvestmentEndpoints.cs b/backend/src/Modules/Investments/Investments.Application/InvestmentEndpoints.cs index eec4b91..7269d94 100644 --- a/backend/src/Modules/Investments/Investments.Application/InvestmentEndpoints.cs +++ b/backend/src/Modules/Investments/Investments.Application/InvestmentEndpoints.cs @@ -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); diff --git a/backend/tests/Integration.Tests/AccountDeletionTests.cs b/backend/tests/Integration.Tests/AccountDeletionTests.cs index 36c718a..11eef6c 100644 --- a/backend/tests/Integration.Tests/AccountDeletionTests.cs +++ b/backend/tests/Integration.Tests/AccountDeletionTests.cs @@ -70,4 +70,59 @@ public async Task Deleting_a_person_or_an_account_takes_what_belongs_to_it() .ShouldNotContain(r => r.GetProperty("name").GetString() == "From the deleted bank"); (await api.Http.DeleteAsync($"/api/accounts/{Guid.NewGuid()}")).StatusCode.ShouldBe(HttpStatusCode.NotFound); } + + [Fact] + public async Task A_category_in_use_moves_what_was_in_it_and_built_ins_stay() + { + var api = await factory.OwnerAsync(); + var bank = await api.CreateAsync("/api/accounts", new { name = "Category delete bank", kind = "Bank", currency = "EUR", openingBalance = 0m }); + var mine = await api.CreateAsync("/api/categories", new { name = "Comics (delete)", type = "Expense" }); + var child = await api.CreateAsync("/api/categories", new { name = "Manga (delete)", type = "Expense", parentId = mine }); + var expense = await api.CreateAsync("/api/transactions", new + { + type = "Expense", occurredOn = "2054-04-02", amount = 12m, accountId = bank, categoryId = mine, + }); + + var preview = await api.GetJsonAsync($"/api/categories/{mine}/deletion"); + preview.GetProperty("transactions").GetInt32().ShouldBe(1); + preview.GetProperty("subcategories").GetInt32().ShouldBe(1); + (await api.Http.DeleteAsync($"/api/categories/{mine}")).StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await api.Http.DeleteAsync($"/api/categories/{mine}?moveTo={SystemCatalog.CategoryId("salary")}")) + .StatusCode.ShouldBe(HttpStatusCode.BadRequest); // an income category + (await api.Http.DeleteAsync($"/api/categories/{mine}?moveTo={Subscriptions}")).EnsureSuccessStatusCode(); + + (await api.GetJsonAsync($"/api/transactions/{expense}")).GetProperty("categoryId").GetGuid().ShouldBe(Subscriptions); + var categories = await api.GetAsync("/api/categories"); + categories.ShouldNotContain(c => c.GetProperty("id").GetGuid() == mine); + categories.Single(c => c.GetProperty("id").GetGuid() == child).GetProperty("parentId").ValueKind + .ShouldBe(JsonValueKind.Null); + (await api.Http.DeleteAsync($"/api/categories/{Subscriptions}")).StatusCode.ShouldBe(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task Buckets_goals_and_assets_can_be_deleted() + { + var api = await factory.OwnerAsync(); + var bank = await api.CreateAsync("/api/accounts", new { name = "Bucket delete bank", kind = "Bank", currency = "EUR", openingBalance = 0m }); + var buckets = await api.GetAsync("/api/buckets"); + var savings = buckets.First(b => b.GetProperty("group").GetString() == "Savings" && b.GetProperty("isSystem").GetBoolean()) + .GetProperty("id").GetGuid(); + var mine = await api.CreateAsync("/api/buckets", new { name = "Bike fund (delete)", group = "Savings" }); + var saved = await api.CreateAsync("/api/transactions", new + { + type = "Savings", occurredOn = "2054-05-02", amount = 50m, accountId = bank, bucketId = mine, + }); + (await api.Http.DeleteAsync($"/api/buckets/{mine}")).StatusCode.ShouldBe(HttpStatusCode.BadRequest); + (await api.Http.DeleteAsync($"/api/buckets/{mine}?moveTo={savings}")).EnsureSuccessStatusCode(); + (await api.GetJsonAsync($"/api/transactions/{saved}")).GetProperty("bucketId").GetGuid().ShouldBe(savings); + (await api.Http.DeleteAsync($"/api/buckets/{savings}")).StatusCode.ShouldBe(HttpStatusCode.BadRequest); + + var goal = await api.CreateAsync("/api/goals", new { name = "Delete me goal", targetAmount = 500m }); + (await api.Http.DeleteAsync($"/api/goals/{goal}")).EnsureSuccessStatusCode(); + (await api.GetAsync("/api/goals")).ShouldNotContain(g => g.GetProperty("id").GetGuid() == goal); + + var asset = await api.CreateAsync("/api/assets", new { name = "Old bike (delete)", kind = "Vehicle", value = 300m }); + (await api.Http.DeleteAsync($"/api/assets/{asset}")).EnsureSuccessStatusCode(); + (await api.GetAsync("/api/assets")).ShouldNotContain(a => a.GetProperty("id").GetGuid() == asset); + } } diff --git a/web/public/i18n/en.json b/web/public/i18n/en.json index 3bdd190..704c2d7 100644 --- a/web/public/i18n/en.json +++ b/web/public/i18n/en.json @@ -1417,6 +1417,12 @@ "sharedParts": "{{count}} shares people owe", "loan": "its loan terms", "cannotUndo": "This cannot be undone (archive it instead to keep the history).", - "done": "Deleted" + "done": "Deleted", + "askGoal": "Delete the goal “{{name}}” for good?", + "askAsset": "Delete “{{name}}” and its valuations for good?", + "askCategory": "Delete the category “{{name}}”?", + "categoryInUse": "In use (movements: {{transactions}}, recurring items: {{recurring}}): choose the category they move to.", + "moveTo": "Move to", + "categoryHint": "Its sub-categories move up a level and its budget limits are removed. This cannot be undone." } } diff --git a/web/public/i18n/pt-PT.json b/web/public/i18n/pt-PT.json index 6816ee0..541ca3a 100644 --- a/web/public/i18n/pt-PT.json +++ b/web/public/i18n/pt-PT.json @@ -1417,6 +1417,12 @@ "sharedParts": "{{count}} partes que outras pessoas devem", "loan": "os termos do empréstimo", "cannotUndo": "Não se pode desfazer (arquiva em vez disso para manter o histórico).", - "done": "Eliminado" + "done": "Eliminado", + "askGoal": "Eliminar de vez o objetivo “{{name}}”?", + "askAsset": "Eliminar de vez “{{name}}” e as suas avaliações?", + "askCategory": "Eliminar a categoria “{{name}}”?", + "categoryInUse": "Está em uso (movimentos: {{transactions}}, recorrentes: {{recurring}}): escolhe para que categoria passam.", + "moveTo": "Mover para", + "categoryHint": "As subcategorias sobem um nível e os limites de orçamento dela são removidos. Não se pode desfazer." } } diff --git a/web/src/app/core/api.ts b/web/src/app/core/api.ts index 6dcd0bb..7a0116c 100644 --- a/web/src/app/core/api.ts +++ b/web/src/app/core/api.ts @@ -114,6 +114,21 @@ export class Api { updateCategory = (id: string, body: unknown) => this.http.put(`/api/categories/${id}`, body); archiveCategory = (id: string) => this.http.post(`/api/categories/${id}/archive`, {}); + /** What deleting a category (for good) touches; in use, it needs a category to move to. */ + categoryDeletion = (id: string) => + this.http.get<{ + id: string; + name: string; + isSystem: boolean; + transactions: number; + recurring: number; + subcategories: number; + budgetLimits: number; + }>(`/api/categories/${id}/deletion`); + deleteCategory = (id: string, moveTo: string | null) => + this.http.delete(`/api/categories/${id}`, { + params: moveTo ? new HttpParams().set('moveTo', moveTo) : undefined, + }); restoreCategory = (id: string) => this.http.post(`/api/categories/${id}/restore`, {}); resetCategoryName = (id: string) => this.http.post(`/api/categories/${id}/reset-name`, {}); buckets = () => this.http.get('/api/buckets'); @@ -205,6 +220,7 @@ export class Api { createGoal = (body: unknown) => this.http.post<{ id: string }>('/api/goals', body); updateGoal = (id: string, body: unknown) => this.http.put(`/api/goals/${id}`, body); archiveGoal = (id: string) => this.http.post(`/api/goals/${id}/archive`, {}); + deleteGoal = (id: string) => this.http.delete(`/api/goals/${id}`); // Reports overview = (year: number) => this.http.get(`/api/reports/overview/${year}`); @@ -297,6 +313,7 @@ export class Api { valueManualAsset = (id: string, value: number, on?: string) => this.http.post(`/api/assets/${id}/valuations`, { value, on }); archiveManualAsset = (id: string) => this.http.post(`/api/assets/${id}/archive`, {}); + deleteManualAsset = (id: string) => this.http.delete(`/api/assets/${id}`); fx = (currency: string, date?: string) => this.http.get<{ currency: string; date: string; eurPerUnit: number }>(`/api/fx/${currency}`, { params: date ? { date } : {}, diff --git a/web/src/app/features/categories/categories.ts b/web/src/app/features/categories/categories.ts index 83e8fff..00edb8a 100644 --- a/web/src/app/features/categories/categories.ts +++ b/web/src/app/features/categories/categories.ts @@ -3,6 +3,7 @@ import { liveResource } from '../../core/resource'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; import { Api } from '../../core/api'; +import { Confirm } from '../../core/confirm'; import { DataEvents } from '../../core/data-events'; import { Category, ExpenseNature } from '../../core/models'; import { Prefs } from '../../core/prefs'; @@ -141,6 +142,19 @@ import { HlmTooltipImports } from '@spartan-ng/helm/tooltip'; > + @if (!c.isSystem) { + + } } @@ -227,6 +241,44 @@ import { HlmTooltipImports } from '@spartan-ng/helm/tooltip'; + + + +
+

+ {{ + 'deletion.categoryInUse' + | translate + : { + transactions: deletingUsage()?.transactions ?? 0, + recurring: deletingUsage()?.recurring ?? 0, + } + }} +

+
+ + +
+

{{ 'deletion.categoryHint' | translate }}

+
+ + +
+
+
`, }) export class CategoriesComponent { @@ -235,6 +287,7 @@ export class CategoriesComponent { private readonly events = inject(DataEvents); private readonly toasts = inject(Toasts); private readonly i18n = inject(TranslateService); + private readonly confirm = inject(Confirm); private readonly prefs = inject(Prefs); protected readonly categories = liveResource({ params: () => this.events.version(), @@ -357,6 +410,59 @@ export class CategoriesComponent { } } + protected readonly deleting = signal(null); + protected readonly deletingUsage = signal<{ transactions: number; recurring: number } | null>( + null, + ); + protected readonly moveTo = signal(null); + protected readonly moveOptions = computed(() => { + const c = this.deleting(); + return [ + { value: '', label: '—' }, + ...(this.categories.value() ?? []) + .filter((o) => c && o.id !== c.id && o.type === c.type && !o.archived) + .map((o) => ({ value: o.id, label: categoryLabel(this.i18n, o) })), + ]; + }); + + /** Deletes a custom category for good; in use, its movements move to another one first. */ + protected async remove(c: Category) { + try { + const usage = await firstValueFrom(this.api.categoryDeletion(c.id)); + if (usage.transactions || usage.recurring) { + this.deletingUsage.set(usage); + this.moveTo.set(null); + this.deleting.set(c); + return; + } + const ok = await this.confirm.ask( + this.i18n.instant('deletion.askCategory', { name: c.name }) + + ' ' + + this.i18n.instant('deletion.cannotUndo'), + { destructive: true, confirmLabel: this.i18n.instant('common.delete') }, + ); + if (!ok) return; + await firstValueFrom(this.api.deleteCategory(c.id, null)); + this.events.bump(); + this.toasts.show(this.i18n.instant('deletion.done')); + } catch (err) { + this.toasts.error(err); + } + } + + protected async confirmDelete() { + const c = this.deleting(); + if (!c || !this.moveTo()) return; + try { + await firstValueFrom(this.api.deleteCategory(c.id, this.moveTo())); + this.deleting.set(null); + this.events.bump(); + this.toasts.show(this.i18n.instant('deletion.done')); + } catch (err) { + this.toasts.error(err); + } + } + protected async toggleArchive(c: Category) { try { await firstValueFrom( diff --git a/web/src/app/features/goals/goals.ts b/web/src/app/features/goals/goals.ts index e793cb4..b33db45 100644 --- a/web/src/app/features/goals/goals.ts +++ b/web/src/app/features/goals/goals.ts @@ -5,6 +5,7 @@ import { liveResource } from '../../core/resource'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; import { Api } from '../../core/api'; +import { Confirm } from '../../core/confirm'; import { DataEvents } from '../../core/data-events'; import { DayPipe, MoneyPipe, PercentPipe } from '../../core/format'; import { Goal } from '../../core/models'; @@ -119,6 +120,16 @@ import { StatusBadgeComponent } from '../../shared/status-badge'; + } @empty { @@ -227,6 +238,7 @@ export class GoalsComponent { private readonly events = inject(DataEvents); private readonly toasts = inject(Toasts); private readonly i18n = inject(TranslateService); + private readonly confirm = inject(Confirm); private readonly prefs = inject(Prefs); protected readonly goals = liveResource({ params: () => this.events.version(), @@ -290,6 +302,23 @@ export class GoalsComponent { } } + protected async remove(g: Goal) { + const ok = await this.confirm.ask( + this.i18n.instant('deletion.askGoal', { name: g.name }) + + ' ' + + this.i18n.instant('deletion.cannotUndo'), + { destructive: true, confirmLabel: this.i18n.instant('common.delete') }, + ); + if (!ok) return; + try { + await firstValueFrom(this.api.deleteGoal(g.id)); + this.events.bump(); + this.toasts.show(this.i18n.instant('deletion.done')); + } catch (err) { + this.toasts.error(err); + } + } + protected async archive(g: Goal) { try { await firstValueFrom(this.api.archiveGoal(g.id)); diff --git a/web/src/app/features/net-worth/net-worth.ts b/web/src/app/features/net-worth/net-worth.ts index 93de882..675e70f 100644 --- a/web/src/app/features/net-worth/net-worth.ts +++ b/web/src/app/features/net-worth/net-worth.ts @@ -4,6 +4,7 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import type { EChartsOption } from 'echarts'; import { firstValueFrom } from 'rxjs'; import { Api } from '../../core/api'; +import { Confirm } from '../../core/confirm'; import { DataEvents } from '../../core/data-events'; import { DayPipe, MoneyPipe, PercentPipe, today } from '../../core/format'; import { ManualAsset, ManualAssetKind } from '../../core/models'; @@ -213,6 +214,16 @@ import { EmptyStateComponent } from '../../shared/empty-state'; >{{ 'common.archive' | translate }} + } @empty { @@ -303,6 +314,7 @@ export class NetWorthComponent { private readonly prefs = inject(Prefs); private readonly toasts = inject(Toasts); private readonly i18n = inject(TranslateService); + private readonly confirm = inject(Confirm); protected readonly colors = SERIES_COLORS; protected readonly kinds: ManualAssetKind[] = [ 'RealEstate', @@ -413,6 +425,23 @@ export class NetWorthComponent { } } + protected async remove(a: ManualAsset) { + const ok = await this.confirm.ask( + this.i18n.instant('deletion.askAsset', { name: a.name }) + + ' ' + + this.i18n.instant('deletion.cannotUndo'), + { destructive: true, confirmLabel: this.i18n.instant('common.delete') }, + ); + if (!ok) return; + try { + await firstValueFrom(this.api.deleteManualAsset(a.id)); + this.events.bump(); + this.toasts.show(this.i18n.instant('deletion.done')); + } catch (err) { + this.toasts.error(err); + } + } + protected async archive(a: ManualAsset) { try { await firstValueFrom(this.api.archiveManualAsset(a.id));