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
This commit is contained in:
parent
8b054d27ea
commit
ee10b2e04d
6 changed files with 313 additions and 9 deletions
|
|
@ -13,7 +13,13 @@ from datetime import date
|
|||
import pytest
|
||||
|
||||
from ..models import ContractStatus, PayoutStatus
|
||||
from ..services import MAX_PERIOD_ATTEMPTS, PeriodOutcome, run_due_periods
|
||||
from ..services import (
|
||||
MAX_PERIOD_ATTEMPTS,
|
||||
LifecycleError,
|
||||
PeriodOutcome,
|
||||
pay_now,
|
||||
run_due_periods,
|
||||
)
|
||||
from .conftest import make_contract
|
||||
|
||||
|
||||
|
|
@ -210,3 +216,44 @@ def test_a_failure_under_the_cap_leaves_the_contract_active(payroll_stub):
|
|||
|
||||
assert payroll_stub.contract.status == ContractStatus.active
|
||||
assert payroll_stub.ledger[-1].attempt == MAX_PERIOD_ATTEMPTS - 1
|
||||
|
||||
|
||||
# --- off-cycle payout ------------------------------------------------------
|
||||
|
||||
|
||||
def test_pay_now_settles_the_next_period_before_its_payday(payroll_stub):
|
||||
payroll_stub.contract = make_contract(
|
||||
start_date="2026-12-01", created_at="2026-01-01"
|
||||
)
|
||||
|
||||
payout = asyncio.run(pay_now(payroll_stub.contract))
|
||||
|
||||
assert payout.status == PayoutStatus.paid
|
||||
assert payout.period_index == 0
|
||||
assert payroll_stub.contract.periods_done == 1
|
||||
|
||||
|
||||
def test_pay_now_ignores_the_back_dated_skip(payroll_stub):
|
||||
"""`_settle` skips back-dated periods to stop a new contract firing
|
||||
surprise back-pay. An operator explicitly asking to pay is not a
|
||||
surprise, so pay_now goes straight to the transfer."""
|
||||
payroll_stub.contract = make_contract(
|
||||
start_date="2026-01-15", created_at="2026-05-01", backfill=False
|
||||
)
|
||||
|
||||
payout = asyncio.run(pay_now(payroll_stub.contract))
|
||||
|
||||
assert payout.status == PayoutStatus.paid
|
||||
assert payroll_stub.attempted == [0]
|
||||
|
||||
|
||||
def test_pay_now_refuses_a_paused_contract(payroll_stub):
|
||||
payroll_stub.contract = make_contract(status=ContractStatus.paused)
|
||||
with pytest.raises(LifecycleError):
|
||||
asyncio.run(pay_now(payroll_stub.contract))
|
||||
|
||||
|
||||
def test_pay_now_refuses_an_exhausted_contract(payroll_stub):
|
||||
payroll_stub.contract = make_contract(total_periods=2, periods_done=2)
|
||||
with pytest.raises(LifecycleError):
|
||||
asyncio.run(pay_now(payroll_stub.contract))
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ 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
|
||||
|
|
@ -147,3 +149,36 @@ def test_upcoming_paydays_are_truncated_by_the_period_cap():
|
|||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue