diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.spec.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.spec.ts new file mode 100644 index 0000000..cf55f8a --- /dev/null +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.spec.ts @@ -0,0 +1,121 @@ +import { RecurringTransactionIntervalEnum } from '../recurring-transactions/recurring-transaction-interval.enum'; +import { CashboxExportScheduler } from './cashbox-export.scheduler'; + +describe('CashboxExportScheduler', () => { + const subscriptionRepository = { find: jest.fn(), save: jest.fn((v) => v) }; + const teamRepository = { findOne: jest.fn() }; + const mailService = { cashboxExport: jest.fn() }; + const logger = { info: jest.fn() }; + let scheduler: CashboxExportScheduler; + + beforeEach(() => { + jest.clearAllMocks(); + scheduler = new CashboxExportScheduler( + subscriptionRepository as any, + teamRepository as any, + mailService as any, + logger as any, + ); + }); + + it('does nothing when no subscription is due', async () => { + subscriptionRepository.find.mockResolvedValue([]); + + await scheduler.runDueSubscriptions(); + + expect(teamRepository.findOne).not.toHaveBeenCalled(); + expect(mailService.cashboxExport).not.toHaveBeenCalled(); + }); + + it('emails the elapsed monthly period and advances nextRunDate', async () => { + subscriptionRepository.find.mockResolvedValue([ + { + id: 1, + team: { id: 5 }, + recipients: ['vorstand@example.com'], + interval: RecurringTransactionIntervalEnum.monthly, + nextRunDate: '2026-09-01T00:00:00.000Z', + active: true, + }, + ]); + teamRepository.findOne.mockResolvedValue({ + id: 5, + name: 'Team A', + alias: 'team-a', + transactions: [ + { date: '2026-08-15T00:00:00.000Z', amount: 10, note: 'Sponsoring', type: { name: 'credit' } }, + ], + players: [], + }); + + await scheduler.runDueSubscriptions(); + + expect(teamRepository.findOne).toHaveBeenCalledWith({ + where: { id: 5 }, + relations: ['players', 'players.transactions', 'transactions'], + }); + expect(mailService.cashboxExport).toHaveBeenCalledTimes(1); + const [mailData, attachment, filename] = mailService.cashboxExport.mock.calls[0]; + expect(mailData).toEqual({ + to: 'vorstand@example.com', + data: { teamName: 'Team A', from: '2026-08-01', to: '2026-08-31' }, + }); + expect(Buffer.isBuffer(attachment)).toBe(true); + expect(filename).toBe('kassenbuch_team-a_2026-08-01_2026-08-31.pdf'); + expect(subscriptionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ nextRunDate: '2026-10-01T00:00:00.000Z' }), + ); + expect(logger.info).toHaveBeenCalledWith({ + event: 'cashbox_export_subscription_run', + details: 'teamId=5 recipients=1 from=2026-08-01 to=2026-08-31', + userId: -1, + }); + }); + + it.each([ + [RecurringTransactionIntervalEnum.monthly, '2026-09-01T00:00:00.000Z', '2026-08-01', '2026-08-31', '2026-10-01T00:00:00.000Z'], + [RecurringTransactionIntervalEnum.quarterly, '2026-09-01T00:00:00.000Z', '2026-06-01', '2026-08-31', '2026-12-01T00:00:00.000Z'], + [RecurringTransactionIntervalEnum.yearly, '2027-01-01T00:00:00.000Z', '2026-01-01', '2026-12-31', '2028-01-01T00:00:00.000Z'], + ])( + 'computes the elapsed period and next run date for %s', + async (interval, nextRunDate, expectedFrom, expectedTo, expectedNext) => { + subscriptionRepository.find.mockResolvedValue([ + { id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval, nextRunDate, active: true }, + ]); + teamRepository.findOne.mockResolvedValue({ + id: 5, + name: 'Team A', + alias: 'team-a', + transactions: [], + players: [], + }); + + await scheduler.runDueSubscriptions(); + + const [mailData] = mailService.cashboxExport.mock.calls[0]; + expect(mailData.data.from).toBe(expectedFrom); + expect(mailData.data.to).toBe(expectedTo); + expect(subscriptionRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ nextRunDate: expectedNext }), + ); + }, + ); + + it('processes multiple due subscriptions independently', async () => { + subscriptionRepository.find.mockResolvedValue([ + { id: 1, team: { id: 5 }, recipients: ['a@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true }, + { id: 2, team: { id: 6 }, recipients: ['b@example.com'], interval: RecurringTransactionIntervalEnum.monthly, nextRunDate: '2026-09-01T00:00:00.000Z', active: true }, + ]); + teamRepository.findOne.mockResolvedValue({ + id: 5, + name: 'Team A', + alias: 'team-a', + transactions: [], + players: [], + }); + + await scheduler.runDueSubscriptions(); + + expect(mailService.cashboxExport).toHaveBeenCalledTimes(2); + }); +}); diff --git a/myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.ts b/myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.ts new file mode 100644 index 0000000..f4bbe6e --- /dev/null +++ b/myteamwallet_backend/src/cashbox-export/cashbox-export.scheduler.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LoggingService } from 'src/database/logging/logging.service'; +import { Team } from 'src/teams/entities/team.entity'; +import { MailService } from 'src/mail/mail.service'; +import { LessThanOrEqual, Repository } from 'typeorm'; +import { RecurringTransactionIntervalEnum } from 'src/recurring-transactions/recurring-transaction-interval.enum'; +import { buildPdf, buildRows } from './cashbox-export.utils'; +import { CashboxExportSubscription } from './entities/cashbox-export-subscription.entity'; + +const INTERVAL_MONTHS: Record = { + [RecurringTransactionIntervalEnum.monthly]: 1, + [RecurringTransactionIntervalEnum.quarterly]: 3, + [RecurringTransactionIntervalEnum.yearly]: 12, +}; + +@Injectable() +export class CashboxExportScheduler { + constructor( + @InjectRepository(CashboxExportSubscription) + private readonly subscriptionRepository: Repository, + @InjectRepository(Team) + private readonly teamRepository: Repository, + private readonly mailService: MailService, + private readonly logger: LoggingService, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_4AM) + async runDueSubscriptions(): Promise { + const today = new Date().toISOString(); + const due = await this.subscriptionRepository.find({ + where: { active: true, nextRunDate: LessThanOrEqual(today) }, + relations: ['team'], + }); + + for (const subscription of due) { + await this.runOne(subscription); + } + } + + private async runOne(subscription: CashboxExportSubscription): Promise { + const team = await this.teamRepository.findOne({ + where: { id: subscription.team.id }, + relations: ['players', 'players.transactions', 'transactions'], + }); + if (!team) return; + + const { from, to } = this.periodBounds(subscription.nextRunDate, subscription.interval); + const rows = buildRows(team, from, to); + const pdf = await buildPdf(team, rows, from, to); + const filename = `kassenbuch_${team.alias}_${from}_${to}.pdf`; + + await this.mailService.cashboxExport( + { to: subscription.recipients.join(', '), data: { teamName: team.name, from, to } }, + pdf, + filename, + ); + + subscription.nextRunDate = this.advance(subscription.nextRunDate, subscription.interval); + await this.subscriptionRepository.save(subscription); + + await this.logger.info({ + event: 'cashbox_export_subscription_run', + details: `teamId=${team.id} recipients=${subscription.recipients.length} from=${from} to=${to}`, + userId: -1, + }); + } + + private periodBounds( + nextRunDate: string, + interval: RecurringTransactionIntervalEnum, + ): { from: string; to: string } { + const end = new Date(nextRunDate); + end.setUTCDate(end.getUTCDate() - 1); + const start = new Date(nextRunDate); + start.setUTCMonth(start.getUTCMonth() - INTERVAL_MONTHS[interval]); + return { from: start.toISOString().slice(0, 10), to: end.toISOString().slice(0, 10) }; + } + + private advance(nextRunDate: string, interval: RecurringTransactionIntervalEnum): string { + const date = new Date(nextRunDate); + date.setUTCMonth(date.getUTCMonth() + INTERVAL_MONTHS[interval]); + return date.toISOString(); + } +} diff --git a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts index cfcf2c7..49a3503 100644 --- a/myteamwallet_backend/src/database/logging/model/logging-event.type.ts +++ b/myteamwallet_backend/src/database/logging/model/logging-event.type.ts @@ -30,6 +30,7 @@ export type LOGEVENT = | 'recurring_transaction_create' | 'recurring_transaction_update' | 'recurring_transaction_delete' - | 'recurring_transaction_run'; + | 'recurring_transaction_run' + | 'cashbox_export_subscription_run'; export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';