diff --git a/backend/src/Modules/Finance/Finance.Application/Accounts/AccountEndpoints.cs b/backend/src/Modules/Finance/Finance.Application/Accounts/AccountEndpoints.cs index fe55e31..157ebcc 100644 --- a/backend/src/Modules/Finance/Finance.Application/Accounts/AccountEndpoints.cs +++ b/backend/src/Modules/Finance/Finance.Application/Accounts/AccountEndpoints.cs @@ -42,7 +42,8 @@ public sealed record AccountInterestDto( InterestPayout Payout, decimal YearToDate, decimal YearToDateEstimated, - decimal EstimatedInBalance); + decimal EstimatedInBalance, + DateOnly? AccruingSince = null); public sealed record CreateAccountRequest( string Name, @@ -285,11 +286,17 @@ public static async Task> InterestSummaries Amount = account.Currency == Currency.Base ? s.Base : s.Original, }) .ToList(); + // This year's figure only covers the days the app knows the account and its rate: say from when. + var firstRate = rates.Where(r => r.AccountId == account.Id).MinBy(r => r.EffectiveFrom)?.EffectiveFrom; + DateOnly? since = firstRate is { } f + ? new[] { yearStart, account.OpeningBalanceOn, f }.Max() + : null; result[account.Id] = new AccountInterestDto(current?.AnnualRatePercent, current?.WithholdingPercent, current?.EffectiveFrom, account.InterestPayout, mine.Where(s => s.ThisYear).Sum(s => s.Amount), mine.Where(s => s.ThisYear && s.Estimated).Sum(s => s.Amount), - mine.Where(s => s.Estimated).Sum(s => s.Amount)); + mine.Where(s => s.Estimated).Sum(s => s.Amount), + since); } return result; diff --git a/web/public/i18n/en.json b/web/public/i18n/en.json index 12394ac..c6cc61a 100644 --- a/web/public/i18n/en.json +++ b/web/public/i18n/en.json @@ -23,7 +23,8 @@ "assistant": "Assistant", "more": "More", "main": "Main navigation", - "general": "General" + "general": "General", + "loans": "Loans" }, "common": { "save": "Save", @@ -404,7 +405,13 @@ "countsTowards": "Deposits count towards", "countsTowardsHint": "Money you deposit here counts in that bucket’s monthly allocation, without recording it by hand.", "noBucket": "No bucket", - "countsTowardsSaved": "Saved. The monthly allocation now includes this broker’s deposits." + "countsTowardsSaved": "Saved. The monthly allocation now includes this broker’s deposits.", + "group": { + "money": "Everyday money", + "savings": "Savings", + "investments": "Investments", + "debts": "Debts" + } }, "categories": { "new": "New category", @@ -1022,7 +1029,12 @@ }, "history": "Rate history", "withheld": "{{pct}} withheld", - "removeLatest": "Remove latest rate" + "removeLatest": "Remove latest rate", + "perDay": "≈ {{gross}}/day gross ({{net}} net)", + "perDayHint": "Balance × TANB / 365, before the {{withholding}} % withholding tax.", + "thisYearNet": "{{amount}} net this year", + "since": "(since {{date}})", + "thisYearHint": "Interest after withholding tax, from the days the app knows this account and its rate. Record interest the bank paid earlier as income in the Interest category to include it." }, "crypto": { "addButton": "Add crypto", @@ -1245,6 +1257,15 @@ "skip": "Skip", "dashboardTitle": "Loans", "owed": "Owed", - "endsOn": "ends {{date}}" + "endsOn": "ends {{date}}", + "listSubtitle": "Mortgages, car and personal loans: what you owe and when each ends.", + "new": "New loan", + "monthly": "Instalments per month", + "interestLeftAll": "Interest still to pay", + "emptyTitle": "No loans", + "emptyText": "Add a mortgage, car or personal loan to see what you owe, the instalment, when it ends and what early repayments would save.", + "namePlaceholder": "Mortgage, Car loan…", + "newHint": "Next you enter the amount, rate and term.", + "next": "Continue" } } diff --git a/web/public/i18n/pt-PT.json b/web/public/i18n/pt-PT.json index b3aa814..0bf771d 100644 --- a/web/public/i18n/pt-PT.json +++ b/web/public/i18n/pt-PT.json @@ -23,7 +23,8 @@ "assistant": "Assistente", "more": "Mais", "main": "Navegação principal", - "general": "Geral" + "general": "Geral", + "loans": "Empréstimos" }, "common": { "save": "Guardar", @@ -404,7 +405,13 @@ "countsTowards": "Os depósitos contam para", "countsTowardsHint": "O dinheiro que depositas aqui conta na alocação mensal desse balde, sem o registares à mão.", "noBucket": "Nenhum balde", - "countsTowardsSaved": "Guardado. A alocação mensal passa a incluir os depósitos desta corretora." + "countsTowardsSaved": "Guardado. A alocação mensal passa a incluir os depósitos desta corretora.", + "group": { + "money": "Dinheiro do dia a dia", + "savings": "Poupança", + "investments": "Investimentos", + "debts": "Dívidas" + } }, "categories": { "new": "Nova categoria", @@ -1022,7 +1029,12 @@ }, "history": "Histórico de taxas", "withheld": "{{pct}} retidos", - "removeLatest": "Remover a taxa mais recente" + "removeLatest": "Remover a taxa mais recente", + "perDay": "≈ {{gross}}/dia brutos ({{net}} líquidos)", + "perDayHint": "Saldo × TANB / 365, antes da retenção de {{withholding}} %.", + "thisYearNet": "{{amount}} líquidos este ano", + "since": "(desde {{date}})", + "thisYearHint": "Juros depois da retenção na fonte, nos dias em que a app conhece esta conta e a taxa. Para incluir juros que o banco pagou antes, regista-os como receita na categoria Juros." }, "crypto": { "addButton": "Adicionar cripto", @@ -1245,6 +1257,15 @@ "skip": "Saltar", "dashboardTitle": "Empréstimos", "owed": "Em dívida", - "endsOn": "termina {{date}}" + "endsOn": "termina {{date}}", + "listSubtitle": "Crédito habitação, automóvel e pessoal: quanto deves e quando acaba cada um.", + "new": "Novo empréstimo", + "monthly": "Prestações por mês", + "interestLeftAll": "Juros por pagar", + "emptyTitle": "Sem empréstimos", + "emptyText": "Adiciona um crédito habitação, automóvel ou pessoal para veres quanto deves, a prestação, quando acaba e quanto pouparias a amortizar.", + "namePlaceholder": "Crédito habitação, Carro…", + "newHint": "A seguir indicas o montante, a taxa e o prazo.", + "next": "Continuar" } } diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index a4d8b7e..2acb0c1 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -67,6 +67,10 @@ export const routes: Routes = [ loadComponent: () => import('./features/connections/connections').then((m) => m.ConnectionsComponent), }, + { + path: 'loans', + loadComponent: () => import('./features/loans/loans').then((m) => m.LoansComponent), + }, { path: 'loans/:accountId', loadComponent: () => import('./features/loans/loan').then((m) => m.LoanComponent), diff --git a/web/src/app/core/models.ts b/web/src/app/core/models.ts index e47edf8..a5e4c58 100644 --- a/web/src/app/core/models.ts +++ b/web/src/app/core/models.ts @@ -110,6 +110,8 @@ export interface AccountInterest { yearToDateEstimated: number; /** Part of the balance that is still an estimate. */ estimatedInBalance: number; + /** This year's interest counts from this day (the account or its rate is newer than 1 January). */ + accruingSince?: string | null; } export interface InterestRate { diff --git a/web/src/app/features/accounts/accounts.ts b/web/src/app/features/accounts/accounts.ts index 080a3b4..0424859 100644 --- a/web/src/app/features/accounts/accounts.ts +++ b/web/src/app/features/accounts/accounts.ts @@ -100,159 +100,207 @@ export const supportsInterest = (kind: AccountKind) => kind === 'Savings' || kin -
- @for (a of visible(); track a.id) { -
-
-
- @if (brokerOf(a); as broker) { -
+
+ @if (a.isManual) { +
+ + +
+ } +
+ } +
+ } @empty { +
+ + + +
+ } this.events.version(), stream: () => this.api.buckets(), }); + /** Today's gross interest: balance × TANB / 365 (the app's and the banks' day count). */ + protected dailyGross(a: Account): number | null { + const rate = a.interest?.annualRatePercent; + if (rate == null || a.balance <= 0) return null; + return Math.round(((a.balance * rate) / 100 / 365) * 100) / 100; + } + + /** When this year's interest only starts later than 1 January (the account or its rate is newer). */ + protected sinceThisYear(since: string | null | undefined): string | null { + return since && !since.endsWith('-01-01') ? since : null; + } + protected readonly bucketOptions = computed(() => { this.prefs.translations(); return [ @@ -542,6 +602,21 @@ export class AccountsComponent { (this.accounts.value() ?? []).filter((a) => this.showArchived() || !a.archived), ); + /** Accounts by what they are: everyday money, savings, investments, debts (cards and loans). */ + protected readonly groups = computed(() => { + const groupOf = (a: Account) => + a.kind === 'Savings' + ? 'savings' + : a.kind === 'Broker' + ? 'investments' + : a.kind === 'CreditCard' || a.kind === 'Loan' + ? 'debts' + : 'money'; + return ['money', 'savings', 'investments', 'debts'] + .map((key) => ({ key, accounts: this.visible().filter((a) => groupOf(a) === key) })) + .filter((g) => g.accounts.length > 0); + }); + protected patch(p: Partial) { this.form.update((f) => ({ ...f, ...p })); } diff --git a/web/src/app/features/loans/loans.ts b/web/src/app/features/loans/loans.ts new file mode 100644 index 0000000..9e0853c --- /dev/null +++ b/web/src/app/features/loans/loans.ts @@ -0,0 +1,179 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; +import { NgIcon } from '@ng-icons/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { HlmButtonImports } from '@spartan-ng/helm/button'; +import { HlmInputImports } from '@spartan-ng/helm/input'; +import { firstValueFrom } from 'rxjs'; +import { Api } from '../../core/api'; +import { DataEvents } from '../../core/data-events'; +import { DayPipe, MoneyPipe, PercentPipe } from '../../core/format'; +import { liveResource } from '../../core/resource'; +import { Toasts } from '../../core/toast'; +import { EmptyStateComponent } from '../../shared/empty-state'; +import { APP_ICONS, PAGE_ICONS } from '../../shared/icons'; +import { ModalComponent } from '../../shared/modal'; +import { PageHeaderComponent } from '../../shared/page-header'; +import { ProgressComponent } from '../../shared/progress'; + +/** + * Every loan in one place: what is owed, the next instalment and when each ends. A loan is a debt account (so it + * counts in net worth and takes the capital transfers) with credit terms; new ones are created here. + */ +@Component({ + selector: 'app-loans', + imports: [ + RouterLink, + NgIcon, + TranslatePipe, + HlmButtonImports, + HlmInputImports, + MoneyPipe, + DayPipe, + PercentPipe, + EmptyStateComponent, + ModalComponent, + PageHeaderComponent, + ProgressComponent, + ], + providers: [APP_ICONS], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + @let ls = loans.value() ?? []; + @if (ls.length) { +
+
+

{{ 'loans.owed' | translate }}

+

{{ owed() | money }}

+
+
+

{{ 'loans.monthly' | translate }}

+

{{ monthly() | money }}

+
+
+

{{ 'loans.interestLeftAll' | translate }}

+

{{ interestLeft() | money }}

+
+
+
+ @for (l of ls; track l.accountId) { + +
+

{{ l.accountName }}

+ {{ + l.currentRatePercent / 100 | pct: 2 + }} +
+

{{ l.outstanding | money }}

+ +
+
+
{{ 'loans.nextPayment' | translate }}
+
+ {{ l.next ? (l.next.payment | money) : '—' }} +
+
+
+
{{ 'loans.ends' | translate }}
+
{{ l.endDate | day }}
+
+
+
+ } +
+ } @else if (loans.hasValue()) { +
+ + + +
+ } + + +
+
+ + +

{{ 'loans.newHint' | translate }}

+
+
+ + +
+
+
+ `, +}) +export class LoansComponent { + protected readonly icons = PAGE_ICONS; + private readonly api = inject(Api); + private readonly events = inject(DataEvents); + private readonly toasts = inject(Toasts); + private readonly router = inject(Router); + private readonly i18n = inject(TranslateService); + + protected readonly loans = liveResource({ + params: () => this.events.version(), + stream: () => this.api.loans(), + }); + protected readonly owed = computed(() => + (this.loans.value() ?? []).reduce((s, l) => s + l.outstanding, 0), + ); + protected readonly monthly = computed(() => + (this.loans.value() ?? []).reduce((s, l) => s + (l.next?.payment ?? 0), 0), + ); + protected readonly interestLeft = computed(() => + (this.loans.value() ?? []).reduce((s, l) => s + l.interestLeft, 0), + ); + + protected readonly newOpen = signal(false); + protected readonly name = signal(''); + + /** A loan is a debt account; its terms are filled in on the next screen. */ + protected async create() { + try { + const account = await firstValueFrom( + this.api.createAccount({ + name: this.name().trim(), + kind: 'Loan', + currency: 'EUR', + openingBalance: 0, + institution: null, + }), + ); + this.newOpen.set(false); + this.name.set(''); + this.events.bump(); + await this.router.navigate(['/loans', account.id]); + } catch (err) { + this.toasts.error(err); + } + } +} diff --git a/web/src/app/shared/icons.ts b/web/src/app/shared/icons.ts index 4344916..1a88abf 100644 --- a/web/src/app/shared/icons.ts +++ b/web/src/app/shared/icons.ts @@ -221,6 +221,7 @@ export const APP_ICONS = provideIcons({ /** Sidebar icon per route; page headers reuse it so a page and its nav entry always match. */ export const PAGE_ICONS = { + loans: 'lucideHandCoins', dashboard: 'lucideLayoutDashboard', monthly: 'lucideCalendarDays', annual: 'lucideChartColumn', diff --git a/web/src/app/shell/shell.ts b/web/src/app/shell/shell.ts index 67e8929..90ae90a 100644 --- a/web/src/app/shell/shell.ts +++ b/web/src/app/shell/shell.ts @@ -3,12 +3,15 @@ import { Component, DestroyRef, HostListener, + computed, inject, signal, } from '@angular/core'; import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; -import { filter, map } from 'rxjs'; +import { catchError, filter, map, of } from 'rxjs'; +import { HttpClient, HttpContext } from '@angular/common/http'; +import { BACKGROUND } from '../core/activity'; import { SETTINGS_ROUTES } from '../shared/settings-tabs'; import { NgIcon } from '@ng-icons/core'; import { TranslatePipe } from '@ngx-translate/core'; @@ -69,7 +72,7 @@ interface NavItem {