From 1584eb337f90d0b1b8034df1292fa8092485dd78 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 31 Aug 2026 22:14:46 +0200 Subject: [PATCH] fix: honour a stated rate whatever the calendar says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ". 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 Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj --- docs/operations.md | 11 +++++++++++ services.py | 33 ++++++++++++++++++++------------- static/js/index.js | 13 +++++++++++++ templates/payroll/index.html | 22 ++++++++++++++++------ tests/test_pricing.py | 34 ++++++++++++++++++++++++++++++++++ 5 files changed, 94 insertions(+), 19 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 2c816db..1bc1c8b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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" | | `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` on `pay-now`, which is how you enter one back-dated payment without editing the contract. diff --git a/services.py b/services.py index 60e8950..e3cf8f2 100644 --- a/services.py +++ b/services.py @@ -290,12 +290,26 @@ async def resolve_price( mode = mode or contract.pricing_mode manual_rate = manual_rate or contract.manual_rate - # A payday that is today or ahead needs no history: every mode agrees on - # the live rate. 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. It is the same single - # conversion either way — fiat_amount_as_satoshis is the function - # create_invoice would have called — not a second opinion. + # An explicit rate wins outright, whatever the calendar says. It is an + # instruction rather than a lookup — "pay at the rate we agreed" is as + # meaningful for a payday next week as for one last month, and a + # contract pegged to a fixed rate should honour it on every period. + 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" + ) + + # 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: amount_sat = await fiat_amount_as_satoshis(contract.amount, contract.currency) if amount_sat <= 0: @@ -306,13 +320,6 @@ async def resolve_price( rate = (contract.amount / amount_sat) * 100_000_000 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) if historical is None: raise PricingError( diff --git a/static/js/index.js b/static/js/index.js index 4f88d7a..8f3b912 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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() { const f = this.ledgerFilter const params = new URLSearchParams() diff --git a/templates/payroll/index.html b/templates/payroll/index.html index 0e5ca71..4a5ab2e 100644 --- a/templates/payroll/index.html +++ b/templates/payroll/index.html @@ -269,8 +269,8 @@ + label="Pricing" + hint="'Rate on the payday' only differs for a payday in the past">
+ label="Pricing">
${ payNowDialog.contract.label || payNowDialog.contract.employee_username } - — ${ payNowDialog.contract.amount } ${ payNowDialog.contract.currency }. + — ${ payNowDialog.contract.amount } ${ payNowDialog.contract.currency }, + payday ${ payNowPayday || '—' }. The period is consumed even if its payday has not arrived yet.
+ label="Price it at"> + +
+ Payday ${ payNowPayday } has not passed, so there is no historical + rate to look up — this will be priced at the current rate. +
+
+ Will convert at what BTC was worth on ${ payNowPayday }. +