feat: add recurring transactions (Wiederkehrende Buchungen)

Lets treasurers/captains/coaches define recurring fee/levy dues that
are automatically booked for all active players on a monthly,
quarterly, or yearly schedule via a daily cron job, instead of having
to book them manually every cycle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-03 20:20:57 +02:00
parent 6531f2553f
commit 9e55c0549b
25 changed files with 2181 additions and 1 deletions

View File

@@ -88,6 +88,13 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/team/more/penalties/penalties').then((m) => m.Penalties),
},
{
path: 'more/recurring-transactions',
loadComponent: () =>
import('./features/team/more/recurring-transactions/recurring-transactions').then(
(m) => m.RecurringTransactions,
),
},
{
path: 'more/invite',
loadComponent: () => import('./features/team/more/invite/invite').then((m) => m.Invite),

View File

@@ -0,0 +1,65 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { environment } from '../../../environments/environment';
import { RecurringTransactionApi } from './recurring-transaction-api';
describe('RecurringTransactionApi', () => {
let api: RecurringTransactionApi;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
api = TestBed.inject(RecurringTransactionApi);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('loads the recurring transactions of a team', () => {
api.loadRecurringTransactions(5).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions/5`);
expect(request.request.method).toBe('GET');
request.flush([]);
});
it('creates a recurring transaction', () => {
const entry = {
teamId: 5,
description: 'Monatsbeitrag',
amount: 10,
type: 13,
interval: 'monthly' as const,
startDate: '2026-09-01T00:00:00.000Z',
};
api.createRecurringTransaction(entry).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions`);
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual(entry);
request.flush({ id: 1, ...entry });
});
it('updates a recurring transaction', () => {
const update = {
description: 'Monatsbeitrag',
amount: 12,
type: 12,
interval: 'quarterly' as const,
active: false,
};
api.updateRecurringTransaction(8, update).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions/8`);
expect(request.request.method).toBe('PATCH');
expect(request.request.body).toEqual(update);
request.flush({ id: 8, ...update });
});
it('deletes a recurring transaction', () => {
api.deleteRecurringTransaction(8).subscribe();
const request = httpMock.expectOne(`${environment.apiUrl}recurring-transactions/8`);
expect(request.request.method).toBe('DELETE');
request.flush(null);
});
});

View File

@@ -0,0 +1,36 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../../environments/environment';
import {
CreateRecurringTransaction,
RecurringTransaction,
UpdateRecurringTransaction,
} from '../../models/recurring-transaction.model';
@Injectable({ providedIn: 'root' })
export class RecurringTransactionApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = `${environment.apiUrl}recurring-transactions`;
loadRecurringTransactions(teamId: number): Observable<RecurringTransaction[]> {
return this.http.get<RecurringTransaction[]>(`${this.baseUrl}/${teamId}`);
}
createRecurringTransaction(
entry: CreateRecurringTransaction,
): Observable<RecurringTransaction> {
return this.http.post<RecurringTransaction>(this.baseUrl, entry);
}
updateRecurringTransaction(
id: number,
entry: UpdateRecurringTransaction,
): Observable<RecurringTransaction> {
return this.http.patch<RecurringTransaction>(`${this.baseUrl}/${id}`, entry);
}
deleteRecurringTransaction(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
}

View File

@@ -40,6 +40,16 @@
<mat-icon>chevron_right</mat-icon></mat-card
></a
>
<a routerLink="recurring-transactions"
><mat-card
><mat-icon>event_repeat</mat-icon>
<div>
<strong>Wiederkehrende Buchungen</strong
><span>Fälligkeiten automatisch buchen lassen</span>
</div>
<mat-icon>chevron_right</mat-icon></mat-card
></a
>
<a routerLink="invite"
><mat-card
><mat-icon>person_add</mat-icon>

View File

@@ -0,0 +1,185 @@
<a mat-button routerLink="../"><mat-icon>arrow_back</mat-icon>Mehr</a>
<header>
<p class="eyebrow">Kasse</p>
<h1>Wiederkehrende Buchungen</h1>
<p>Fälligkeiten wie den Monatsbeitrag automatisch für alle aktiven Spieler buchen lassen.</p>
</header>
@if (canManage()) {
<mat-card class="create-card">
<form [formGroup]="form" (ngSubmit)="createEntry()" aria-label="Wiederkehrende Buchung anlegen">
<mat-form-field appearance="outline">
<mat-label>Beschreibung</mat-label>
<input matInput maxlength="120" formControlName="description" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Betrag</mat-label>
<input matInput type="number" min="0.01" max="10000" step="0.01" formControlName="amount" />
<span matTextSuffix></span>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Typ</mat-label>
<mat-select formControlName="type">
@for (option of typeOptions; track option.id) {
<mat-option [value]="option.id">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Intervall</mat-label>
<mat-select formControlName="interval">
@for (option of intervalOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Erste Buchung am</mat-label>
<input matInput type="date" formControlName="startDate" />
</mat-form-field>
<button mat-flat-button type="submit" [disabled]="form.invalid || mutationPending()">
@if (saving()) {
<mat-spinner diameter="18" />
} @else {
<mat-icon>add</mat-icon>
}
Eintrag anlegen
</button>
</form>
</mat-card>
}
@if (mutationError()) {
<p class="error-message" role="alert">{{ mutationError() }}</p>
}
@if (loading()) {
<div class="state" aria-live="polite">
<mat-spinner diameter="36" />
<span>Liste wird geladen …</span>
</div>
} @else if (loadError()) {
<div class="state" role="alert">
<mat-icon>error_outline</mat-icon>
<strong>{{ loadError() }}</strong>
<button mat-stroked-button type="button" (click)="retryLoad()">Erneut versuchen</button>
</div>
} @else if (entries().length === 0) {
<div class="state">
<mat-icon>event_repeat</mat-icon>
<strong>Noch keine Einträge</strong>
<span>Es gibt noch keine wiederkehrenden Buchungen für dieses Team.</span>
</div>
} @else {
<div class="catalog">
@for (entry of entries(); track entry.id) {
<mat-card class="entry-card" [class.paused]="!entry.active">
@if (editingEntryId() === entry.id) {
<form
class="edit-form"
[formGroup]="editForm"
(ngSubmit)="saveEntry(entry)"
[attr.aria-label]="'Eintrag ' + entry.description + ' bearbeiten'"
>
<mat-form-field appearance="outline">
<mat-label>Beschreibung</mat-label>
<input matInput maxlength="120" formControlName="description" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Betrag</mat-label>
<input
matInput
type="number"
min="0.01"
max="10000"
step="0.01"
formControlName="amount"
/>
<span matTextSuffix></span>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Typ</mat-label>
<mat-select formControlName="type">
@for (option of typeOptions; track option.id) {
<mat-option [value]="option.id">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Intervall</mat-label>
<mat-select formControlName="interval">
@for (option of intervalOptions; track option.value) {
<mat-option [value]="option.value">{{ option.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<div class="edit-actions">
<button
mat-button
type="button"
(click)="cancelEdit()"
[disabled]="pendingEntryId() === entry.id"
>
Abbrechen
</button>
<button
mat-flat-button
type="submit"
[disabled]="editForm.invalid || pendingEntryId() === entry.id"
>
@if (pendingEntryId() === entry.id) {
<mat-spinner diameter="18" />
} @else {
<mat-icon>save</mat-icon>
}
Speichern
</button>
</div>
</form>
} @else {
<div class="entry-content">
<span>{{ entry.description }}</span>
<strong>{{ entry.amount | currency: 'EUR' }}</strong>
@if (!entry.active) {
<span class="paused-badge">Pausiert</span>
} @else {
<span class="next-run">nächste Buchung am {{ entry.nextRunDate | date: 'dd.MM.yyyy' }}</span>
}
</div>
<div class="entry-actions">
@if (canManage()) {
<button
mat-button
type="button"
(click)="toggleActive(entry)"
[disabled]="mutationPending()"
[attr.aria-label]="(entry.active ? 'Pausieren' : 'Fortsetzen') + ': ' + entry.description"
>
<mat-icon>{{ entry.active ? 'pause_circle' : 'play_circle' }}</mat-icon>
{{ entry.active ? 'Pausieren' : 'Fortsetzen' }}
</button>
<button
mat-button
type="button"
(click)="startEdit(entry)"
[disabled]="mutationPending()"
[attr.aria-label]="entry.description + ' bearbeiten'"
>
<mat-icon>edit</mat-icon>Bearbeiten
</button>
<button
mat-button
type="button"
(click)="confirmDelete(entry)"
[disabled]="mutationPending()"
[attr.aria-label]="entry.description + ' löschen'"
>
<mat-icon>delete</mat-icon>Löschen
</button>
}
</div>
}
</mat-card>
}
</div>
}

View File

@@ -0,0 +1,157 @@
:host {
display: block;
padding: 24px 28px;
max-width: 900px;
margin: 0 auto;
}
header {
margin: 20px 0 26px;
}
h1,
p {
margin-top: 0;
}
h1 {
font-size: clamp(2rem, 4vw, 3rem);
line-height: clamp(2rem, 4vw, 3rem);
margin-bottom: 8px;
}
.eyebrow {
color: var(--mat-sys-primary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 6px;
}
.create-card {
padding: 20px;
border-radius: 18px;
margin-bottom: 22px;
}
.create-card form,
.edit-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px 150px 160px 160px auto;
gap: 12px;
align-items: start;
}
.create-card button,
.edit-actions button {
min-height: 48px;
}
.create-card button mat-spinner,
.edit-actions button mat-spinner {
display: inline-block;
margin-right: 8px;
}
.error-message {
padding: 12px 16px;
border-radius: 12px;
color: var(--mat-sys-error);
background: var(--mat-sys-error-container);
}
.catalog {
display: grid;
gap: 10px;
}
.entry-card {
padding: 16px 18px;
border-radius: 16px;
&.paused {
opacity: 0.7;
}
}
.entry-content {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: baseline;
flex-wrap: wrap;
}
.next-run,
.paused-badge {
font-size: 0.8rem;
color: var(--mat-sys-on-surface-variant);
}
.paused-badge {
color: var(--mat-sys-error);
font-weight: 600;
}
.entry-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
margin-top: 8px;
}
.edit-form {
grid-template-columns: minmax(0, 1fr) 140px 150px 160px;
}
.edit-actions {
grid-column: 1 / -1;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.state {
min-height: 180px;
display: grid;
place-content: center;
justify-items: center;
text-align: center;
gap: 10px;
color: var(--mat-sys-on-surface-variant);
}
@media (max-width: 900px) {
:host {
padding: 20px 16px;
}
.create-card form,
.edit-form {
grid-template-columns: 1fr;
}
.create-card form button,
.edit-actions,
.edit-actions button {
width: 100%;
}
.edit-actions {
grid-column: auto;
flex-direction: column-reverse;
}
.entry-content {
align-items: flex-start;
}
.entry-actions {
justify-content: stretch;
}
.entry-actions button {
flex: 1;
}
}

View File

@@ -0,0 +1,234 @@
import { HttpErrorResponse } from '@angular/common/http';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { provideRouter } from '@angular/router';
import { Subject, of, throwError } from 'rxjs';
import { AuthStore } from '../../../../core/auth/auth-store';
import { RecurringTransactionApi } from '../../../../core/team/recurring-transaction-api';
import { TeamStore } from '../../../../core/team/team-store';
import { RecurringTransaction } from '../../../../models/recurring-transaction.model';
import { RecurringTransactions } from './recurring-transactions';
const first: RecurringTransaction = {
id: 1,
description: 'Monatsbeitrag',
amount: 10,
type: 13,
interval: 'monthly',
nextRunDate: '2026-09-01T00:00:00.000Z',
active: true,
createdAt: '2026-01-01T00:00:00.000Z',
};
const team = {
id: 5,
name: 'Team A',
alias: 'a',
balance: 0,
players: [
{
id: 1,
firstName: 'Alex',
lastName: 'Muster',
balance: 0,
active: true,
teamRole: { id: 3 },
user: { id: 42 },
},
],
};
describe('RecurringTransactions', () => {
let fixture: ComponentFixture<RecurringTransactions>;
let currentUser: ReturnType<typeof signal<{ id: number; role: { id: number } }>>;
let loadRecurringTransactions: ReturnType<typeof vi.fn>;
let createRecurringTransaction: ReturnType<typeof vi.fn>;
let updateRecurringTransaction: ReturnType<typeof vi.fn>;
let deleteRecurringTransaction: ReturnType<typeof vi.fn>;
let dialogClosed: Subject<boolean>;
let dialog: { open: ReturnType<typeof vi.fn> };
beforeEach(async () => {
currentUser = signal({ id: 42, role: { id: 2 } });
loadRecurringTransactions = vi.fn(() => of([first]));
createRecurringTransaction = vi.fn(() =>
of({ ...first, id: 2, description: 'Ausrüstung' }),
);
updateRecurringTransaction = vi.fn(() => of({ ...first, description: 'Neu', amount: 12 }));
deleteRecurringTransaction = vi.fn(() => of(undefined));
dialogClosed = new Subject<boolean>();
dialog = { open: vi.fn(() => ({ afterClosed: () => dialogClosed.asObservable() })) };
await TestBed.configureTestingModule({
imports: [RecurringTransactions],
providers: [
provideRouter([]),
{ provide: TeamStore, useValue: { team: signal(team) } },
{ provide: AuthStore, useValue: { currentUser } },
{
provide: RecurringTransactionApi,
useValue: {
loadRecurringTransactions,
createRecurringTransaction,
updateRecurringTransaction,
deleteRecurringTransaction,
},
},
{ provide: MatDialog, useValue: dialog },
],
}).compileComponents();
});
function create(): void {
fixture = TestBed.createComponent(RecurringTransactions);
fixture.detectChanges();
}
function text(): string {
return (fixture.nativeElement as HTMLElement).textContent ?? '';
}
function button(label: string): HTMLButtonElement {
const match = [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')].find(
(element) => element.textContent?.includes(label),
);
if (!match) throw new Error(`Missing button: ${label}`);
return match as HTMLButtonElement;
}
it('shows entries without mutation controls to a reader', () => {
currentUser.set({ id: 7, role: { id: 2 } });
create();
expect(text()).toContain('Monatsbeitrag');
expect((fixture.nativeElement as HTMLElement).querySelector('.create-card')).toBeNull();
expect(text()).not.toContain('Bearbeiten');
expect(text()).not.toContain('Löschen');
});
it('lets an authorized user create an entry and reloads authoritative data', () => {
loadRecurringTransactions
.mockReturnValueOnce(of([first]))
.mockReturnValueOnce(of([first, { ...first, id: 2, description: 'Ausrüstung' }]));
create();
fixture.componentInstance['form'].setValue({
description: 'Ausrüstung',
amount: 20,
type: 12,
interval: 'yearly',
startDate: '2026-09-01',
});
fixture.componentInstance['createEntry']();
fixture.detectChanges();
expect(createRecurringTransaction).toHaveBeenCalledWith({
teamId: 5,
description: 'Ausrüstung',
amount: 20,
type: 12,
interval: 'yearly',
startDate: new Date('2026-09-01T12:00:00').toISOString(),
});
expect(loadRecurringTransactions).toHaveBeenCalledTimes(2);
expect(text()).toContain('Ausrüstung');
});
it('rejects blank descriptions and amounts with more than two decimals', () => {
create();
fixture.componentInstance['form'].patchValue({ description: ' ', amount: 1.234 });
expect(fixture.componentInstance['form'].invalid).toBe(true);
fixture.componentInstance['createEntry']();
expect(createRecurringTransaction).not.toHaveBeenCalled();
});
it('opens one inline editor, supports cancel, and saves pessimistically', () => {
const updateResult = new Subject<RecurringTransaction>();
updateRecurringTransaction.mockReturnValue(updateResult);
loadRecurringTransactions
.mockReturnValueOnce(of([first]))
.mockReturnValueOnce(of([{ ...first, description: 'Neu', amount: 12 }]));
create();
button('Bearbeiten').click();
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.edit-form')).not.toBeNull();
fixture.componentInstance['cancelEdit']();
fixture.detectChanges();
expect((fixture.nativeElement as HTMLElement).querySelector('.edit-form')).toBeNull();
button('Bearbeiten').click();
fixture.componentInstance['editForm'].patchValue({ description: 'Neu', amount: 12 });
fixture.componentInstance['saveEntry'](first);
fixture.detectChanges();
expect(fixture.componentInstance['editingEntryId']()).toBe(first.id);
expect(loadRecurringTransactions).toHaveBeenCalledTimes(1);
updateResult.next({ ...first, description: 'Neu', amount: 12 });
fixture.detectChanges();
expect(updateRecurringTransaction).toHaveBeenCalledWith(1, {
description: 'Neu',
amount: 12,
type: 13,
interval: 'monthly',
active: true,
});
expect(loadRecurringTransactions).toHaveBeenCalledTimes(2);
expect(text()).toContain('Neu');
});
it('pauses and resumes an entry via the active toggle', () => {
const paused = { ...first, active: false };
updateRecurringTransaction.mockReturnValue(of(paused));
loadRecurringTransactions
.mockReturnValueOnce(of([first]))
.mockReturnValueOnce(of([paused]));
create();
button('Pausieren').click();
fixture.detectChanges();
expect(updateRecurringTransaction).toHaveBeenCalledWith(1, {
description: 'Monatsbeitrag',
amount: 10,
type: 13,
interval: 'monthly',
active: false,
});
expect(loadRecurringTransactions).toHaveBeenCalledTimes(2);
expect(text()).toContain('Fortsetzen');
});
it('confirms deletion and reloads only after server success', () => {
const deletion = new Subject<void>();
deleteRecurringTransaction.mockReturnValue(deletion);
loadRecurringTransactions.mockReturnValueOnce(of([first])).mockReturnValueOnce(of([]));
create();
button('Löschen').click();
expect(dialog.open).toHaveBeenCalled();
dialogClosed.next(true);
expect(deleteRecurringTransaction).toHaveBeenCalledWith(1);
expect(loadRecurringTransactions).toHaveBeenCalledTimes(1);
deletion.next();
fixture.detectChanges();
expect(loadRecurringTransactions).toHaveBeenCalledTimes(2);
expect(text()).toContain('Noch keine Einträge');
});
it('shows a load error and retries', () => {
loadRecurringTransactions
.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 500 })))
.mockReturnValueOnce(of([first]));
create();
fixture.detectChanges();
expect(text()).toContain('konnte nicht geladen werden');
button('Erneut versuchen').click();
fixture.detectChanges();
expect(text()).toContain('Monatsbeitrag');
});
});

View File

@@ -0,0 +1,306 @@
import { CurrencyPipe, DatePipe, registerLocaleData } from '@angular/common';
import localeDe from '@angular/common/locales/de';
import { HttpErrorResponse } from '@angular/common/http';
import { Component, DestroyRef, LOCALE_ID, computed, effect, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatDialog } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { RouterLink } from '@angular/router';
import { EMPTY, Observable, catchError, finalize, switchMap, take, tap } from 'rxjs';
import { RecurringTransactionApi } from '../../../../core/team/recurring-transaction-api';
import { TeamPermissionsService } from '../../../../core/team/team-permissions';
import { TeamStore } from '../../../../core/team/team-store';
import {
RecurringTransaction,
RecurringTransactionInterval,
} from '../../../../models/recurring-transaction.model';
import { ConfirmDialog } from '../../../../shared/confirm-dialog/confirm-dialog';
registerLocaleData(localeDe);
const TYPE_OPTIONS = [
{ id: 13, label: 'Gebühr' },
{ id: 12, label: 'Umlage' },
];
const INTERVAL_OPTIONS: { value: RecurringTransactionInterval; label: string }[] = [
{ value: 'monthly', label: 'Monatlich' },
{ value: 'quarterly', label: 'Quartalsweise' },
{ value: 'yearly', label: 'Jährlich' },
];
@Component({
selector: 'app-recurring-transactions',
imports: [
CurrencyPipe,
DatePipe,
ReactiveFormsModule,
RouterLink,
MatButtonModule,
MatCardModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
],
providers: [{ provide: LOCALE_ID, useValue: 'de-DE' }],
templateUrl: './recurring-transactions.html',
styleUrl: './recurring-transactions.scss',
})
export class RecurringTransactions {
private readonly destroyRef = inject(DestroyRef);
private readonly dialog = inject(MatDialog);
private readonly formBuilder = inject(FormBuilder);
private readonly api = inject(RecurringTransactionApi);
private readonly permissions = inject(TeamPermissionsService);
private readonly teamStore = inject(TeamStore);
private loadedTeamId: number | null = null;
protected readonly typeOptions = TYPE_OPTIONS;
protected readonly intervalOptions = INTERVAL_OPTIONS;
protected readonly team = this.teamStore.team;
protected readonly entries = signal<RecurringTransaction[]>([]);
protected readonly loading = signal(false);
protected readonly saving = signal(false);
protected readonly pendingEntryId = signal<number | null>(null);
protected readonly editingEntryId = signal<number | null>(null);
protected readonly loadError = signal<string | null>(null);
protected readonly mutationError = signal<string | null>(null);
protected readonly mutationPending = computed(
() => this.saving() || this.pendingEntryId() !== null,
);
protected readonly canManage = computed(() =>
this.permissions.canDo(this.team(), 'transactionCreate'),
);
protected readonly form = this.formBuilder.nonNullable.group({
description: ['', [Validators.required, Validators.maxLength(120), Validators.pattern(/\S/)]],
amount: [
0,
[
Validators.required,
Validators.min(0.01),
Validators.max(10000),
Validators.pattern(/^\d+(\.\d{1,2})?$/),
],
],
type: [13, Validators.required],
interval: ['monthly' as RecurringTransactionInterval, Validators.required],
startDate: ['', Validators.required],
});
protected readonly editForm = this.formBuilder.nonNullable.group({
description: ['', [Validators.required, Validators.maxLength(120), Validators.pattern(/\S/)]],
amount: [
0,
[
Validators.required,
Validators.min(0.01),
Validators.max(10000),
Validators.pattern(/^\d+(\.\d{1,2})?$/),
],
],
type: [13, Validators.required],
interval: ['monthly' as RecurringTransactionInterval, Validators.required],
});
constructor() {
effect(() => {
const teamId = this.team()?.id;
if (teamId && teamId !== this.loadedTeamId) {
this.loadedTeamId = teamId;
this.load(teamId);
}
});
}
protected createEntry(): void {
const team = this.team();
if (!this.canManage() || !team || this.form.invalid || this.mutationPending()) return;
this.saving.set(true);
this.mutationError.set(null);
const { description, amount, type, interval, startDate } = this.form.getRawValue();
this.api
.createRecurringTransaction({
teamId: team.id,
description,
amount,
type,
interval,
startDate: this.toIso(startDate),
})
.pipe(
tap(() =>
this.form.reset({ description: '', amount: 0, type: 13, interval: 'monthly', startDate: '' }),
),
switchMap(() => this.reloadAfterMutation(team.id)),
finalize(() => this.saving.set(false)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (entries) => this.entries.set(entries),
error: (error: HttpErrorResponse) =>
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht angelegt werden.')),
});
}
protected startEdit(entry: RecurringTransaction): void {
if (!this.canManage() || this.mutationPending()) return;
this.editingEntryId.set(entry.id);
this.editForm.setValue({
description: entry.description,
amount: entry.amount,
type: entry.type,
interval: entry.interval,
});
this.mutationError.set(null);
}
protected cancelEdit(): void {
if (this.pendingEntryId() !== null) return;
this.editingEntryId.set(null);
this.mutationError.set(null);
}
protected saveEntry(entry: RecurringTransaction): void {
const teamId = this.team()?.id;
if (
!this.canManage() ||
!teamId ||
this.editingEntryId() !== entry.id ||
this.editForm.invalid ||
this.mutationPending()
) {
return;
}
this.pendingEntryId.set(entry.id);
this.mutationError.set(null);
this.api
.updateRecurringTransaction(entry.id, { ...this.editForm.getRawValue(), active: entry.active })
.pipe(
tap(() => this.editingEntryId.set(null)),
switchMap(() => this.reloadAfterMutation(teamId)),
finalize(() => this.pendingEntryId.set(null)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (entries) => this.entries.set(entries),
error: (error: HttpErrorResponse) =>
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht gespeichert werden.')),
});
}
protected toggleActive(entry: RecurringTransaction): void {
const teamId = this.team()?.id;
if (!this.canManage() || !teamId || this.mutationPending()) return;
this.pendingEntryId.set(entry.id);
this.mutationError.set(null);
this.api
.updateRecurringTransaction(entry.id, {
description: entry.description,
amount: entry.amount,
type: entry.type,
interval: entry.interval,
active: !entry.active,
})
.pipe(
switchMap(() => this.reloadAfterMutation(teamId)),
finalize(() => this.pendingEntryId.set(null)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (entries) => this.entries.set(entries),
error: (error: HttpErrorResponse) =>
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht geändert werden.')),
});
}
protected confirmDelete(entry: RecurringTransaction): void {
if (!this.canManage() || this.mutationPending()) return;
this.dialog
.open(ConfirmDialog, {
data: {
title: 'Eintrag löschen?',
message: `${entry.description}“ wird endgültig gelöscht.`,
confirmLabel: 'Löschen',
},
restoreFocus: true,
})
.afterClosed()
.pipe(take(1), takeUntilDestroyed(this.destroyRef))
.subscribe((confirmed) => {
if (confirmed) this.deleteEntry(entry);
});
}
protected retryLoad(): void {
const teamId = this.team()?.id;
if (teamId) this.load(teamId);
}
private deleteEntry(entry: RecurringTransaction): void {
const teamId = this.team()?.id;
if (!teamId) return;
this.pendingEntryId.set(entry.id);
this.mutationError.set(null);
this.api
.deleteRecurringTransaction(entry.id)
.pipe(
switchMap(() => this.reloadAfterMutation(teamId)),
finalize(() => this.pendingEntryId.set(null)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (entries) => this.entries.set(entries),
error: (error: HttpErrorResponse) =>
this.mutationError.set(this.errorMessage(error, 'Eintrag konnte nicht gelöscht werden.')),
});
}
private reloadAfterMutation(teamId: number): Observable<RecurringTransaction[]> {
return this.api.loadRecurringTransactions(teamId).pipe(
catchError(() => {
this.loadError.set(
'Änderung wurde gespeichert, aber die Liste konnte nicht aktualisiert werden.',
);
return EMPTY;
}),
);
}
private load(teamId: number): void {
this.loading.set(true);
this.loadError.set(null);
this.api
.loadRecurringTransactions(teamId)
.pipe(
finalize(() => this.loading.set(false)),
takeUntilDestroyed(this.destroyRef),
)
.subscribe({
next: (entries) => this.entries.set(entries),
error: () => {
this.entries.set([]);
this.loadError.set('Liste konnte nicht geladen werden.');
},
});
}
private toIso(date: string): string {
return new Date(`${date}T12:00:00`).toISOString();
}
private errorMessage(error: HttpErrorResponse, fallback: string): string {
const detail = typeof error.error?.message === 'string' ? error.error.message : '';
if (error.status === 403) return 'Keine Berechtigung für diese Änderung.';
if (error.status === 404) return 'Der Eintrag wurde nicht gefunden. Bitte lade die Liste neu.';
if (error.status === 422) return 'Bitte prüfe die Eingaben.';
return detail || fallback;
}
}

View File

@@ -0,0 +1,29 @@
export type RecurringTransactionInterval = 'monthly' | 'quarterly' | 'yearly';
export interface RecurringTransaction {
id: number;
description: string;
amount: number;
type: number;
interval: RecurringTransactionInterval;
nextRunDate: string;
active: boolean;
createdAt?: string;
}
export interface CreateRecurringTransaction {
teamId: number;
description: string;
amount: number;
type: number;
interval: RecurringTransactionInterval;
startDate: string;
}
export interface UpdateRecurringTransaction {
description: string;
amount: number;
type: number;
interval: RecurringTransactionInterval;
active: boolean;
}