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
This commit is contained in:
parent
3b14db3dff
commit
1584eb337f
5 changed files with 94 additions and 19 deletions
|
|
@ -56,6 +56,17 @@ A period whose payday is **in the past** is where the modes diverge:
|
||||||
| `current` | today's rate | the debt reads "we owe EUR 800, whenever it settles" |
|
| `current` | today's rate | the debt reads "we owe EUR 800, whenever it settles" |
|
||||||
| `manual` | a rate you state (`100000` EUR/BTC) | the figure was agreed, not looked up |
|
| `manual` | a rate you state (`100000` EUR/BTC) | the figure was agreed, not looked up |
|
||||||
|
|
||||||
|
`manual` is an instruction, not a lookup, so it applies to **every** period —
|
||||||
|
a contract pegged to an agreed rate honours it whether the payday is last
|
||||||
|
month or next week.
|
||||||
|
|
||||||
|
`payday` and `current` only diverge for a payday already past. For one that
|
||||||
|
is today or ahead there is no history to consult, so `payday` collapses into
|
||||||
|
`current`: a future rate is not knowable, and the live one is the only
|
||||||
|
defensible answer. The pay-now dialog says so outright, naming the period's
|
||||||
|
date, rather than leaving a hint to be missed beside a control that appears
|
||||||
|
to do something.
|
||||||
|
|
||||||
Set per contract; overridable per payout via `pricing_mode` / `manual_rate`
|
Set per contract; overridable per payout via `pricing_mode` / `manual_rate`
|
||||||
on `pay-now`, which is how you enter one back-dated payment without editing
|
on `pay-now`, which is how you enter one back-dated payment without editing
|
||||||
the contract.
|
the contract.
|
||||||
|
|
|
||||||
33
services.py
33
services.py
|
|
@ -290,12 +290,26 @@ async def resolve_price(
|
||||||
mode = mode or contract.pricing_mode
|
mode = mode or contract.pricing_mode
|
||||||
manual_rate = manual_rate or contract.manual_rate
|
manual_rate = manual_rate or contract.manual_rate
|
||||||
|
|
||||||
# A payday that is today or ahead needs no history: every mode agrees on
|
# An explicit rate wins outright, whatever the calendar says. It is an
|
||||||
# the live rate. Payroll still does the conversion rather than handing
|
# instruction rather than a lookup — "pay at the rate we agreed" is as
|
||||||
# create_invoice a fiat amount, because knowing the rate *before* the
|
# meaningful for a payday next week as for one last month, and a
|
||||||
# invoice exists is what lets the memo state it. It is the same single
|
# contract pegged to a fixed rate should honour it on every period.
|
||||||
# conversion either way — fiat_amount_as_satoshis is the function
|
if mode == PricingMode.manual:
|
||||||
# create_invoice would have called — not a second opinion.
|
if not manual_rate:
|
||||||
|
raise PricingError("manual pricing selected but no rate given")
|
||||||
|
return Price(
|
||||||
|
sats_for(contract.amount, manual_rate), "sat", manual_rate, "manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
# A payday that is today or ahead has no history to consult, so `payday`
|
||||||
|
# mode collapses into `current` — a future rate is not knowable, and the
|
||||||
|
# live one is the only defensible answer.
|
||||||
|
#
|
||||||
|
# Payroll still does the conversion rather than handing create_invoice a
|
||||||
|
# fiat amount, because knowing the rate *before* the invoice exists is
|
||||||
|
# what lets the memo state it. Same single conversion either way —
|
||||||
|
# fiat_amount_as_satoshis is the function create_invoice would have
|
||||||
|
# called — not a second opinion.
|
||||||
if payday >= today or mode == PricingMode.current:
|
if payday >= today or mode == PricingMode.current:
|
||||||
amount_sat = await fiat_amount_as_satoshis(contract.amount, contract.currency)
|
amount_sat = await fiat_amount_as_satoshis(contract.amount, contract.currency)
|
||||||
if amount_sat <= 0:
|
if amount_sat <= 0:
|
||||||
|
|
@ -306,13 +320,6 @@ async def resolve_price(
|
||||||
rate = (contract.amount / amount_sat) * 100_000_000
|
rate = (contract.amount / amount_sat) * 100_000_000
|
||||||
return Price(amount_sat, "sat", rate, "current")
|
return Price(amount_sat, "sat", rate, "current")
|
||||||
|
|
||||||
if mode == PricingMode.manual:
|
|
||||||
if not manual_rate:
|
|
||||||
raise PricingError("manual pricing selected but no rate given")
|
|
||||||
return Price(
|
|
||||||
sats_for(contract.amount, manual_rate), "sat", manual_rate, "manual"
|
|
||||||
)
|
|
||||||
|
|
||||||
historical = await historical_btc_rate(payday, contract.currency)
|
historical = await historical_btc_rate(payday, contract.currency)
|
||||||
if historical is None:
|
if historical is None:
|
||||||
raise PricingError(
|
raise PricingError(
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,19 @@ window.app = Vue.createApp({
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Which period pay-now would settle, and whether it is actually in the
|
||||||
|
// past. "Rate on the payday" cannot mean anything for a date that has
|
||||||
|
// not happened, and the dialog should say so rather than offer a choice
|
||||||
|
// that quietly does nothing.
|
||||||
|
payNowPayday() {
|
||||||
|
const c = this.payNowDialog.contract
|
||||||
|
return c ? c.next_payday : null
|
||||||
|
},
|
||||||
|
|
||||||
|
payNowIsBackdated() {
|
||||||
|
return !!this.payNowPayday && this.payNowPayday < today()
|
||||||
|
},
|
||||||
|
|
||||||
ledgerCsvHref() {
|
ledgerCsvHref() {
|
||||||
const f = this.ledgerFilter
|
const f = this.ledgerFilter
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
|
|
|
||||||
|
|
@ -269,8 +269,8 @@
|
||||||
<q-select outlined dense v-model="contractDialog.data.pricing_mode"
|
<q-select outlined dense v-model="contractDialog.data.pricing_mode"
|
||||||
:options="pricingModes" emit-value map-options
|
:options="pricingModes" emit-value map-options
|
||||||
:disable="contractDialog.data.currency === 'sat'"
|
:disable="contractDialog.data.currency === 'sat'"
|
||||||
label="Price a late payday at"
|
label="Pricing"
|
||||||
hint="Only applies to a payday already in the past"></q-select>
|
hint="'Rate on the payday' only differs for a payday in the past"></q-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-5">
|
<div class="col-5">
|
||||||
<q-input outlined dense type="number" step="any"
|
<q-input outlined dense type="number" step="any"
|
||||||
|
|
@ -356,7 +356,7 @@
|
||||||
<div class="col-7">
|
<div class="col-7">
|
||||||
<q-select outlined dense v-model="editDialog.data.pricing_mode"
|
<q-select outlined dense v-model="editDialog.data.pricing_mode"
|
||||||
:options="pricingModes" emit-value map-options
|
:options="pricingModes" emit-value map-options
|
||||||
label="Price a late payday at"></q-select>
|
label="Pricing"></q-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-5">
|
<div class="col-5">
|
||||||
<q-input outlined dense type="number" step="any"
|
<q-input outlined dense type="number" step="any"
|
||||||
|
|
@ -384,15 +384,25 @@
|
||||||
<div v-if="payNowDialog.contract" class="text-caption"
|
<div v-if="payNowDialog.contract" class="text-caption"
|
||||||
:style="{opacity: 0.7}">
|
:style="{opacity: 0.7}">
|
||||||
${ payNowDialog.contract.label || payNowDialog.contract.employee_username }
|
${ payNowDialog.contract.label || payNowDialog.contract.employee_username }
|
||||||
— ${ payNowDialog.contract.amount } ${ payNowDialog.contract.currency }.
|
— ${ payNowDialog.contract.amount } ${ payNowDialog.contract.currency },
|
||||||
|
payday <strong>${ payNowPayday || '—' }</strong>.
|
||||||
The period is consumed even if its payday has not arrived yet.
|
The period is consumed even if its payday has not arrived yet.
|
||||||
</div>
|
</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-gutter-md">
|
<q-card-section class="q-gutter-md">
|
||||||
<q-select outlined dense v-model="payNowDialog.pricing_mode"
|
<q-select outlined dense v-model="payNowDialog.pricing_mode"
|
||||||
:options="pricingModes" emit-value map-options
|
:options="pricingModes" emit-value map-options
|
||||||
label="Price it at"
|
label="Price it at"></q-select>
|
||||||
hint="Ignored unless the payday is already in the past"></q-select>
|
|
||||||
|
<div v-if="payNowDialog.pricing_mode === 'payday' && !payNowIsBackdated"
|
||||||
|
class="text-caption" :style="{opacity: 0.8}">
|
||||||
|
Payday ${ payNowPayday } has not passed, so there is no historical
|
||||||
|
rate to look up — this will be priced at the current rate.
|
||||||
|
</div>
|
||||||
|
<div v-else-if="payNowDialog.pricing_mode === 'payday'"
|
||||||
|
class="text-caption" :style="{opacity: 0.8}">
|
||||||
|
Will convert at what BTC was worth on ${ payNowPayday }.
|
||||||
|
</div>
|
||||||
<q-input outlined dense type="number" step="any"
|
<q-input outlined dense type="number" step="any"
|
||||||
v-model.number="payNowDialog.manual_rate"
|
v-model.number="payNowDialog.manual_rate"
|
||||||
:disable="payNowDialog.pricing_mode !== 'manual'"
|
:disable="payNowDialog.pricing_mode !== 'manual'"
|
||||||
|
|
|
||||||
|
|
@ -168,3 +168,37 @@ def test_memo_of_a_sat_contract_has_no_rate():
|
||||||
|
|
||||||
def test_memo_without_a_rate_still_states_the_fiat_amount():
|
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"
|
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 == []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue