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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions backend/tests/Integration.Tests/SharedExpenseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonElement[]>("/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<decimal> GetIncomeAsync(ApiClient api) =>
(await api.GetJsonAsync("/api/reports/monthly/2053-07")).GetProperty("current").GetProperty("income").GetDecimal();
}
8 changes: 7 additions & 1 deletion web/public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
8 changes: 7 additions & 1 deletion web/public/i18n/pt-PT.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
89 changes: 74 additions & 15 deletions web/src/app/features/recurring/recurring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ interface Form {
sharePeople: string[];
shareEqual: boolean;
shareAmounts: Record<string, string>;
/** Paid by another person each time (the owner owes it to them): that person's id, '' when the owner pays. */
paidBy: string;
}

@Component({
Expand Down Expand Up @@ -157,6 +159,12 @@ interface Form {
} @else {
{{ categoryFor(r.categoryId) | categoryLabel }}
}
@if (paidByOf(r); as who) {
·
<span class="text-violet-700 dark:text-violet-300">{{
'recurring.paidByName' | translate: { name: who }
}}</span>
}
@if (r.shares?.length) {
·
<span class="text-violet-700 dark:text-violet-300">{{
Expand Down Expand Up @@ -341,17 +349,54 @@ interface Form {
<app-field-error [error]="fs.errors().bucketId" />
</div>
}
<div class="col-span-2">
<label class="label" for="r-acc">{{ 'tx.account' | translate }}</label>
<app-select
inputId="r-acc"
[options]="accountOptions()"
[value]="form().accountId"
(valueChange)="fs.touch('accountId'); patch({ accountId: $event })"
[attr.aria-invalid]="!!fs.errors().accountId || null"
/>
<app-field-error [error]="fs.errors().accountId" />
</div>
@if (form().type === 'Expense' && people().length) {
<!-- Someone else pays it each time (a flatmate pays the rent): the owner owes them, nothing leaves an account. -->
<div class="col-span-2">
<p class="label">{{ 'recurring.whoPays' | translate }}</p>
<div
class="segmented text-xs"
role="group"
[attr.aria-label]="'recurring.whoPays' | translate"
>
<button type="button" [class.active]="!form().paidBy" (click)="patch({ paidBy: '' })">
{{ 'recurring.paidByMe' | translate }}
</button>
<button
type="button"
[class.active]="!!form().paidBy"
(click)="patch({ paidBy: form().paidBy || people()[0].id, sharePeople: [] })"
>
{{ 'recurring.paidByPerson' | translate }}
</button>
</div>
</div>
}
@if (form().paidBy) {
<div class="col-span-2">
<label class="label" for="r-paid-by">{{ 'recurring.paidByWho' | translate }}</label>
<app-select
inputId="r-paid-by"
[options]="personOptions()"
[value]="form().paidBy"
(valueChange)="patch({ paidBy: $event })"
/>
<p class="mt-1 text-xs text-muted-foreground">
{{ 'recurring.paidByHint' | translate }}
</p>
</div>
} @else {
<div class="col-span-2">
<label class="label" for="r-acc">{{ 'tx.account' | translate }}</label>
<app-select
inputId="r-acc"
[options]="accountOptions()"
[value]="form().accountId"
(valueChange)="fs.touch('accountId'); patch({ accountId: $event })"
[attr.aria-invalid]="!!fs.errors().accountId || null"
/>
<app-field-error [error]="fs.errors().accountId" />
</div>
}
@if (canShare()) {
<!-- Shared with people (ADR-0011): each confirmed occurrence owes them their part of what was paid. -->
<div class="col-span-2 rounded-lg border p-3">
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -771,6 +829,7 @@ export class RecurringComponent {
sharePeople: [],
shareEqual: true,
shareAmounts: {},
paidBy: '',
};
}
}
Expand Down
Loading