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
96
services.py
96
services.py
|
|
@ -101,18 +101,46 @@ def next_payday(contract: Contract) -> date | None:
|
|||
)
|
||||
|
||||
|
||||
def upcoming_paydays(contract: Contract, count: int) -> list[date]:
|
||||
"""The next `count` paydays, truncated by any period cap."""
|
||||
start = parse_start_date(contract)
|
||||
last = contract.total_periods if contract.total_periods is not None else None
|
||||
indices = range(contract.periods_done, contract.periods_done + count)
|
||||
def paydays_from(
|
||||
start: date,
|
||||
frequency: Frequency,
|
||||
first_index: int,
|
||||
count: int,
|
||||
total_periods: int | None = None,
|
||||
) -> list[date]:
|
||||
"""`count` paydays starting at period `first_index`, truncated by any cap.
|
||||
|
||||
Takes loose parameters rather than a Contract so the operator can preview
|
||||
a schedule *before* the contract exists — which is the point at which a
|
||||
mistyped start date or the wrong frequency is cheap to fix.
|
||||
"""
|
||||
return [
|
||||
occurrence_on(start, contract.frequency, i)
|
||||
for i in indices
|
||||
if last is None or i < last
|
||||
occurrence_on(start, frequency, i)
|
||||
for i in range(first_index, first_index + count)
|
||||
if total_periods is None or i < total_periods
|
||||
]
|
||||
|
||||
|
||||
def upcoming_paydays(contract: Contract, count: int) -> list[date]:
|
||||
"""The next `count` paydays for a live contract, from its position."""
|
||||
return paydays_from(
|
||||
parse_start_date(contract),
|
||||
contract.frequency,
|
||||
contract.periods_done,
|
||||
count,
|
||||
contract.total_periods,
|
||||
)
|
||||
|
||||
|
||||
def final_payday(
|
||||
start: date, frequency: Frequency, total_periods: int | None
|
||||
) -> date | None:
|
||||
"""When an open-ended contract would end — None, by definition."""
|
||||
if total_periods is None:
|
||||
return None
|
||||
return occurrence_on(start, frequency, total_periods - 1)
|
||||
|
||||
|
||||
def due_period_indices(contract: Contract, today: date) -> list[int]:
|
||||
"""Every period index that is payable as of `today`, in order.
|
||||
|
||||
|
|
@ -516,3 +544,55 @@ def cancel(contract: Contract) -> Contract:
|
|||
raise LifecycleError(f"Contract is already {contract.status.value}.")
|
||||
contract.status = ContractStatus.cancelled
|
||||
return contract
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Off-cycle payout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def pay_now(contract: Contract) -> Payout:
|
||||
"""Settle the contract's next period immediately, whatever the calendar
|
||||
says.
|
||||
|
||||
The operator's manual override: "run it now" when waiting five minutes
|
||||
for the tick is not acceptable, and "pay it early" when a payday needs to
|
||||
land before it is due. Both are the same operation — the next period
|
||||
settles and is consumed — so there is one endpoint rather than two that
|
||||
differ only in whether today happens to be the payday.
|
||||
|
||||
It calls `pay_period` directly rather than going through `_settle`,
|
||||
because `_settle`'s back-dated skip exists to stop a *new* contract
|
||||
firing surprise back-pay. An operator explicitly asking to pay a period
|
||||
is not a surprise.
|
||||
"""
|
||||
async with _lock_for(contract.id):
|
||||
fresh = await crud.get_contract(contract.id)
|
||||
if not fresh:
|
||||
raise LifecycleError("Contract not found.")
|
||||
contract = fresh
|
||||
|
||||
if contract.status != ContractStatus.active:
|
||||
raise LifecycleError(
|
||||
f"Only an active contract can be paid (is {contract.status.value})."
|
||||
)
|
||||
remaining = contract.periods_remaining
|
||||
if remaining is not None and remaining <= 0:
|
||||
raise LifecycleError("Contract has no periods left to pay.")
|
||||
|
||||
index = contract.periods_done
|
||||
payday = occurrence_on(parse_start_date(contract), contract.frequency, index)
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
outcome = await pay_period(contract, index, payday)
|
||||
if payday > today and outcome.status == PayoutStatus.paid:
|
||||
outcome.detail = f"paid early on {today.isoformat()} (due {payday})"
|
||||
|
||||
payout = await _record(contract, outcome)
|
||||
if outcome.consumed:
|
||||
contract.periods_done = index + 1
|
||||
_maybe_complete(contract)
|
||||
else:
|
||||
_maybe_pause_after_repeated_failure(contract, payout)
|
||||
await crud.update_contract(contract)
|
||||
return payout
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue