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>
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
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);
|
|
});
|
|
});
|