Commit graph

3 commits

Author SHA1 Message Date
3b14db3dff feat: name the applied rate in the payout memo
A back-dated payout's sat figure is unexplainable on its own — employee_2's
August backfill paid 18,353 sat and 14,794 sat for the same ten euro, and
only the rate says why. Both wallets now show it:

    dev retainer — 2026-08-01 · 10 EUR @ 54,487 EUR/BTC
    dev retainer — 2026-08-29 · 10 EUR @ 67,596.6 EUR/BTC

This required moving the `current` mode conversion into payroll as well.
Previously that mode handed create_invoice a fiat amount and let LNbits
convert, so the rate only became knowable by reading it back off the
resulting invoice — after the memo had already been fixed. Now every mode
resolves its rate before invoicing and the memo is accurate in all three,
rather than only for the back-dated ones.

It remains a single conversion, not a second opinion: fiat_amount_as_satoshis
is the same function create_invoice would have called, and the rate is
derived by the same formula calculate_fiat_amounts uses. Because passing
sats means LNbits no longer stamps the fiat metadata itself, payroll now
writes the identical fiat_currency / fiat_amount / fiat_rate / btc_rate keys
onto the payment, so a payroll payment still reads like any other
fiat-priced one in the payments list.

A rate that rounds an amount to zero sats now fails the period rather than
raising a zero-division while deriving the rate.

Memo omits the rate clause for sat-denominated contracts, where there is no
conversion to report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 21:55:49 +02:00
18a17cd693 feat: price a back-dated period at the day it was due
Until now every payout converted at whatever the rate was when it settled,
so a period paid late was silently mispriced. Contracts now carry a pricing
mode, and every payout records the rate it used plus where that rate came
from — a figure in the ledger can be explained months later instead of
merely trusted.

Three modes, per contract and overridable per payout:

- `payday` (default) converts at what BTC was worth on the payday itself,
  via the historical lookup.
- `current` converts at today's rate — correct when the obligation reads
  "we owe EUR 800 whenever it settles".
- `manual` converts at a rate the operator states (100000 EUR/BTC), for a
  figure that was agreed rather than looked up.

For a payday that is today or ahead, all three collapse to the same thing
and none of them touches the network: LNbits' own live pricing is the
freshest source available, so `resolve_price` returns the fiat amount
unconverted and lets create_invoice do its job. History is consulted only
where it can actually change the answer.

Where payroll does convert, it must hand create_invoice a sat amount —
create_invoice always prices fiat itself and cannot be told a rate. That
moves the single conversion point into payroll, which is why the rate and
its source are recorded on the payout. In `current` mode the rate is read
back off the invoice LNbits priced (extra["btc_rate"]) rather than
recomputed, so the row records the number actually applied.

An unavailable rate raises PricingError and fails the period. Deliberately
no fallback to today's rate: a rate that moved 30% since the payday would
pay 30% off and hide it, which is the class of error nobody finds until an
audit. The existing retry-then-pause machinery already handles a failed
period, and the ledger row names the date and currency that could not be
priced. Manual mode missing its rate is caught at contract-creation time
instead, rather than surfacing as a failed payout weeks later.

m003 defaults preserve behaviour for anything in flight — every live payday
is today or ahead, where the modes agree. Verified by applying m003 to a
copy of the running instance's database: the existing contract and its paid
payout both survive and read back correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
2026-08-31 21:42:00 +02:00
99f2131474 feat: recurring payout scheduler
Turns a contract into money moving. One permanent task ticks every five
minutes and settles whatever each active contract owes; the actual transfer
is a plain internal LNbits invoice on the employee's wallet, paid from the
source wallet.

services.py is split into a pure half and an effectful half on purpose.
Paydays are the part of payroll that is easy to get subtly wrong and
expensive to get wrong in production, so the schedule math has no DB, no
wallets and no clock of its own, and is covered by tests.

Decisions worth reviewing:

- The n-th payday is a function of start_date and n alone. Advancing a
  stored date would drift on every late tick and would pin a month-end
  contract to the 28th forever; anchoring means 31 Jan pays 28 Feb and then
  31 Mar. Tested both ways round.
- A failed period does not advance the contract's position, and a backlog
  halts at the first failure so paydays cannot settle out of order.
- The sat amount is derived exactly once, by create_invoice, and the value
  it returns is what gets recorded — never recomputed from amount x rate.
- Back-dated start dates skip rather than back-pay by default; a mistyped
  start date is far more likely than a genuine back-pay request. Explicit
  `backfill` opts in, and a single tick is capped at 12 periods either way.
- Per-contract asyncio lock, with the row re-read under it. Not needed by
  the scheduler alone, but off-cycle payout paths land mid-tick and
  double-paying is the worst thing this extension could do.

Known gap, addressed by the payout-ledger commit that follows: a failure is
retried indefinitely, once per tick, with nothing but a log line to show
for it.

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