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:
@@ -0,0 +1,151 @@
|
||||
import { TransactionTypeEnum } from '../transactions/transaction-type.enum';
|
||||
import { RecurringTransactionIntervalEnum } from './recurring-transaction-interval.enum';
|
||||
import { RecurringTransactionsScheduler } from './recurring-transactions.scheduler';
|
||||
|
||||
describe('RecurringTransactionsScheduler', () => {
|
||||
const dueRepository = { find: jest.fn() };
|
||||
const playerRepository = { find: jest.fn() };
|
||||
const definitionWriteRepository = { save: jest.fn((value) => value) };
|
||||
const transactionWriteRepository = {
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => ({ id: 1, ...value })),
|
||||
};
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity) => {
|
||||
if (entity.name === 'Player') return playerRepository;
|
||||
if (entity.name === 'Transaction') return transactionWriteRepository;
|
||||
return definitionWriteRepository;
|
||||
}),
|
||||
};
|
||||
const dataSource = { transaction: jest.fn((work) => work(manager)) };
|
||||
const logger = { info: jest.fn() };
|
||||
let scheduler: RecurringTransactionsScheduler;
|
||||
|
||||
const player = (id: number, active: boolean) => ({ id, active, team: { id: 5 } });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
function buildScheduler() {
|
||||
return new RecurringTransactionsScheduler(
|
||||
dueRepository as any,
|
||||
dataSource as any,
|
||||
logger as any,
|
||||
);
|
||||
}
|
||||
|
||||
it('does nothing when no recurring transaction is due', async () => {
|
||||
dueRepository.find.mockResolvedValue([]);
|
||||
scheduler = buildScheduler();
|
||||
|
||||
await scheduler.runDueRecurringTransactions();
|
||||
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
expect(logger.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('books a transaction for every active player and skips inactive ones', async () => {
|
||||
dueRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
team: { id: 5 },
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: { id: TransactionTypeEnum.fee },
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
nextRunDate: '2026-08-01T00:00:00.000Z',
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
// The DB query filters by active:true itself (asserted below) — an
|
||||
// inactive player would never be returned, so the mock reflects that.
|
||||
playerRepository.find.mockResolvedValue([player(1, true), player(3, true)]);
|
||||
scheduler = buildScheduler();
|
||||
|
||||
await scheduler.runDueRecurringTransactions();
|
||||
|
||||
expect(playerRepository.find).toHaveBeenCalledWith({
|
||||
where: { team: { id: 5 }, active: true },
|
||||
});
|
||||
expect(transactionWriteRepository.save).toHaveBeenCalledTimes(2);
|
||||
expect(transactionWriteRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
amount: 10,
|
||||
note: 'Monatsbeitrag',
|
||||
player: player(1, true),
|
||||
type: { id: TransactionTypeEnum.fee },
|
||||
}),
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{
|
||||
event: 'recurring_transaction_run',
|
||||
details:
|
||||
'recurringTransactionId=3 teamId=5 gebuchteSpieler=2',
|
||||
userId: -1,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[RecurringTransactionIntervalEnum.monthly, '2026-08-01T00:00:00.000Z', '2026-09-01T00:00:00.000Z'],
|
||||
[RecurringTransactionIntervalEnum.quarterly, '2026-08-01T00:00:00.000Z', '2026-11-01T00:00:00.000Z'],
|
||||
[RecurringTransactionIntervalEnum.yearly, '2026-08-01T00:00:00.000Z', '2027-08-01T00:00:00.000Z'],
|
||||
])(
|
||||
'advances nextRunDate by %s from %s to %s',
|
||||
async (interval, nextRunDate, expected) => {
|
||||
dueRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
team: { id: 5 },
|
||||
description: 'Monatsbeitrag',
|
||||
amount: 10,
|
||||
type: { id: TransactionTypeEnum.fee },
|
||||
interval,
|
||||
nextRunDate,
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
playerRepository.find.mockResolvedValue([player(1, true)]);
|
||||
scheduler = buildScheduler();
|
||||
|
||||
await scheduler.runDueRecurringTransactions();
|
||||
|
||||
expect(definitionWriteRepository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nextRunDate: expected }),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('processes each due definition inside its own transaction', async () => {
|
||||
dueRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 3,
|
||||
team: { id: 5 },
|
||||
description: 'A',
|
||||
amount: 10,
|
||||
type: { id: TransactionTypeEnum.fee },
|
||||
interval: RecurringTransactionIntervalEnum.monthly,
|
||||
nextRunDate: '2026-08-01T00:00:00.000Z',
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
team: { id: 6 },
|
||||
description: 'B',
|
||||
amount: 20,
|
||||
type: { id: TransactionTypeEnum.levy },
|
||||
interval: RecurringTransactionIntervalEnum.yearly,
|
||||
nextRunDate: '2026-08-01T00:00:00.000Z',
|
||||
active: true,
|
||||
},
|
||||
]);
|
||||
playerRepository.find.mockResolvedValue([player(1, true)]);
|
||||
scheduler = buildScheduler();
|
||||
|
||||
await scheduler.runDueRecurringTransactions();
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user