feat: add CashboxExportSubscription entity and service
This commit is contained in:
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, number> = {
|
||||||
|
[RecurringTransactionIntervalEnum.monthly]: 1,
|
||||||
|
[RecurringTransactionIntervalEnum.quarterly]: 3,
|
||||||
|
[RecurringTransactionIntervalEnum.yearly]: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CashboxExportSubscriptionService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CashboxExportSubscription)
|
||||||
|
private readonly repository: Repository<CashboxExportSubscription>,
|
||||||
|
private readonly access: TeamAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getSubscription(
|
||||||
|
teamId: number,
|
||||||
|
userId: number,
|
||||||
|
): Promise<CashboxExportSubscriptionResponseDTO> {
|
||||||
|
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<CashboxExportSubscriptionResponseDTO> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user