payroll/tests/test_pricing.py
Padreug 1584eb337f fix: honour a stated rate whatever the calendar says
Pay-now on a period due next week offered "Rate on the payday" and then
found a rate anyway, with only a passive hint to explain why. Two separate
problems behind that.

The real one: `resolve_price` tested `payday >= today` before it tested the
mode, so `manual` was silently ignored for any period not already
back-dated. "Pay at the rate we agreed" quietly did not, and a contract
pegged to a fixed rate honoured it only on late periods. A stated rate is
an instruction rather than a lookup, so it now wins outright and applies to
every period. `payday` and `current` keep their old order — for a payday
today or ahead there is no history to consult, so `payday` collapses into
`current`, which is the only defensible answer for a date that has not
happened.

The cosmetic one: the dialog's hint said "ignored unless the payday is
already in the past", sitting beside a control that plainly appeared to do
something — and was about to become wrong anyway, since manual now always
applies. It is replaced by a line that names the actual period: either
"Payday 2026-09-05 has not passed, so there is no historical rate to look
up — this will be priced at the current rate", or "Will convert at what BTC
was worth on <date>". The dialog header now shows which payday is being
settled, which was not visible at all before.

The contract dialogs lose the same stale hint; their label becomes
"Pricing" rather than "Price a late payday at", which no longer describes
manual mode.

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

204 lines
6.7 KiB
Python

"""Pricing a period.
`resolve_price` is where a back-dated payout stops being worth today's
money. These pin down which mode wins when, and that an unavailable rate
refuses rather than substitutes.
"""
import asyncio
from datetime import date
import pytest
from .. import services
from ..models import PricingMode
from ..services import PricingError, resolve_price
from .conftest import make_contract
TODAY = date(2026, 8, 31)
PAST = date(2026, 5, 15)
PAST_RATE = 69743.26
LIVE_RATE = 67_000.0
@pytest.fixture
def historical(monkeypatch):
"""Stub both rate sources and record what the historical one was asked."""
asked = []
async def fake_rate(day, currency):
asked.append((day, currency))
return PAST_RATE if currency == "EUR" else None
async def fake_live(amount, _currency):
return round(amount / LIVE_RATE * 1e8)
monkeypatch.setattr(services, "historical_btc_rate", fake_rate)
monkeypatch.setattr(services, "fiat_amount_as_satoshis", fake_live)
return asked
def eur(**kw):
return make_contract(currency="EUR", amount=800, **kw)
def price(contract, payday=PAST, today=TODAY, **kw):
return asyncio.run(resolve_price(contract, payday, today, **kw))
# --- when history is irrelevant --------------------------------------------
def test_a_payday_today_uses_the_live_rate(historical):
"""No mode needs history for a payday that has not passed."""
p = price(eur(), payday=TODAY)
assert (p.currency, p.source) == ("sat", "current")
assert p.amount == round(800 / LIVE_RATE * 1e8)
assert historical == []
def test_the_live_rate_is_known_before_the_invoice(historical):
"""Payroll converts rather than handing create_invoice a fiat amount, so
the rate exists in time to go in the memo."""
p = price(eur(), payday=TODAY)
assert p.rate == pytest.approx(LIVE_RATE, rel=1e-6)
def test_a_future_payday_uses_the_live_rate(historical):
p = price(eur(), payday=date(2026, 12, 1))
assert p.source == "current"
assert historical == []
def test_a_sat_contract_never_prices_at_all(historical):
p = price(make_contract(currency="sat", amount=1000))
assert (p.amount, p.currency, p.rate, p.source) == (1000, "sat", None, "")
assert historical == []
# --- the three modes -------------------------------------------------------
def test_payday_mode_converts_at_the_paydays_rate(historical):
p = price(eur(pricing_mode=PricingMode.payday))
assert p.source == "payday"
assert p.currency == "sat"
assert p.rate == PAST_RATE
assert p.amount == round(800 / PAST_RATE * 1e8)
assert historical == [(PAST, "EUR")]
def test_current_mode_ignores_the_past_payday(historical):
"""The employee is owed EUR 800 and gets EUR 800 of bitcoin today."""
p = price(eur(pricing_mode=PricingMode.current))
assert p.source == "current"
assert p.amount == round(800 / LIVE_RATE * 1e8)
assert historical == []
def test_manual_mode_uses_the_stated_rate(historical):
p = price(eur(pricing_mode=PricingMode.manual, manual_rate=100_000))
assert p.source == "manual"
assert p.rate == 100_000
assert p.amount == 800_000 # 800 EUR at 100k EUR/BTC == 0.008 BTC
assert historical == []
def test_manual_mode_without_a_rate_refuses(historical):
with pytest.raises(PricingError):
price(eur(pricing_mode=PricingMode.manual))
# --- per-call override -----------------------------------------------------
def test_an_override_beats_the_contract(historical):
"""Pricing one off-cycle payout differently must not require editing the
contract."""
contract = eur(pricing_mode=PricingMode.payday)
p = price(contract, mode=PricingMode.manual, manual_rate=50_000)
assert (p.source, p.rate) == ("manual", 50_000)
assert historical == []
def test_an_override_rate_supplies_a_contract_that_has_none(historical):
p = price(eur(), mode=PricingMode.manual, manual_rate=50_000)
assert p.amount == round(800 / 50_000 * 1e8)
# --- the refusal that matters ----------------------------------------------
def test_an_unavailable_rate_refuses_rather_than_substituting(historical):
"""The whole point: no silent fallback to today's rate. A rate that
moved 30% since the payday would otherwise pay 30% off, discovered only
on review."""
with pytest.raises(PricingError):
price(make_contract(currency="JPY", amount=800))
def test_the_refusal_names_the_date_and_currency(historical):
with pytest.raises(PricingError) as exc:
price(make_contract(currency="JPY", amount=800))
assert "JPY" in str(exc.value) and "2026-05-15" in str(exc.value)
# --- memo ------------------------------------------------------------------
def test_memo_names_the_rate_applied():
"""18,353 sat and 14,794 sat are both "ten euro" — only the rate says
why a back-dated payout differs from today's."""
memo = services.payout_memo(eur(label="dev retainer"), PAST, PAST_RATE)
assert memo == "dev retainer — 2026-05-15 · 800 EUR @ 69,743.26 EUR/BTC"
def test_memo_prefers_the_payment_memo_over_the_label():
contract = eur(label="internal label")
contract.memo = "Salary"
assert services.payout_memo(contract, PAST, PAST_RATE).startswith("Salary — ")
def test_memo_of_a_sat_contract_has_no_rate():
contract = make_contract(currency="sat", amount=1000, label="chores")
assert services.payout_memo(contract, PAST) == "chores — 2026-05-15"
def test_memo_without_a_rate_still_states_the_fiat_amount():
assert services.payout_memo(eur(label="x"), PAST) == "x — 2026-05-15 · 800 EUR"
# --- an explicit rate is an instruction, not a lookup ----------------------
def test_manual_applies_to_a_payday_today(historical):
""" "Pay at the rate we agreed" is as meaningful for a payday now as for
one last month — the calendar must not quietly override it."""
p = price(eur(pricing_mode=PricingMode.manual, manual_rate=100_000), payday=TODAY)
assert (p.source, p.rate) == ("manual", 100_000)
assert p.amount == 800_000
def test_manual_applies_to_a_future_payday(historical):
p = price(
eur(pricing_mode=PricingMode.manual, manual_rate=100_000),
payday=date(2026, 12, 1),
)
assert p.source == "manual"
def test_manual_override_beats_a_future_payday(historical):
"""Paying next week's period early, at a stated rate."""
p = price(
eur(), payday=date(2026, 12, 1), mode=PricingMode.manual, manual_rate=50_000
)
assert (p.source, p.rate) == ("manual", 50_000)
def test_payday_mode_on_a_future_payday_falls_to_the_live_rate(historical):
"""There is no historical rate for a date that has not happened."""
p = price(eur(pricing_mode=PricingMode.payday), payday=date(2026, 12, 1))
assert p.source == "current"
assert historical == []