Skip to content

Commit ce48a25

Browse files
stoyan.sclaude
andcommitted
#1/#2: contribution categories + fund-attributed deposits
#1 Account-level ContributionCategory (Salary/Vouchers...) with add/rename/ remove (dup-name guard; remove blocked when a deposit references it). New accounts seed Salary + Other. The 'From previous period' leftover is not a contribution and keeps its own pseudo-category. #2 Contribution is now an itemized entry (MemberId, CategoryId, FundId, Date, Paid); deposits merge by (member, category, fund). A deposit raises the chosen fund's balance (FundBalance adds attributed deposits, so fund balances now sum to the expected closing). Edit/remove are by id; a user may only handle their OWN contributions. Serializer round-trips the new fields (back-compat defaults for old snapshots). EF mapping + SQLite migration (MAUI). Server stores the body as opaque JSON, so no Postgres change needed. Contributions UI reshaped: deposit form (category + fund + amount + date), category chips with add/edit/remove, itemized own-editable list. Localized the new strings (EN/BG). 101 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 05421ac commit ce48a25

15 files changed

Lines changed: 1176 additions & 83 deletions

src/FinApp.Contracts/AccountSnapshotSerializer.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ public static string Serialize(Account account)
3434
account.Funds.Select(f => new FundNode(f.Id, f.Name, f.ParentId)).ToList(),
3535
account.Categories.Select(c => new CategoryNode(c.Id, c.Name, c.ParentId)).ToList(),
3636
account.SavingCategories.Select(s => new SavingCategoryNode(s.Id, s.Name, s.ParentId, s.GoalAmount, s.AlertThreshold, s.NotifyOnMilestone, s.InitialAmount)).ToList(),
37-
account.Periods.Select(ToNode).ToList());
37+
account.Periods.Select(ToNode).ToList(),
38+
account.ContributionCategories.Select(c => new ContributionCategoryNode(c.Id, c.Name)).ToList());
3839
return JsonSerializer.Serialize(node, Json);
3940
}
4041

@@ -64,6 +65,8 @@ public static Account Deserialize(string payload)
6465
SetField(account, "_funds", node.Funds.Select(f => Build(new Fund(f.Name, f.ParentId), f.Id)).ToList());
6566
SetField(account, "_categories", node.Categories.Select(c => Build(new Category(c.Name, c.ParentId), c.Id)).ToList());
6667
SetField(account, "_savingCategories", node.SavingCategories.Select(ToEntity).ToList());
68+
SetField(account, "_contributionCategories",
69+
(node.ContributionCategories ?? []).Select(c => Build(new ContributionCategory(c.Name), c.Id)).ToList());
6770
SetField(account, "_periods", node.Periods.Select(p => ToEntity(p, node.Currency)).ToList());
6871
return account;
6972
}
@@ -73,7 +76,7 @@ public static Account Deserialize(string payload)
7376
private static PeriodNode ToNode(Period p) => new(
7477
p.Id, p.Currency, p.From, p.To, p.Status, p.CarriedIn.Amount,
7578
p.InitialBalances.Select(b => new InitialBalanceNode(b.Id, b.FundId, b.Amount.Amount, b.Informative)).ToList(),
76-
p.Contributions.Select(c => new ContributionNode(c.Id, c.MemberId, c.Paid.Amount)).ToList(),
79+
p.Contributions.Select(c => new ContributionNode(c.Id, c.MemberId, c.Paid.Amount, c.CategoryId, c.FundId, c.Date)).ToList(),
7780
p.Budgets.Select(b => new BudgetNode(b.Id, b.CategoryId, b.Allocated.Amount, b.AlertThreshold, b.NotifyOnEveryExpense)).ToList(),
7881
p.Expenses.Select(e => new ExpenseNode(e.Id, e.CategoryId, e.Amount.Amount, e.Date, e.MemberId, e.FundId, e.Note, e.SourceSavingCategoryId)).ToList(),
7982
p.SavingAllocations.Select(a => new SavingAllocationNode(a.Id, a.SavingCategoryId, a.Amount.Amount, a.Date, a.Note, a.SourceExpenseId, a.BudgetCategoryId, a.TransferPairId)).ToList(),
@@ -103,7 +106,7 @@ private static Period ToEntity(PeriodNode n, string currency)
103106
if (n.Status == PeriodStatus.Closed) p.Close();
104107

105108
SetField(p, "_initialBalances", n.InitialBalances.Select(b => Build(new InitialBalance(b.FundId, M(b.Amount), b.Informative), b.Id)).ToList());
106-
SetField(p, "_contributions", n.Contributions.Where(c => c.MemberId != Period.CarryoverSource).Select(c => Build(new Contribution(c.MemberId, M(c.Paid)), c.Id)).ToList());
109+
SetField(p, "_contributions", n.Contributions.Where(c => c.MemberId != Period.CarryoverSource).Select(c => Build(new Contribution(c.MemberId, M(c.Paid), c.CategoryId, c.FundId, c.Date), c.Id)).ToList());
107110
SetField(p, "_budgets", n.Budgets.Select(b => Build(new Budget(b.CategoryId, M(b.Allocated), b.AlertThreshold, b.NotifyOnEveryExpense), b.Id)).ToList());
108111
SetField(p, "_expenses", n.Expenses.Select(e => Build(new Expense(e.CategoryId, M(e.Amount), e.Date, e.MemberId, e.FundId, e.Note, e.SourceSavingCategoryId), e.Id)).ToList());
109112
SetField(p, "_savingAllocations", n.SavingAllocations.Select(a => Build(new SavingAllocation(a.SavingCategoryId, M(a.Amount), a.Date, a.Note, a.SourceExpenseId, a.BudgetCategoryId, a.TransferPairId), a.Id)).ToList());
@@ -142,9 +145,11 @@ private static FieldInfo FindField(Type? type, string name)
142145

143146
private record AccountNode(Guid Id, string Name, string Currency, Guid OwnerUserId,
144147
List<MemberNode> Members, List<FundNode> Funds, List<CategoryNode> Categories,
145-
List<SavingCategoryNode> SavingCategories, List<PeriodNode> Periods);
148+
List<SavingCategoryNode> SavingCategories, List<PeriodNode> Periods,
149+
List<ContributionCategoryNode>? ContributionCategories = null);
146150

147151
private record MemberNode(Guid Id, Guid UserId, string DisplayName);
152+
private record ContributionCategoryNode(Guid Id, string Name);
148153
private record FundNode(Guid Id, string Name, Guid? ParentId);
149154
private record CategoryNode(Guid Id, string Name, Guid? ParentId);
150155
private record SavingCategoryNode(Guid Id, string Name, Guid? ParentId, decimal? GoalAmount, decimal AlertThreshold, bool NotifyOnMilestone, decimal InitialAmount);
@@ -155,7 +160,8 @@ private record PeriodNode(Guid Id, string Currency, DateOnly From, DateOnly To,
155160
List<ExternalTransferNode>? ExternalTransfers = null);
156161

157162
private record InitialBalanceNode(Guid Id, Guid FundId, decimal Amount, bool Informative);
158-
private record ContributionNode(Guid Id, Guid MemberId, decimal Paid);
163+
private record ContributionNode(Guid Id, Guid MemberId, decimal Paid,
164+
Guid CategoryId = default, Guid FundId = default, DateOnly Date = default);
159165
private record BudgetNode(Guid Id, Guid CategoryId, decimal Allocated, decimal AlertThreshold, bool NotifyOnEveryExpense);
160166
private record ExpenseNode(Guid Id, Guid CategoryId, decimal Amount, DateOnly Date, Guid MemberId, Guid FundId, string? Note, Guid? SourceSavingCategoryId);
161167
private record SavingAllocationNode(Guid Id, Guid SavingCategoryId, decimal Amount, DateOnly Date, string? Note, Guid? SourceExpenseId, Guid? BudgetCategoryId = null, Guid? TransferPairId = null);

src/FinApp.Domain/Accounts/Account.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public sealed class Account : Entity
1515
private readonly List<AccountMember> _members = [];
1616
private readonly List<Category> _categories = [];
1717
private readonly List<SavingCategory> _savingCategories = [];
18+
private readonly List<ContributionCategory> _contributionCategories = [];
1819
private readonly List<Fund> _funds = [];
1920
private readonly List<Period> _periods = [];
2021

@@ -36,6 +37,9 @@ public sealed class Account : Entity
3637
/// <summary>All savings buckets, flat.</summary>
3738
public IReadOnlyList<SavingCategory> SavingCategories => _savingCategories;
3839

40+
/// <summary>Account-level contribution categories (Salary, Vouchers…), referenced by id from deposits.</summary>
41+
public IReadOnlyList<ContributionCategory> ContributionCategories => _contributionCategories;
42+
3943
/// <summary>All funds (places money lives), flat. Referenced by id from expenses, opening balances and transfers.</summary>
4044
public IReadOnlyList<Fund> Funds => _funds;
4145

@@ -198,6 +202,44 @@ public void RemoveSavingCategory(Guid savingCategoryId)
198202
_savingCategories.Remove(category);
199203
}
200204

205+
// --- Contribution categories -----------------------------------------
206+
207+
public ContributionCategory AddContributionCategory(string name)
208+
{
209+
if (_contributionCategories.Any(c => NameEquals(c.Name, name)))
210+
throw new InvalidOperationException($"A contribution category named “{name.Trim()}” already exists.");
211+
var category = new ContributionCategory(name);
212+
_contributionCategories.Add(category);
213+
return category;
214+
}
215+
216+
public ContributionCategory? FindContributionCategory(Guid id) => _contributionCategories.FirstOrDefault(c => c.Id == id);
217+
218+
public void RenameContributionCategory(Guid id, string name)
219+
{
220+
var category = FindContributionCategory(id) ?? throw new InvalidOperationException("Contribution category not found.");
221+
if (_contributionCategories.Any(c => c.Id != id && NameEquals(c.Name, name)))
222+
throw new InvalidOperationException($"A contribution category named “{name.Trim()}” already exists.");
223+
category.Rename(name);
224+
}
225+
226+
/// <summary>Why a contribution category can't be removed, or null when it can.</summary>
227+
public string? ContributionCategoryRemovalBlocker(Guid id)
228+
{
229+
if (_periods.SelectMany(p => p.Contributions).Any(c => c.CategoryId == id))
230+
return "deposits reference it";
231+
return null;
232+
}
233+
234+
public void RemoveContributionCategory(Guid id)
235+
{
236+
var blocker = ContributionCategoryRemovalBlocker(id);
237+
if (blocker is not null)
238+
throw new InvalidOperationException($"Cannot remove contribution category: {blocker}.");
239+
var category = FindContributionCategory(id) ?? throw new InvalidOperationException("Contribution category not found.");
240+
_contributionCategories.Remove(category);
241+
}
242+
201243
// --- Funds ------------------------------------------------------------
202244

203245
/// <summary>Add a fund. Pass <paramref name="parentId"/> to nest it as an informational sub-fund.</summary>

src/FinApp.Domain/Periods/Contribution.cs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,28 @@
33
namespace FinApp.Domain.Periods;
44

55
/// <summary>
6-
/// A member's deposit into a period — how much they have actually put in. There are no pledges or
7-
/// due dates: deposits are recorded directly as money comes in.
6+
/// A member's deposit into a period — how much they have actually put in, classified by an optional
7+
/// contribution <see cref="CategoryId"/> (Salary, Vouchers…) and attributed to a <see cref="FundId"/>
8+
/// (the deposited money lands in that fund). Deposits with the same (member, category, fund) merge into
9+
/// one row; different combinations are separate rows. There are no pledges or due dates.
810
/// </summary>
911
public sealed class Contribution : Entity
1012
{
1113
public Guid MemberId { get; }
14+
public Guid CategoryId { get; private set; }
15+
public Guid FundId { get; private set; }
16+
public DateOnly Date { get; private set; }
1217
public Money Paid { get; private set; }
1318

14-
public Contribution(Guid memberId, Money paid)
19+
public Contribution(Guid memberId, Money paid, Guid categoryId = default, Guid fundId = default, DateOnly date = default)
1520
{
1621
if (paid.IsNegative)
1722
throw new ArgumentException("Deposited amount cannot be negative.", nameof(paid));
1823
MemberId = memberId;
1924
Paid = paid;
25+
CategoryId = categoryId;
26+
FundId = fundId;
27+
Date = date;
2028
}
2129

2230
public void RecordPayment(Money amount)
@@ -33,4 +41,13 @@ public void SetPaid(Money amount)
3341
throw new ArgumentException("Deposited amount cannot be negative.", nameof(amount));
3442
Paid = amount;
3543
}
44+
45+
/// <summary>Overwrite all editable fields of a deposit row.</summary>
46+
public void Update(Money amount, Guid categoryId, Guid fundId, DateOnly date)
47+
{
48+
SetPaid(amount);
49+
CategoryId = categoryId;
50+
FundId = fundId;
51+
Date = date;
52+
}
3653
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using FinApp.Domain.Common;
2+
3+
namespace FinApp.Domain.Periods;
4+
5+
/// <summary>
6+
/// An account-level category for contributions/deposits (e.g. Salary, Vouchers, Rent share).
7+
/// Lets income be classified by source. The automatic "From previous period" leftover is not a
8+
/// contribution and keeps its own pseudo-category, so it's unaffected by these.
9+
/// </summary>
10+
public sealed class ContributionCategory : Entity
11+
{
12+
public string Name { get; private set; }
13+
14+
public ContributionCategory(string name)
15+
{
16+
if (string.IsNullOrWhiteSpace(name))
17+
throw new ArgumentException("Contribution category name is required.", nameof(name));
18+
Name = name.Trim();
19+
}
20+
21+
public void Rename(string name)
22+
{
23+
if (string.IsNullOrWhiteSpace(name))
24+
throw new ArgumentException("Contribution category name is required.", nameof(name));
25+
Name = name.Trim();
26+
}
27+
}

src/FinApp.Domain/Periods/Period.cs

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,8 @@ public Money FundBalance(Guid fundId)
139139
var transfersOut = Sum(_fundTransfers.Where(t => t.FromFundId == fundId).Select(t => t.Amount));
140140
var spent = Sum(_expenses.Where(e => e.FundId == fundId).Select(e => e.Amount));
141141
var sentOut = Sum(_externalTransfers.Where(t => t.FundId == fundId).Select(t => t.Amount));
142-
return opening + transfersIn - transfersOut - spent - sentOut;
142+
var depositsIn = Sum(_contributions.Where(c => c.MemberId != CarryoverSource && c.FundId == fundId).Select(c => c.Paid));
143+
return opening + transfersIn + depositsIn - transfersOut - spent - sentOut;
143144
}
144145

145146
// --- Transfers to other accounts --------------------------------------
@@ -171,36 +172,44 @@ public void RemoveExternalTransfer(Guid transferId)
171172

172173
// --- Contributions ----------------------------------------------------
173174

174-
/// <summary>Record a member's deposit (creating their contribution on first deposit, adding to it after).</summary>
175-
public Contribution Deposit(Guid memberId, Money amount)
175+
/// <summary>
176+
/// Record a member's deposit, classified by <paramref name="categoryId"/> and attributed to
177+
/// <paramref name="fundId"/> (the money lands in that fund). Deposits with the same
178+
/// (member, category, fund) merge into one row; different combinations are separate rows.
179+
/// </summary>
180+
public Contribution Deposit(Guid memberId, Money amount, Guid categoryId = default, Guid fundId = default, DateOnly date = default)
176181
{
177182
EnsureCurrency(amount);
178-
var existing = _contributions.FirstOrDefault(c => c.MemberId == memberId);
183+
var existing = _contributions.FirstOrDefault(c =>
184+
c.MemberId == memberId && c.CategoryId == categoryId && c.FundId == fundId);
179185
if (existing is null)
180186
{
181-
existing = new Contribution(memberId, Money.Zero(Currency));
187+
existing = new Contribution(memberId, Money.Zero(Currency), categoryId, fundId, date);
182188
_contributions.Add(existing);
183189
}
184190
existing.RecordPayment(amount);
185191
return existing;
186192
}
187193

188-
/// <summary>Overwrite a member's deposited total (used when editing a deposit).</summary>
189-
public void SetDeposit(Guid memberId, Money amount)
194+
public Contribution? FindContribution(Guid contributionId) =>
195+
_contributions.FirstOrDefault(c => c.Id == contributionId);
196+
197+
/// <summary>Overwrite a deposit row's amount/category/fund/date (used when editing a deposit).</summary>
198+
public void EditContribution(Guid contributionId, Money amount, Guid categoryId, Guid fundId, DateOnly date)
190199
{
191200
EnsureCurrency(amount);
192201
EnsureOpen();
193-
var contribution = _contributions.FirstOrDefault(c => c.MemberId == memberId)
194-
?? throw new InvalidOperationException("No contribution exists for this member.");
195-
contribution.SetPaid(amount);
202+
var contribution = FindContribution(contributionId)
203+
?? throw new InvalidOperationException("Contribution not found in this period.");
204+
contribution.Update(amount, categoryId, fundId, date);
196205
}
197206

198-
/// <summary>Clear a member's deposit for this period.</summary>
199-
public void RemoveDeposit(Guid memberId)
207+
/// <summary>Remove a deposit row.</summary>
208+
public void RemoveContribution(Guid contributionId)
200209
{
201210
EnsureOpen();
202-
var contribution = _contributions.FirstOrDefault(c => c.MemberId == memberId)
203-
?? throw new InvalidOperationException("No contribution exists for this member.");
211+
var contribution = FindContribution(contributionId)
212+
?? throw new InvalidOperationException("Contribution not found in this period.");
204213
_contributions.Remove(contribution);
205214
}
206215

src/FinApp.Persistence/FinAppDbContext.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,18 @@ protected override void OnModelCreating(ModelBuilder b)
4747
OwnedList(a, x => x.Members);
4848
OwnedList(a, x => x.Categories);
4949
OwnedList(a, x => x.SavingCategories);
50+
OwnedList(a, x => x.ContributionCategories);
5051
OwnedList(a, x => x.Funds);
5152
OwnedList(a, x => x.Periods);
5253
});
5354

55+
b.Entity<ContributionCategory>(c =>
56+
{
57+
c.ToTable("ContributionCategories");
58+
Key(c);
59+
c.Property(x => x.Name).IsRequired();
60+
});
61+
5462
b.Entity<Fund>(f =>
5563
{
5664
f.ToTable("Funds");
@@ -193,6 +201,9 @@ protected override void OnModelCreating(ModelBuilder b)
193201
c.ToTable("Contributions");
194202
Key(c);
195203
c.Property(x => x.MemberId);
204+
c.Property(x => x.CategoryId);
205+
c.Property(x => x.FundId);
206+
c.Property(x => x.Date);
196207
c.Property(x => x.Paid).HasConversion(money).IsRequired();
197208
});
198209

0 commit comments

Comments
 (0)