feat: add CashboxExportScheduler for recurring PDF mailing
- Implement CashboxExportScheduler with @Cron(EVERY_DAY_AT_4AM) - Query due subscriptions (active=true, nextRunDate <= today) - For each subscription: fetch team, build PDF, send email, advance nextRunDate - Support monthly/quarterly/yearly intervals via INTERVAL_MONTHS map - Add cashbox_export_subscription_run to LOGEVENT type for logging - All 6 tests passing: empty state, monthly/quarterly/yearly periods, multiple subscriptions Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, number> = {
|
||||||
|
[RecurringTransactionIntervalEnum.monthly]: 1,
|
||||||
|
[RecurringTransactionIntervalEnum.quarterly]: 3,
|
||||||
|
[RecurringTransactionIntervalEnum.yearly]: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CashboxExportScheduler {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CashboxExportSubscription)
|
||||||
|
private readonly subscriptionRepository: Repository<CashboxExportSubscription>,
|
||||||
|
@InjectRepository(Team)
|
||||||
|
private readonly teamRepository: Repository<Team>,
|
||||||
|
private readonly mailService: MailService,
|
||||||
|
private readonly logger: LoggingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||||
|
async runDueSubscriptions(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export type LOGEVENT =
|
|||||||
| 'recurring_transaction_create'
|
| 'recurring_transaction_create'
|
||||||
| 'recurring_transaction_update'
|
| 'recurring_transaction_update'
|
||||||
| 'recurring_transaction_delete'
|
| 'recurring_transaction_delete'
|
||||||
| 'recurring_transaction_run';
|
| 'recurring_transaction_run'
|
||||||
|
| 'cashbox_export_subscription_run';
|
||||||
|
|
||||||
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
export type LOGLEVEL = 'FATAL' | 'ERROR' | 'WARN' | 'INFO' | 'DEBUG' | 'TRACE';
|
||||||
|
|||||||
Reference in New Issue
Block a user