payroll/tests/test_schedule.py
Padreug ee10b2e04d feat: schedule preview and off-cycle payouts
Two things an operator needs that the scheduler alone does not give them.

Preview draws a contract's calendar from loose terms — start date,
frequency, period count — with no employee or wallet required, because the
schedule is what an operator wants to sanity-check first and a mistyped
start date is cheapest to fix before anything is saved. The same shape is
available for a live contract, from its current position. Both return
`ends_on`: "12 monthly payments from 15 Jan" is far easier to verify
against "ends 15 Dec" than against a list of twelve dates.

pay-now settles the next period immediately and consumes it. Deliberately
one endpoint rather than two: "run it now, don't wait for the tick" and
"pay it early" are the same operation and differ only in whether today
happens to be the payday. It bypasses the back-dated skip — that guard
exists to stop a new contract firing surprise back-pay, and an operator
explicitly asking to pay is not a surprise — and it takes the same
per-contract lock as the scheduler, which is what that lock was added for.
The ledger row is returned even on failure, since a manual payout that did
not land is exactly when you want the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 13:51:20 +02:00

184 lines
6.1 KiB
Python

"""Schedule math.
These are the cases that make month-anchored payroll wrong in practice:
month-end clamping, clamping that must not become permanent, leap days, and
a schedule that must not drift no matter how late the scheduler runs.
"""
from datetime import date
import pytest
from ..models import ContractStatus, Frequency
from ..services import (
MAX_CATCH_UP_PERIODS,
add_months,
due_period_indices,
final_payday,
next_payday,
occurrence_on,
paydays_from,
upcoming_paydays,
)
from .conftest import make_contract
# --- month arithmetic ------------------------------------------------------
def test_add_months_clamps_into_a_short_month():
assert add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)
def test_clamping_is_not_permanent():
"""The whole reason paydays are computed from the anchor and not from the
previous payday: 31 Jan must pay 28 Feb and then *31* Mar, not 28 Mar."""
anchor = date(2026, 1, 31)
assert add_months(anchor, 1) == date(2026, 2, 28)
assert add_months(anchor, 2) == date(2026, 3, 31)
assert add_months(anchor, 3) == date(2026, 4, 30)
assert add_months(anchor, 4) == date(2026, 5, 31)
def test_add_months_crosses_year_boundaries():
assert add_months(date(2026, 11, 30), 3) == date(2027, 2, 28)
assert add_months(date(2026, 3, 15), -4) == date(2025, 11, 15)
def test_leap_day_anchor_clamps_in_common_years():
leap = date(2028, 2, 29)
assert add_months(leap, 12) == date(2029, 2, 28)
assert add_months(leap, 48) == date(2032, 2, 29)
# --- occurrences -----------------------------------------------------------
@pytest.mark.parametrize(
("frequency", "index", "expected"),
[
(Frequency.daily, 0, date(2026, 1, 15)),
(Frequency.daily, 20, date(2026, 2, 4)),
(Frequency.weekly, 3, date(2026, 2, 5)),
(Frequency.biweekly, 2, date(2026, 2, 12)),
(Frequency.monthly, 2, date(2026, 3, 15)),
(Frequency.quarterly, 2, date(2026, 7, 15)),
(Frequency.yearly, 2, date(2028, 1, 15)),
],
)
def test_occurrence_on(frequency, index, expected):
assert occurrence_on(date(2026, 1, 15), frequency, index) == expected
def test_period_zero_is_the_start_date_for_every_frequency():
start = date(2026, 6, 30)
for frequency in Frequency:
assert occurrence_on(start, frequency, 0) == start
def test_schedule_does_not_drift_over_a_year():
"""A monthly contract must land on the same day twelve months later —
incrementally advancing a stored date is what would break this."""
start = date(2026, 1, 15)
assert occurrence_on(start, Frequency.monthly, 12) == date(2027, 1, 15)
# --- due periods -----------------------------------------------------------
def test_nothing_is_due_before_the_start_date():
contract = make_contract(start_date="2026-03-01")
assert due_period_indices(contract, date(2026, 2, 28)) == []
def test_the_start_date_itself_is_due():
contract = make_contract(start_date="2026-03-01")
assert due_period_indices(contract, date(2026, 3, 1)) == [0]
def test_a_backlog_returns_every_missed_period_in_order():
contract = make_contract(start_date="2026-01-15", frequency=Frequency.monthly)
assert due_period_indices(contract, date(2026, 4, 20)) == [0, 1, 2, 3]
def test_periods_already_done_are_not_due_again():
contract = make_contract(start_date="2026-01-15", periods_done=3)
assert due_period_indices(contract, date(2026, 4, 20)) == [3]
def test_due_periods_stop_at_the_period_cap():
contract = make_contract(start_date="2026-01-15", total_periods=2)
assert due_period_indices(contract, date(2026, 12, 31)) == [0, 1]
def test_catch_up_is_capped_for_open_ended_contracts():
"""A daily contract with a badly back-dated start must not try to fire
hundreds of transfers in one tick."""
contract = make_contract(
start_date="2020-01-01", frequency=Frequency.daily, total_periods=None
)
assert len(due_period_indices(contract, date(2026, 1, 1))) == MAX_CATCH_UP_PERIODS
@pytest.mark.parametrize(
"status",
[ContractStatus.paused, ContractStatus.cancelled, ContractStatus.completed],
)
def test_only_active_contracts_are_due(status):
contract = make_contract(start_date="2026-01-15", status=status)
assert due_period_indices(contract, date(2026, 6, 1)) == []
# --- previews --------------------------------------------------------------
def test_next_payday_follows_the_position():
contract = make_contract(start_date="2026-01-31", periods_done=1)
assert next_payday(contract) == date(2026, 2, 28)
def test_next_payday_is_none_once_exhausted():
contract = make_contract(total_periods=3, periods_done=3)
assert next_payday(contract) is None
def test_upcoming_paydays_are_truncated_by_the_period_cap():
contract = make_contract(start_date="2026-01-15", total_periods=3, periods_done=1)
assert upcoming_paydays(contract, 10) == [date(2026, 2, 15), date(2026, 3, 15)]
def test_upcoming_paydays_are_unbounded_for_open_ended_contracts():
contract = make_contract(start_date="2026-01-15", total_periods=None)
assert len(upcoming_paydays(contract, 24)) == 24
# --- preview from loose terms ----------------------------------------------
def test_paydays_from_draws_a_window_of_the_schedule():
assert paydays_from(date(2026, 1, 31), Frequency.monthly, 0, 4) == [
date(2026, 1, 31),
date(2026, 2, 28),
date(2026, 3, 31),
date(2026, 4, 30),
]
def test_paydays_from_starts_at_the_given_index():
assert paydays_from(date(2026, 1, 15), Frequency.weekly, 2, 2) == [
date(2026, 1, 29),
date(2026, 2, 5),
]
def test_paydays_from_stops_at_the_period_cap():
drawn = paydays_from(date(2026, 1, 15), Frequency.monthly, 0, 10, total_periods=3)
assert len(drawn) == 3
def test_final_payday_is_the_last_period_not_one_past_it():
"""12 monthly payments from 15 Jan end on 15 Dec, not 15 Jan next year."""
assert final_payday(date(2026, 1, 15), Frequency.monthly, 12) == date(2026, 12, 15)
def test_open_ended_contracts_have_no_final_payday():
assert final_payday(date(2026, 1, 15), Frequency.monthly, None) is None