From aaac962ccc4bb43c557d5bd86ec2f9c57a3d186e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Guimar=C3=A3es?= Date: Tue, 20 Oct 2026 23:34:14 +0100 Subject: [PATCH] feat(recurring): a recurring expense paid by someone else is owed to them --- CHANGELOG.md | 7 ++ .../Integration.Tests/SharedExpenseTests.cs | 20 +++++ web/public/i18n/en.json | 8 +- web/public/i18n/pt-PT.json | 8 +- web/src/app/features/recurring/recurring.ts | 89 +++++++++++++++---- 5 files changed, 115 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da3c33..f4aa8c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ Notable changes for people who install the app. Every release, with its installers, is on the [releases page](https://github.com/joaoguimaraespro/personal-finance/releases); the full history is in the [commits](https://github.com/joaoguimaraespro/personal-finance/commits/main). +## [Unreleased] + +### Added + +- Recurring expenses paid by someone else (a flatmate pays the rent): choose *Someone else (I owe them)* and each + confirmed occurrence is owed to that person, until you pay them back in Sharing. + ## [1.3.0] - 2026-10-20 ### Changed diff --git a/backend/tests/Integration.Tests/SharedExpenseTests.cs b/backend/tests/Integration.Tests/SharedExpenseTests.cs index ca2beae..d71bae3 100644 --- a/backend/tests/Integration.Tests/SharedExpenseTests.cs +++ b/backend/tests/Integration.Tests/SharedExpenseTests.cs @@ -280,6 +280,26 @@ public async Task What_was_owed_before_the_app_is_the_persons_opening_balance() .GetDecimal().ShouldBe(0m); // being paid back is not income } + [Fact] + public async Task A_recurring_item_paid_by_someone_else_is_owed_to_them_each_time() + { + var api = await factory.OwnerAsync(); + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var sara = await api.CreateAsync("/api/accounts", new { name = "Sara (pays the rent)", kind = "Person", currency = "EUR", openingBalance = 0m }); + var recurringId = await api.CreateAsync("/api/recurring", new + { + name = "My part of the rent", type = "Expense", amount = 300m, accountId = sara, frequency = "Monthly", + startOn = today.ToString("yyyy-MM-dd"), dayOfMonth = today.Day, categoryId = SystemCatalog.CategoryId("housing"), + }); + var proposal = (await api.GetAsync("/api/expected")) + .Single(p => p.GetProperty("recurringTransactionId").GetGuid() == recurringId); + await ApiClient.EnsureAsync(await api.PostAsync($"/api/expected/{proposal.GetProperty("id").GetGuid()}/confirm", new { })); + + var person = await PersonAsync(api, sara); + person.GetProperty("balance").GetDecimal().ShouldBe(-300m); // the owner owes it + person.GetProperty("recent").EnumerateArray().Single().GetProperty("kind").GetString().ShouldBe("theyPaid"); + } + private static async Task GetIncomeAsync(ApiClient api) => (await api.GetJsonAsync("/api/reports/monthly/2053-07")).GetProperty("current").GetProperty("income").GetDecimal(); } diff --git a/web/public/i18n/en.json b/web/public/i18n/en.json index 7c4840c..cb80c7c 100644 --- a/web/public/i18n/en.json +++ b/web/public/i18n/en.json @@ -384,7 +384,13 @@ "active": "Active", "paused": "Paused", "shareHint": "Each time it is confirmed, they owe their part of what was paid — the split follows the amount when the bill varies.", - "confirmValue": "{{name}}: value to confirm" + "confirmValue": "{{name}}: value to confirm", + "whoPays": "Who pays it", + "paidByMe": "I do", + "paidByPerson": "Someone else (I owe them)", + "paidByWho": "Paid by", + "paidByHint": "Each time it is confirmed you owe them this amount (your part), until you pay them back in Sharing. Nothing leaves your accounts.", + "paidByName": "paid by {{name}}" }, "budgets": { "subtitle": "Each version applies from its month onwards — changing it never rewrites past months.", diff --git a/web/public/i18n/pt-PT.json b/web/public/i18n/pt-PT.json index d710ae0..2313b25 100644 --- a/web/public/i18n/pt-PT.json +++ b/web/public/i18n/pt-PT.json @@ -384,7 +384,13 @@ "active": "Ativo", "paused": "Em pausa", "shareHint": "Sempre que for confirmado, ficam a dever a parte deles do que foi pago — a divisão acompanha o valor quando a conta varia.", - "confirmValue": "{{name}}: quantia a confirmar" + "confirmValue": "{{name}}: quantia a confirmar", + "whoPays": "Quem paga", + "paidByMe": "Eu", + "paidByPerson": "Outra pessoa (fico a dever)", + "paidByWho": "Pago por", + "paidByHint": "Sempre que for confirmado ficas a dever-lhe este valor (a tua parte), até lhe pagares em Partilhas. Não sai dinheiro das tuas contas.", + "paidByName": "paga por {{name}}" }, "budgets": { "subtitle": "Cada versão aplica-se a partir do seu mês — alterá-la nunca reescreve meses anteriores.", diff --git a/web/src/app/features/recurring/recurring.ts b/web/src/app/features/recurring/recurring.ts index 6b5dfc7..068e9e9 100644 --- a/web/src/app/features/recurring/recurring.ts +++ b/web/src/app/features/recurring/recurring.ts @@ -61,6 +61,8 @@ interface Form { sharePeople: string[]; shareEqual: boolean; shareAmounts: Record; + /** Paid by another person each time (the owner owes it to them): that person's id, '' when the owner pays. */ + paidBy: string; } @Component({ @@ -157,6 +159,12 @@ interface Form { } @else { {{ categoryFor(r.categoryId) | categoryLabel }} } + @if (paidByOf(r); as who) { + · + {{ + 'recurring.paidByName' | translate: { name: who } + }} + } @if (r.shares?.length) { · {{ @@ -341,17 +349,54 @@ interface Form { } -
- - - -
+ @if (form().type === 'Expense' && people().length) { + +
+

{{ 'recurring.whoPays' | translate }}

+
+ + +
+
+ } + @if (form().paidBy) { +
+ + +

+ {{ 'recurring.paidByHint' | translate }} +

+
+ } @else { +
+ + + +
+ } @if (canShare()) {
@@ -535,8 +580,17 @@ export class RecurringComponent { this.people().filter((p) => this.form().sharePeople.includes(p.id)), ); protected readonly canShare = computed( - () => this.form().type === 'Expense' && !this.form().splits && this.people().length > 0, + () => + this.form().type === 'Expense' && + !this.form().splits && + !this.form().paidBy && + this.people().length > 0, + ); + protected readonly personOptions = computed(() => + this.people().map((p) => ({ value: p.id, label: p.name })), ); + /** The person who pays an item for the owner, when it is one of them. */ + protected paidByOf = (r: Recurring) => this.people().find((p) => p.id === r.accountId)?.name; protected shareNames = (r: Recurring) => (r.shares ?? []) .map((s) => this.people().find((p) => p.id === s.personId)?.name) @@ -638,7 +692,10 @@ export class RecurringComponent { name: r.name, type: r.type, amount: String(r.amount), - accountId: r.accountId, + accountId: this.people().some((p) => p.id === r.accountId) + ? (this.manualAccounts()[0]?.id ?? '') + : r.accountId, + paidBy: this.people().some((p) => p.id === r.accountId) ? r.accountId : '', categoryId: r.categoryId ?? '', bucketId: r.bucketId ?? '', frequency: r.frequency, @@ -669,7 +726,7 @@ export class RecurringComponent { const valid = this.fs.check({ name: rules.required(f.name), amount: rules.positive(parseAmount(f.amount)), - accountId: rules.pick(f.accountId), + accountId: f.paidBy ? undefined : rules.pick(f.accountId), categoryId: everyday && !split ? rules.pick(f.categoryId) : undefined, bucketId: !everyday && f.type !== 'Transfer' ? rules.pick(f.bucketId) : undefined, interval: @@ -700,7 +757,8 @@ export class RecurringComponent { type: f.type, amount: parseAmount(f.amount), currency: 'EUR', - accountId: f.accountId, + // Paid by someone else: the expense is booked in that person's account (the owner owes it to them). + accountId: f.type === 'Expense' && f.paidBy ? f.paidBy : f.accountId, ...scheduleBody(f), startOn: f.startOn, endOn: f.endOn || null, @@ -771,6 +829,7 @@ export class RecurringComponent { sharePeople: [], shareEqual: true, shareAmounts: {}, + paidBy: '', }; } }