diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts new file mode 100644 index 0000000..cff7ae9 --- /dev/null +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.spec.ts @@ -0,0 +1,119 @@ +import { BadRequestException } from '@nestjs/common'; +import { TeamRolesEnum } from '../team-roles/team-roles.enum'; +import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum'; +import { CashboxExportSubscriptionService } from './cashbox-export-subscription.service'; + +describe('CashboxExportSubscriptionService', () => { + const repository = { findOne: jest.fn(), create: jest.fn((v) => v), save: jest.fn(async (v) => v) }; + const access = { assertAtLeast: jest.fn() }; + let service: CashboxExportSubscriptionService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new CashboxExportSubscriptionService(repository as any, access as any); + }); + + it('returns a paused default when no subscription exists yet', async () => { + repository.findOne.mockResolvedValue(null); + + await expect(service.getSubscription(5, 42)).resolves.toEqual({ + recipients: [], + interval: RecurringTransactionIntervalEnum.monthly, + active: false, + nextRunDate: null, + }); + expect(access.assertAtLeast).toHaveBeenCalledWith( + 42, + 5, + 'transaction_create_min_role', + TeamRolesEnum.scnd_treasurer, + ); + }); + + it('returns the existing subscription', async () => { + repository.findOne.mockResolvedValue({ + recipients: ['vorstand@example.com'], + interval: RecurringTransactionIntervalEnum.yearly, + active: true, + nextRunDate: '2027-01-01T00:00:00.000Z', + }); + + await expect(service.getSubscription(5, 42)).resolves.toEqual({ + recipients: ['vorstand@example.com'], + interval: RecurringTransactionIntervalEnum.yearly, + active: true, + nextRunDate: '2027-01-01T00:00:00.000Z', + }); + }); + + it('rejects activating with an empty recipient list', async () => { + repository.findOne.mockResolvedValue(null); + + await expect( + service.upsertSubscription(5, 42, { + recipients: [], + interval: RecurringTransactionIntervalEnum.monthly, + active: true, + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it('creates a new subscription and computes the next period boundary on first activation', async () => { + repository.findOne.mockResolvedValue(null); + jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z')); + + const result = await service.upsertSubscription(5, 42, { + recipients: ['vorstand@example.com'], + interval: RecurringTransactionIntervalEnum.monthly, + active: true, + }); + + expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z'); + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ + recipients: ['vorstand@example.com'], + interval: RecurringTransactionIntervalEnum.monthly, + active: true, + nextRunDate: '2026-09-01T00:00:00.000Z', + }), + ); + jest.useRealTimers(); + }); + + it('keeps the existing nextRunDate when editing recipients without changing interval or activation state', async () => { + repository.findOne.mockResolvedValue({ + recipients: ['old@example.com'], + interval: RecurringTransactionIntervalEnum.monthly, + active: true, + nextRunDate: '2026-09-01T00:00:00.000Z', + }); + + const result = await service.upsertSubscription(5, 42, { + recipients: ['new@example.com'], + interval: RecurringTransactionIntervalEnum.monthly, + active: true, + }); + + expect(result.nextRunDate).toBe('2026-09-01T00:00:00.000Z'); + }); + + it('recomputes nextRunDate when the interval changes', async () => { + repository.findOne.mockResolvedValue({ + recipients: ['a@example.com'], + interval: RecurringTransactionIntervalEnum.monthly, + active: true, + nextRunDate: '2026-09-01T00:00:00.000Z', + }); + jest.useFakeTimers().setSystemTime(new Date('2026-08-15T10:00:00.000Z')); + + const result = await service.upsertSubscription(5, 42, { + recipients: ['a@example.com'], + interval: RecurringTransactionIntervalEnum.yearly, + active: true, + }); + + expect(result.nextRunDate).toBe('2027-08-01T00:00:00.000Z'); + jest.useRealTimers(); + }); +}); diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts new file mode 100644 index 0000000..875a4a1 --- /dev/null +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export-subscription.service.ts @@ -0,0 +1,99 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { TeamRolesEnum } from 'src/team-roles/team-roles.enum'; +import { TeamAccessService } from 'src/teams/team-access.service'; +import { Repository } from 'typeorm'; +import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum'; +import { CashboxExportSubscriptionResponseDTO } from './dto/cashbox-export-subscription-response.dto'; +import { UpsertCashboxExportSubscriptionDTO } from './dto/upsert-cashbox-export-subscription.dto'; +import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity'; + +const INTERVAL_MONTHS: Record = { + [RecurringTransactionIntervalEnum.monthly]: 1, + [RecurringTransactionIntervalEnum.quarterly]: 3, + [RecurringTransactionIntervalEnum.yearly]: 12, +}; + +@Injectable() +export class CashboxExportSubscriptionService { + constructor( + @InjectRepository(CashboxExportSubscription) + private readonly repository: Repository, + private readonly access: TeamAccessService, + ) {} + + async getSubscription( + teamId: number, + userId: number, + ): Promise { + await this.assertAccess(userId, teamId); + const existing = await this.repository.findOne({ where: { team: { id: teamId } } }); + if (!existing) { + return { + recipients: [], + interval: RecurringTransactionIntervalEnum.monthly, + active: false, + nextRunDate: null, + }; + } + return this.toResponse(existing); + } + + async upsertSubscription( + teamId: number, + userId: number, + dto: UpsertCashboxExportSubscriptionDTO, + ): Promise { + await this.assertAccess(userId, teamId); + if (dto.active && dto.recipients.length === 0) { + throw new BadRequestException( + 'Ein aktivierter automatischer Versand benötigt mindestens eine Empfängeradresse.', + ); + } + + const existing = await this.repository.findOne({ where: { team: { id: teamId } } }); + const needsNewSchedule = + !existing || (dto.active && (!existing.active || existing.interval !== dto.interval)); + + const entity = + existing ?? + this.repository.create({ team: { id: teamId } as any, nextRunDate: null, active: false }); + + entity.recipients = dto.recipients; + entity.interval = dto.interval; + entity.active = dto.active; + if (needsNewSchedule) { + entity.nextRunDate = this.nextBoundary(dto.interval); + } + + const saved = await this.repository.save(entity); + return this.toResponse(saved); + } + + private async assertAccess(userId: number, teamId: number): Promise { + await this.access.assertAtLeast( + userId, + teamId, + 'transaction_create_min_role', + TeamRolesEnum.scnd_treasurer, + ); + } + + private nextBoundary(interval: RecurringTransactionIntervalEnum): string { + const now = new Date(); + const date = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]); + return date.toISOString(); + } + + private toResponse( + entity: CashboxExportSubscription, + ): CashboxExportSubscriptionResponseDTO { + return { + recipients: entity.recipients, + interval: entity.interval, + active: entity.active, + nextRunDate: entity.nextRunDate, + }; + } +} diff --git a/myteamwallet_backend/src/cashbox-export/dto/cashbox-export-subscription-response.dto.ts b/myteamwallet_backend/src/cashbox-export/dto/cashbox-export-subscription-response.dto.ts new file mode 100644 index 0000000..978eb0f --- /dev/null +++ b/myteamwallet_backend/src/cashbox-export/dto/cashbox-export-subscription-response.dto.ts @@ -0,0 +1,8 @@ +import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum'; + +export class CashboxExportSubscriptionResponseDTO { + recipients: string[]; + interval: RecurringTransactionIntervalEnum; + active: boolean; + nextRunDate: string | null; +} diff --git a/myteamwallet_backend/src/cashbox-export/dto/upsert-cashbox-export-subscription.dto.ts b/myteamwallet_backend/src/cashbox-export/dto/upsert-cashbox-export-subscription.dto.ts new file mode 100644 index 0000000..4c8d9d2 --- /dev/null +++ b/myteamwallet_backend/src/cashbox-export/dto/upsert-cashbox-export-subscription.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsBoolean, IsEmail, IsIn } from 'class-validator'; +import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum'; + +export class UpsertCashboxExportSubscriptionDTO { + @ApiProperty({ example: ['vorstand@example.com'] }) + @IsArray() + @IsEmail({}, { each: true }) + recipients: string[]; + + @ApiProperty({ enum: RecurringTransactionIntervalEnum }) + @IsIn(Object.values(RecurringTransactionIntervalEnum)) + interval: RecurringTransactionIntervalEnum; + + @ApiProperty({ example: true }) + @IsBoolean() + active: boolean; +} diff --git a/myteamwallet_backend/src/cashbox-export/entities/cashbox-export-subscription.entity.ts b/myteamwallet_backend/src/cashbox-export/entities/cashbox-export-subscription.entity.ts new file mode 100644 index 0000000..eb71d6c --- /dev/null +++ b/myteamwallet_backend/src/cashbox-export/entities/cashbox-export-subscription.entity.ts @@ -0,0 +1,26 @@ +import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { EntityHelper } from 'src/utils/entity-helper'; +import { Team } from 'src/teams/entities/team.entity'; +import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum'; + +@Entity() +export class CashboxExportSubscription extends EntityHelper { + @PrimaryGeneratedColumn() + id: number; + + @OneToOne(() => Team, { eager: false }) + @JoinColumn() + team: Team; + + @Column({ type: 'simple-array', default: '' }) + recipients: string[]; + + @Column() + interval: RecurringTransactionIntervalEnum; + + @Column({ default: false }) + active: boolean; + + @Column({ nullable: true }) + nextRunDate: string | null; +}