From 9606e98e112e1d1385d92995f1d23b70430e76ab Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 22 Oct 2026 10:14:07 +0000 Subject: [PATCH] plans: names late credit tasks, fourth round --- .../2026-10-21-store-service-tasks.md | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/plans/names-codes/2026-10-21-store-service-tasks.md b/plans/names-codes/2026-10-21-store-service-tasks.md index 2d8312331f..258ee2e964 100644 --- a/plans/names-codes/2026-10-21-store-service-tasks.md +++ b/plans/names-codes/2026-10-21-store-service-tasks.md @@ -303,10 +303,10 @@ The owner's decisions of 2026-10-22 remove the code for a taken order and keep a - **Goal:** the balance of contract section 8 stored, with every query the later tasks use, its turn, its fields in the view, every close of an order releasing its hold, and `placeOrder` able to close a balance's overdue holder. Nothing credits, holds or spends yet. - **Files:** the two migration modules (migration `20261022_name_balances`, which sorts after `20261022_drop_taken_codes`); `StoreService/Names/Store.hs`; `StoreService/Orders.hs` (`closeOrder`'s release); `StoreService/Names/Turns.hs`; new `StoreService/Names/Balances.hs`; `StoreService/Names/View.hs`; `StoreService/Names/Orders.hs`; the modules matching on `RegistrationPayment` (`rg -n 'RPInvoice|RPCode' apps/simplex-store-service/src`), for the new constructor only; `StoreService/Web/Names/Orders.hs` also for `/api/invoice`'s call of `createPaidRegistration` (line 129 at e6fb909d6), which passes its `OTInvoice` terms; `simplex-chat.cabal`; `tests/Test.hs`; `tests/Bots/StoreService/NamesStoreTests.hs`; new `tests/Bots/StoreService/NamesBalancesTests.hs`; `tests/Bots/StoreService/NamesOrdersTests.hs` (the view). - **Do:** - - **Schema:** table `name_balances`: `registration_id` (the primary key: the order the balance belongs to), `amount` (cents left, `CHECK (amount >= 0)`), `credited`, `refunded` (default 0), `reserved_by` (the holder; unique, nullable), `refunded_at`, `created_at`, `updated_at`. Registrations gain `balance_source` (the order whose balance paid, indexed) and `balance_used` (cents, default 0). Payments gain `confirmed_amount` (cents of confirmed money at the invoice's rate, default 0), `confirmed_crypto` (the confirmed crypto as a decimal, nullable) and `invoice_rate` (the invoice's rate, nullable); T24 writes them, only rising. Name invoices gain `late_alerted_at`. A new table `name_late_credits` records each late credit: `invoice_id`, `registration_id`, `crypto` (nullable), `invoice_rate`, `current_rate`, `rate_used` (nullable), `cents_at_invoice_rate`, `cents`, `created_at`. `reserved_by` and `balance_source` reference registrations with the default action (restrict): the 30-day cleanup skips an unpaid order that a balance still names as its holder, and logs it, instead of deleting it. Up and down, SQLite and Postgres. + - **Schema:** table `name_balances`: `registration_id` (the primary key: the order the balance belongs to), `amount` (cents left, `CHECK (amount >= 0)`), `credited`, `refunded` (default 0), `reserved_by` (the holder; unique, nullable), `refunded_at`, `created_at`, `updated_at`. Registrations gain `balance_source` (the order whose balance paid, indexed) and `balance_used` (cents, default 0). Payments gain `confirmed_amount` (cents of confirmed money at the invoice's rate, default 0), `received_units` and `confirmed_units` (crypto in integer atomic units: satoshi for `BTC-CHAIN`, piconero for `XMR-CHAIN`; `INTEGER` in SQLite, `BIGINT` in Postgres; nullable for card payments) and `invoice_rate` (the invoice's rate as decimal text, never summed); T24 writes them, only rising. Name invoices gain `late_alerted_at` and `late_alerted_units` (the received units the last alert named). A new table `name_late_credits` records each late credit: `invoice_id`, `credit_no` (1, 2, … per invoice; `UNIQUE (invoice_id, credit_no)`), `registration_id`, `units` (`INTEGER`/`BIGINT`, nullable for card), `invoice_rate`, `current_rate`, `rate_used` (decimal text, nullable for card), `cents`, `created_at`. Every sum in SQL is over integer columns; no sum is ever taken over text. `reserved_by` and `balance_source` reference registrations with the default action (restrict): the 30-day cleanup skips an unpaid order that a balance still names as its holder, and logs it, instead of deleting it. Up and down, SQLite and Postgres. - **Data step**, in the same migration: `UPDATE … SET live = 0 WHERE phase = 'taken'`, and a balance of `price` for every `taken` order without a code and with `price` above 0 (credited = amount = price). Until T24 changes `orderLive`, `/name` from such a row is refused by `updateRegistration` (`NotLive`, `bad_request`). No database holds names rows yet. - **Types:** `RPBalance`, the payment of an order with neither invoice nor code; `nrBalanceSource`, `nrBalanceUsed`; `Balance`; `OTBalance` in `orderHash`, tagged `name-balance`, over the order fields, the owner, the source's id, `price`, the balance seen and the method or ""; `ORBalanceChanged` in `OrderRefusal`, answered `409 balance_changed` with the current balance (`Web/Names/Orders.hs`). - - **Queries**, each one conditional statement reporting whether it applied: `getBalance` (`held` and `heldUntil` from the holder's invoice; a hold by an order whose invoice is past its expiry with no money received reads as none, by the sweep's `unfundedQuery`); `overdueHolder`; `balanceByReference` (a registration id, or a name invoice id); `balanceChain` (from an order back along `balance_source` to the first, following `balance_source` only from orders past `unpaid`, since a closed difference order released its source's balance; each with its invoice, method, the cents it paid and its invoice's late credits with their rates); `lateCreditState` (after the invoice's lock: the payment row's figures and the sums of the invoice's `name_late_credits`); `creditLatePayment` (in the caller's transaction, after the invoice's lock: one `name_late_credits` row and `creditBalance` of its cents, conditional on the credited sums still being the ones read); `latePending` (one definition, used for the info log and the alert: on a closed unpaid name invoice, the payment row's received `amount` above `confirmed_amount`, or money held with `amount` 0, as Monero confirming, or confirmed money not all credited); `uncreditedLateInvoices` (closed unpaid name invoices with confirmed money not all credited, for the poller's credit pass); `lateAlertDue` (closed unpaid name invoices `latePending` whose payment row was created before a given time and whose `late_alerted_at` is null); `creditBalance` (an upsert adding to `amount` and `credited`); `createBalanceRegistration` (a `queued` order, and its price spent where `amount` is the amount seen and nothing holds it); `createPaidRegistration` taking the order's `OrderTerms` (so a difference spend is stored under its `name-balance` hash, which its exact resend looks up) and an optional held part (the hold written after the registration's insert, on the same condition); `spendHeldBalance` (where this holder holds it and `amount ≥ used`; the hold cleared); `releaseHeldBalance` (by holder); `refundBalance` (where `amount` equals the cents given, above 0, and nothing holds it: `amount` 0, `refunded = refunded + cents`, `refunded_at` set). + - **Queries**, each one conditional statement reporting whether it applied: `getBalance` (`held` and `heldUntil` from the holder's invoice; a hold by an order whose invoice is past its expiry with no money received reads as none, by the sweep's `unfundedQuery`); `overdueHolder`; `balanceByReference` (a registration id, or a name invoice id); `balanceChain` (from an order back along `balance_source` to the first, following `balance_source` only from orders past `unpaid`, since a closed difference order released its source's balance; each with its invoice, method, the cents it paid and its invoice's late credits with their rates); `lateCreditState` (the payment row's figures, the invoice's credited units or cents and its credit count, and the order's phase and `live`); `creditLatePayment` (in the caller's transaction, after the invoice's lock: one `name_late_credits` row numbered the count read plus one, so a second writer with the same read fails at the unique key, and `creditBalance` of its cents); `latePending` (one definition, used for the info log and the alert: on a name invoice whose order is `unpaid` and not live, whatever the invoice's status, `received_units` above `confirmed_units` (cents for card), or money held with `amount` 0, as Monero confirming, or `confirmed_units` above the credited units; each case named, so the alert can tell unconfirmed money from confirmed money waiting on the rate); `uncreditedLateInvoices` (by the order: name invoices whose order is `unpaid` and not live, whatever the invoice's status, with `confirmed_units` above the credited units, or confirmed cents above the credited cents for card; for the poller's credit pass); `lateAlertDue` (the same orders `latePending`, whose payment row was created before a given time, and either never alerted or with `received_units` above `late_alerted_units`); `claimLateAlert` (`UPDATE … SET late_alerted_at = ?, late_alerted_units = ? WHERE invoice_id = ? AND` the old values, reporting whether it applied, so only one instance logs); `creditBalance` (an upsert adding to `amount` and `credited`); `createBalanceRegistration` (a `queued` order, and its price spent where `amount` is the amount seen and nothing holds it); `createPaidRegistration` taking the order's `OrderTerms` (so a difference spend is stored under its `name-balance` hash, which its exact resend looks up) and an optional held part (the hold written after the registration's insert, on the same condition); `spendHeldBalance` (where this holder holds it and `amount ≥ used`; the hold cleared); `releaseHeldBalance` (by holder); `refundBalance` (where `amount` equals the cents given, above 0, and nothing holds it: `amount` 0, `refunded = refunded + cents`, `refunded_at` set). - **Turns:** `withBalanceTurn :: Turns -> RegistrationId -> IO a -> IO a`, taken after any label turn and never with a code turn; the module's header says so. - **Releases:** every close of an unpaid order releases its hold in the transaction that clears `live`: `closeOverdue` (`Names/Store.hs`: a next order, `/spend`) and `closeOrder` (`Orders.hs`: settlement's expiry, cancel, the poller's sweep). A late payment leaves the released balance alone (T24 credits it to the closed order's own balance). - **`Names/Balances.hs`:** `recordRefund :: Turns -> DBStore -> Text -> Int -> UTCTime -> IO (Either RefundRefusal RefundRecord)` (in the balance's turn and one transaction: the reference resolved, then `refundBalance`). It refuses any held balance, overdue holder or not, naming the holder and its invoice's expiry, so the console never closes an order it cannot announce or cancel at the provider; the poller's sweep closes it. The record names the order, the cents, and the whole `balanceChain`. @@ -314,27 +314,33 @@ The owner's decisions of 2026-10-22 remove the code for a taken order and keep a - **The view:** `balance` (`amount`, `held`, `heldUntil`, `refunded`; no holder id) and `fromBalance` (contract section 6), read with `getBalance` in `registrationView`; `find`'s `invoiceId` null for `RPBalance`, and `find` returning the 50 newest and then every older order holding a balance above 0. - **`RPBalance` everywhere** behaves as a paid order: no code to reserve, release or spend; `/name`'s price cap; the worker keeps it through `taken` as it keeps an invoice order. - **Acceptance:** migration up and down on SQLite and Postgres, with the data step; for orders without a balance every answer is as before, except the view's new `balance` (null) and `fromBalance` (0). -- **Tests:** each query's effect, and its refusal leaving the row unchanged; two holds of one balance, one winning; a refund refused while held and accepted once released; `spendHeldBalance` refused for another holder; `amount = credited − spent − refunded` after a sequence of credits, a spend at once, a held spend, a released hold and a refund, where spent is the sum of `balance_used` over the orders with this `balance_source` that are past `unpaid` (a closed unpaid holder recorded `balance_used` but spent nothing); the hold released by each close: `cancelOpenInvoice`, the sweep's `expireOverdue`, settlement's expiry, and `closeOverdue`; a hold by an overdue holder read as none and closed by `placeOrder` (its invoice `expired`, its registration not live, the hold gone, all in the caller's transaction); a named holder no longer live skipped, and one funded meanwhile refusing `ORBalanceChanged`; a hold and a spend read back through a new store connection (a restart); the cleanup skipping and logging an unpaid order still named as a holder; `balanceByReference` by either id; `balanceChain` over three orders; the `name-balance` `order_hash` changing with each field and not with the timestamp or the signature, and stored by `createPaidRegistration` for a difference spend; `recordRefund`'s refusals (no balance, another amount, held by an open holder, held by an overdue holder) and its record with the chain; credit, refund, credit, refund, with `refunded` the sum and the invariant holding; `balanceChain` of a closed difference order stopping at itself; `creditLatePayment` refused when the credited sums changed since read, and applied once; `latePending` for an unconfirmed payment, a Monero zero figure, a part credit with an unconfirmed rest, and a fully credited invoice (not pending); `lateAlertDue` excluding an invoice alerted already and one whose payment row is newer than the cutoff; `find` with 51 orders returning an older one with a balance; the data step on a `taken` paid row (balance of its price, not live) and on a `taken` code row (none); `withBalanceTurn` running two holders of one balance one after the other, and nested in `withLabelTurn` without deadlock; the view's `balance` and `fromBalance`. +- **Tests:** each query's effect, and its refusal leaving the row unchanged; two holds of one balance, one winning; a refund refused while held and accepted once released; `spendHeldBalance` refused for another holder; `amount = credited − spent − refunded` after a sequence of credits, a spend at once, a held spend, a released hold and a refund, where spent is the sum of `balance_used` over the orders with this `balance_source` that are past `unpaid` (a closed unpaid holder recorded `balance_used` but spent nothing); the hold released by each close: `cancelOpenInvoice`, the sweep's `expireOverdue`, settlement's expiry, and `closeOverdue`; a hold by an overdue holder read as none and closed by `placeOrder` (its invoice `expired`, its registration not live, the hold gone, all in the caller's transaction); a named holder no longer live skipped, and one funded meanwhile refusing `ORBalanceChanged`; a hold and a spend read back through a new store connection (a restart); the cleanup skipping and logging an unpaid order still named as a holder; `balanceByReference` by either id; `balanceChain` over three orders; the `name-balance` `order_hash` changing with each field and not with the timestamp or the signature, and stored by `createPaidRegistration` for a difference spend; `recordRefund`'s refusals (no balance, another amount, held by an open holder, held by an overdue holder) and its record with the chain; credit, refund, credit, refund, with `refunded` the sum and the invariant holding; `balanceChain` of a closed difference order stopping at itself; `creditLatePayment` refused when the credited sums changed since read, and applied once; `latePending` for an unconfirmed payment, a Monero zero figure, a part credit with an unconfirmed rest, and a fully credited invoice (not pending); two credits of 0.1 and 0.2 BTC against 0.3 confirmed leaving the invoice fully credited, not selected by `uncreditedLateInvoices` and not pending; a second `creditLatePayment` with the same `credit_no` refused by the unique key; the scans selecting a late `Settled` invoice (status `paid`, order `unpaid`); `lateAlertDue` excluding an invoice alerted at its current received units and one whose payment row is newer than the cutoff, and including it again when more money arrives; `claimLateAlert` applied once of two; `find` with 51 orders returning an older one with a balance; the data step on a `taken` paid row (balance of its price, not live) and on a `taken` code row (none); `withBalanceTurn` running two holders of one balance one after the other, and nested in `withLabelTurn` without deadlock; the view's `balance` and `fromBalance`. - **Postgres:** one spec on two connections, in the wave's Postgres build: a spend at once against `refundBalance`, `spendHeldBalance` against `refundBalance`, and `creditLatePayment` against `refundBalance` (the refund refusing once the credit committed, as `amount = cents` is re-checked), each pair started together many times, exactly one conditional write applying each time and `amount` never negative. The safety argument rests on READ COMMITTED re-reading the row's `WHERE` after the lock. - **Depends on:** T22. #### T24. The balance's life: taken, settlement, closes and moves - **Goal:** a paid order that ends `taken` keeps its worth as a balance; a late payment is credited to its order's balance (owner, 2026-10-22); a difference invoice spends its hold when paid; `/name` no longer moves a taken order, and a move to a cheaper name keeps the difference. (T23 releases holds at every close.) -- **Files:** `StoreService/Names/Worker.hs`; `StoreService/Orders.hs` (settlement's spend, late credits); `StoreService/Poller.hs` (the cleanup's lines); `StoreService/Providers.hs` (`Received` gains `rcvConfirmed`; `Provider` gains `pCurrentRate`); `StoreService/Providers/BTCPay.hs` (each payment's `value` decoded, the confirmed crypto, the store rates call); `StoreService/Providers/Stripe.hs` (the succeeded amount as confirmed; no rate); `StoreService/Store/Invoices.hs` (the confirmed figures in the payment write, only rising); new `StoreService/Names/LateCredits.hs` (the credit step); `tests/Bots/StoreService/BTCPayTests.hs`, `StripeTests.hs` and `WebTests.hs` (their `Received` values, at `BTCPayTests.hs:169, 172, 204, 499, 603, 606`, `StripeTests.hs:76, 79` and `WebTests.hs:2122` at e6fb909d6); `tests/Bots/StoreService/FakeBTCPay.hs` (payments with a status and `value` per state, the rates endpoint, a failing rates read) and `FakeStripe.hs`; new `tests/Bots/StoreService/NamesLateCreditTests.hs`; `StoreService/Names/Store.hs` (`orderLive`); `StoreService/Names/Orders.hs`; `StoreService/Web/Names/Owner.hs`; `tests/Bots/StoreService/` `NamesWorkerTests.hs`, `NamesOrdersTests.hs`, `NamesOwnerTests.hs`, `NamesStoreTests.hs`, `NamesPhasesTests.hs`. +- **Files:** `StoreService/Names/Worker.hs`; `StoreService/Orders.hs` (settlement's spend, late credits); `StoreService/Poller.hs` (the cleanup's lines); `StoreService/Providers.hs` (`Received` gains `rcvConfirmed`; `Provider` gains `pCurrentRate :: Maybe (CryptoCurrency -> IO (Either ProviderError Rate))`, `Nothing` for Stripe, so the step never asks a card provider for a rate); `StoreService/Providers/BTCPay.hs` (each payment's `value` decoded, the confirmed crypto, the store rates call); `StoreService/Providers/Stripe.hs` (the succeeded amount as confirmed; no rate); `StoreService/Store/Invoices.hs` (the confirmed figures in the payment write, only rising); new `StoreService/Names/LateCredits.hs` (the credit step); `tests/Bots/StoreService/BTCPayTests.hs`, `StripeTests.hs` and `WebTests.hs` (their `Received` values, at `BTCPayTests.hs:169, 172, 204, 499, 603, 606`, `StripeTests.hs:76, 79` and `WebTests.hs:2122` at e6fb909d6); `tests/Bots/StoreService/FakeBTCPay.hs` (payments with a status and `value` per state, the rates endpoint, a failing rates read) and `FakeStripe.hs`; new `tests/Bots/StoreService/NamesLateCreditTests.hs`; `StoreService/Names/Store.hs` (`orderLive`); `StoreService/Names/Orders.hs`; `StoreService/Web/Names/Owner.hs`; `tests/Bots/StoreService/` `NamesWorkerTests.hs`, `NamesOrdersTests.hs`, `NamesOwnerTests.hs`, `NamesStoreTests.hs`, `NamesPhasesTests.hs`. - **Do:** - The worker's `taken` for a paid order (invoice, balance or both): the `taken` write and `creditBalance` of its `price` in one transaction (registration before balance), in the name's turn and then the balance's turn. A code order releases its code as now. - `orderLive`: `taken` is not live. `movable` drops `taken`, so `/name` from `taken` answers `bad_request`. - Settlement (`nameSettled`): the queue write and `spendHeldBalance` in one transaction. A held spend that does not apply cannot happen while every close releases its hold; if it does, the order is queued all the same, as the buyer paid the difference, and the operator is alerted with both ids. - - **BTCPay's shapes, cited:** before writing the decoder, the task captures from BTCPay's Greenfield API documentation or its source (`BTCPayServer.Client` models) the payment-methods body (`GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods`: each method's `rate`, `paymentMethodPaid`, `networkFee` and `payments` with `id`, `receivedDate`, `value`, `fee`, `status`), the store rates call (`GET /api/v1/stores/{storeId}/rates?currencyPair=BTC_USD`, or what the docs name), and what each speed policy means for a payment's `Settled` (confirmations for BTC; the Monero plugin's own setting for XMR). It cites the version and link in `Providers/BTCPay.hs`, and the fixtures and `FakeBTCPay` follow those shapes. Two behaviours stay unverified until a regtest BTCPay shows them: a late payment on an `Expired` or `Invalid` invoice reaching `Settled`, and `InvoicePaymentSettled` sent for it. If either is false, late crypto ends in the alert, which is safe. - - **Confirmed money** (contract section 8): `Received` gains `rcvConfirmed`: the confirmed crypto, the invoice's rate, and their cents at that rate, rounded once. BTCPay: on `SigSettled` (an invoice-level `Settled`, including an admin's Mark settled), or when every payment of the method reads `Settled`, all of `paymentMethodPaid`; otherwise the sum of the `Settled` payments' `value`, capped at `paymentMethodPaid`. So the figure shares `paymentMethodPaid`'s basis, whatever it nets out (a `fee`, the `networkFee`), never exceeds the received, and equals it once everything settles: no over-credit and no endless pending. Stripe: the succeeded amount (`SigSettled` only), else 0. The payment write keeps the larger confirmed figure (crypto compared as decimals, under the invoice's lock), and `newPayment` counts a rise as a new payment even when the totals are unchanged. A settled sum that falls (a payment turned `Invalid`, a reorg) leaves the stored figure as it was. Badge settlement ignores it. - - **Speed policy:** a late credit needs at least one confirmation. With `[btcpay] speed_policy = HighSpeed`, where `Settled` can mean zero confirmations, late crypto is never credited automatically: it stays pending and reaches the alert. The README says not to use `HighSpeed` when names are sold (T26's README pass). - - **Late payments, at the lower rate** (owner, 2026-10-22): the credit step `creditLateInvoice` (`Names/LateCredits.hs`) runs for an invoice whose order is closed and `unpaid` (every former `NOLate` outcome, and `cancelOpenInvoice`'s late case) right after the settlement or cancel commits, and from the poller's credit pass for every `uncreditedLateInvoices` entry. It reads the provider's current rate first, outside any transaction (`pCurrentRate`; BTCPay's store rates; Stripe needs none), then, in one transaction under the invoice's lock, reads `lateCreditState`, takes the confirmed crypto not yet credited, multiplies it by the lower of the invoice's rate and the current rate, rounds once, and writes `creditLatePayment` with both rates and the cents. A failed rate read credits nothing and logs a warning; the next pass tries again. A credit is an info log naming the invoice, the order, the rates and the cents. Money `latePending` is an info log, only for uncredited money. + - **BTCPay's shapes, cited:** before writing the decoder, the task captures from BTCPay's Greenfield API documentation or its source (`BTCPayServer.Client` models) the payment-methods body (`GET /api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods`: each method's `rate`, `paymentMethodPaid`, `networkFee` and `payments` with `id`, `receivedDate`, `value`, `fee`, `status`), the store rates call (`GET /api/v1/stores/{storeId}/rates?currencyPair=BTC_USD`, or what the docs name), the rates answer's shape (a list of `{currencyPair, errors, rate}`, `rate` null when `errors` is not empty, as recalled; the task confirms), the invoice's `checkout.speedPolicy` in `GET /api/v1/stores/{storeId}/invoices/{invoiceId}`, which the adapter reads, and what each speed policy means for a payment's `Settled` (confirmations for BTC; the Monero plugin's own setting for XMR). It cites the version and link in `Providers/BTCPay.hs`, and the fixtures and `FakeBTCPay` follow those shapes. Two behaviours stay unverified until a regtest BTCPay shows them: a late payment on an `Expired` or `Invalid` invoice reaching `Settled`, and `InvoicePaymentSettled` sent for it. If either is false, late crypto ends in the alert, which is safe. + - **Atomic units:** the decoder turns `paymentMethodPaid` and each payment's `value` into integer units by a per-method constant beside `knownMethodIds` (8 decimals for `BTC-CHAIN`, 12 for `XMR-CHAIN`); an amount with more decimals fails the read. + - **Confirmed money** (contract section 8): `Received` gains `rcvConfirmed`: the confirmed units, the invoice's rate, and their cents at that rate, rounded once. BTCPay: on `SigSettled` (an invoice-level `Settled`, including an admin's Mark settled), or when every payment of the method reads `Settled`, all of `paymentMethodPaid`; otherwise the sum of the `Settled` payments' `value`, capped at `paymentMethodPaid`. So the figure shares `paymentMethodPaid`'s basis, whatever it nets out (a `fee`, the `networkFee`), never exceeds the received, and equals it once everything settles: no over-credit and no endless pending. Stripe: the succeeded amount (`SigSettled` only), else 0. The payment write keeps the larger confirmed figure (units compared as integers, under the invoice's lock), and `newPayment` counts a rise as a new payment even when the totals are unchanged. A settled sum that falls (a payment turned `Invalid`, a reorg) leaves the stored figure as it was. Badge settlement ignores it. + - **Speed policy:** a late credit needs at least one confirmation. The decoder applies it from the invoice's own `checkout.speedPolicy`, not the current `[btcpay] speed_policy`: under `HighSpeed` (where `Settled` can mean zero confirmations) `rcvConfirmed` is 0, an invoice-level `Settled` included, so the money stays pending and reaches the alert, and a later configuration change cannot credit money support may have refunded by hand after it. The README says not to use `HighSpeed` when names are sold (T26's README pass). + - **Late payments, at the lower rate** (owner, 2026-10-22): the credit step `creditLateInvoice` (`Names/LateCredits.hs`) runs for an invoice whose order is `unpaid` and not live, whatever the invoice's status. `settleMoved` in the poller runs it after `settleOrder` commits, with the provider it already has (`settleOrder` itself takes no provider and returns no outcome); the poller's credit pass, at the idle cadence, runs it for every `uncreditedLateInvoices` entry. `cancelOpenInvoice` (the buyer's request) runs no step and reads no rate: the pass credits its late money. + - First, without the lock, `lateCreditState`: nothing due, no rate read. Card money due: no rate. Crypto due: the rate for its currency pair, read once per pass and shared by the pass's invoices, outside any transaction. + - A rate read fails on an error, a missing pair, a rate with `errors`, a rate at or below 0, or a rate below `minCurrentRateFraction` (½, a named constant) of the invoice's rate; the last is a glitch and logs an error. A failure credits nothing, and is logged once per pass through `dueToWarn`, not per invoice. + - Then, in one transaction: `lockInvoice` first, `lateCreditState` again, the order still `unpaid` and not live, the units not yet credited times the lower of the invoice's rate and the current rate, rounded down to the cent (`floor`; never Haskell's `round`, which rounds half to even), and `creditLatePayment` with both rates, the cents and the next `credit_no`. + - A credit is an info log naming the invoice, the order, the units, the rates and the cents. Money `latePending` is an info log, only for uncredited money. - **Why not in settlement's transaction:** the rate read cannot run under the invoice's lock. The stored state (confirmed money not all credited) is what the poller's pass re-reads, so a crash or a failed read between the settlement's commit and the credit loses nothing, which the earlier same-transaction rule existed to ensure. - **`LiveOrderExists` at settlement** (another live order holds the commitment, or the owner and the name; impossible while the unique indexes hold): the order is closed there as `closeOrder` closes it (not live, its hold released), its payment credited as a late payment, and the operator alerted, since an invariant broke. No sweep closes a paid invoice, so leaving it live would keep its hold for ever. - - The poller: the cleanup's "received a payment; refund it" line becomes an info line saying the order is kept with its balance; its comment on settlement's alert follows. It runs the credit pass each pass. Its daily pass alerts (`logError`) once per invoice for each `lateAlertDue` entry whose payment row is older than `settleWindow`, then sets `late_alerted_at`: "invoice of order holds money not all credited (, ): refund by hand only a transaction confirmed on chain that BTCPay no longer monitors; one still `Processing` there waits". The badge poller's refund alert is unchanged. + - The poller: the cleanup's "received a payment; refund it" line becomes an info line saying the order is kept with its balance; its comment on settlement's alert follows. It runs the credit pass at its idle cadence. Its daily pass, for each `lateAlertDue` entry whose payment row is older than `settleWindow`, claims it with `claimLateAlert` and logs only when the claim applied (`logError`). The alert names crypto received, confirmed and credited in units with the currency (cents for card), and its reason: unconfirmed money, "invoice of order : received, confirmed, credited; refund by hand only a transaction confirmed on chain that BTCPay no longer monitors; one still `Processing` there waits"; or confirmed money waiting on the rate, "…: confirmed, the credit waits on the rate read; do not refund; fix the rate read". It alerts again only when more money arrives. The badge poller's refund alert is unchanged. - `/name` of a paid order to a cheaper name: `price` lowered and the difference credited to the order's balance in the move's transaction (registration before balance), in the label turns and then the balance's turn. -- **Tests:** two wallets' paid orders for one name, the later `taken` with a balance of its price and not live; a taken order paid wholly from a balance and one paid partly, each credited its whole `price` as a balance of its own, the source's balance untouched; a code order taken: its code released, no balance; a refused credit write leaving the order as it was (one transaction); a held order settled: `queued`, the hold spent, the source's `amount` down by `fromBalance`; a difference invoice funded before its expiry time and settled after it, still `open` in the database: `queued`, the hold spent; a late payment after the close credited to the closed order's balance, the released source balance untouched, logged at info; a full late payment, a crypto payment confirming over two reports (credited twice, the increase only), the same report twice (credited once), a provider close holding a confirmed part payment, a cancel meeting confirmed money; an unconfirmed late payment crediting nothing (logged as pending), then credited once when its payment reads `Settled` with the totals unchanged; an unconfirmed payment replaced (RBF) or dropped never crediting, and alerted once by the daily pass after its payment row passed `settleWindow`, never again; a payment first seen just before the window not alerted until its own row is past it; a part credit with an unconfirmed rest alerted once; Monero's zero figure while confirming logged as pending, not alerted; a falling price (the current rate below the invoice's: credited at the current rate) and a rising one (credited at the invoice's), each storing both rates and the rate used; a failed rate read crediting nothing, then credited by the next pass; a crash between the settlement's commit and the credit (the credit step not run) credited by the pass; the confirmed figure from a body with a non-zero `fee`, and from two `Settled` payments whose separate rounding would differ (rounded once, capped at `paymentMethodPaid`); an invoice-level `Settled` with one payment still `Processing` credited in full; a settled sum that falls (a payment turned `Invalid`) leaving the stored figure and the credit, and the same sum again crediting nothing; an invoice past `settleWindow`, not read by the pass, credited after a hinted read; a cancel meeting unconfirmed money crediting nothing, then once when the poller reads it `Settled`; `HighSpeed` configured: a late `Settled` payment not credited, pending and alerted; Stripe's succeeded amount confirmed, credited without a rate read; the decoders against the cited shapes (hand-written fixtures named as such until a regtest capture replaces them); `LiveOrderExists` at settlement closing the order, crediting and alerting; the same for a difference order, the source's hold released before the order's own balance is credited, in one transaction; a credit while the closed order's balance is held by a spend (the held spend settles, the credit stays); a covered `/spend` that read the amount before a credit answering `balance_changed`; credit, refund, late credit, refund, with `refunded` the sum; the cleanup keeping such an order with its balance; the credited balance spent by `/spend` from the closed order and refunded by `recordRefund`; settlement racing `recordRefund` (the refund refused while held, the balance spent after); the alert when the held spend does not apply (a hold removed by hand); `/name` from `taken` refused; a cheaper move crediting the difference, a cheaper move whose compare-and-set loses leaving no credit, and a dearer one still `price_too_high`; `fromBalance` unchanged by a move; a held poll woken by the `taken` write and not by a balance change. +- **Tests:** two wallets' paid orders for one name, the later `taken` with a balance of its price and not live; a taken order paid wholly from a balance and one paid partly, each credited its whole `price` as a balance of its own, the source's balance untouched; a code order taken: its code released, no balance; a refused credit write leaving the order as it was (one transaction); a held order settled: `queued`, the hold spent, the source's `amount` down by `fromBalance`; a difference invoice funded before its expiry time and settled after it, still `open` in the database: `queued`, the hold spent; a late payment after the close credited to the closed order's balance, the released source balance untouched, logged at info; a full late payment, a crypto payment confirming over two reports (credited twice, the increase only), the same report twice (credited once), a provider close holding a confirmed part payment, a cancel meeting confirmed money; an unconfirmed late payment crediting nothing (logged as pending), then credited once when its payment reads `Settled` with the totals unchanged; an unconfirmed payment replaced (RBF) or dropped never crediting, and alerted once by the daily pass after its payment row passed `settleWindow`, never again; a payment first seen just before the window not alerted until its own row is past it; a part credit with an unconfirmed rest alerted once; Monero's zero figure while confirming logged as pending, not alerted; a falling price (the current rate below the invoice's: credited at the current rate) and a rising one (credited at the invoice's), each storing both rates and the rate used; a failed rate read crediting nothing, then credited by the next pass; a zero rate, a rate with `errors`, a missing pair and a rate below the floor each crediting nothing (the floor logged as an error); one rate read per currency pair per pass for several due invoices, none when nothing is due, and one warning per pass while the rate source fails; an invoice made under `HighSpeed` with its payment `Settled`, the configuration then changed to `MediumSpeed`, still not credited; a cancel meeting money reading no rate, its money credited by the pass; a late `Settled` invoice (status `paid`, order `unpaid`) with the credit step not run, credited by the pass; the step refusing to credit an order that is live again; the alert's two reasons with their figures in units, one alert of two instances, and a second alert when more money arrives; a crash between the settlement's commit and the credit (the credit step not run) credited by the pass; the confirmed figure from a body with a non-zero `fee`, and from two `Settled` payments whose separate rounding would differ (rounded once, capped at `paymentMethodPaid`); an invoice-level `Settled` with one payment still `Processing` credited in full; a settled sum that falls (a payment turned `Invalid`) leaving the stored figure and the credit, and the same sum again crediting nothing; an invoice past `settleWindow`, not read by the pass, credited after a hinted read; a cancel meeting unconfirmed money crediting nothing, then once when the poller reads it `Settled`; `HighSpeed` configured: a late `Settled` payment not credited, pending and alerted; Stripe's succeeded amount confirmed, credited without a rate read; the decoders against the cited shapes (hand-written fixtures named as such until a regtest capture replaces them); `LiveOrderExists` at settlement closing the order, crediting and alerting; the same for a difference order, the source's hold released in the settlement's transaction and the order's own balance credited after it, by the credit step; a credit while the closed order's balance is held by a spend (the held spend settles, the credit stays); a covered `/spend` that read the amount before a credit answering `balance_changed`; credit, refund, late credit, refund, with `refunded` the sum; the cleanup keeping such an order with its balance; the credited balance spent by `/spend` from the closed order and refunded by `recordRefund`; settlement racing `recordRefund` (the refund refused while held, the balance spent after); the alert when the held spend does not apply (a hold removed by hand); `/name` from `taken` refused; a cheaper move crediting the difference, a cheaper move whose compare-and-set loses leaving no credit, and a dearer one still `price_too_high`; `fromBalance` unchanged by a move; a held poll woken by the `taken` write and not by a balance change. +- **Postgres** (two connections, many rounds, in the wave's Postgres build): two `creditLateInvoice` for one invoice started together (one credit row, the balance up once); `creditLateInvoice` against a `settleOrder` raising the confirmed figure (the credited units end equal to the confirmed units, nothing counted twice). Each pair also runs against a variant that reads `lateCreditState` before `lockInvoice`, which must fail, so the test proves the lock. - **Depends on:** T23, T18. #### T25. Spending a balance @@ -493,7 +499,7 @@ Manager rulings (2026-10-22): the refund popup writes to chat@simplex.chat (`CON - **`/name` no longer moves a taken order**: the balance spend replaces it, so one path buys another name with a taken order's money. `/name` from `failed` and after refused links stays. - **A move to a cheaper name keeps the difference as balance** instead of refunding nothing, as the owner's leftover rule does for a spend (manager's ruling, 2026-10-22). - **A taken order credits its whole `price`**, including any part from another balance, as a balance of its own: two balances, never merged. -- **A late payment credits only confirmed money** (BTCPay `Settled`, with at least one confirmation; Stripe succeeded), on `paymentMethodPaid`'s basis, capped at it, **at the lower of the invoice's rate and the current rate** (owner, 2026-10-22), each confirmed rise once, with the rates stored in `name_late_credits`. Reason: a 0-conf payment can be replaced or dropped, and a credit at a fixed rate was a free option on a falling price. The credit runs after settlement's commit and from the poller's pass, since the rate read cannot hold the invoice's lock. Pending money is logged; one alert per invoice once its payment row is past `settleWindow`. +- **A late payment credits only confirmed money** (BTCPay `Settled`, with at least one confirmation; Stripe succeeded), on `paymentMethodPaid`'s basis, capped at it, **at the lower of the invoice's rate and the current rate** (owner, 2026-10-22), each confirmed rise once, with the rates stored in `name_late_credits`. Reason: a 0-conf payment can be replaced or dropped, and a credit at a fixed rate was a free option on a falling price. The credit runs after settlement's commit and from the poller's pass, since the rate read cannot hold the invoice's lock. Crypto is integer atomic units, credits are numbered and unique per invoice, rounding is `floor`, a rate below half the invoice's is a glitch, `HighSpeed` is judged by the invoice's own policy, and the scans select by the order, not the invoice status. Pending money is logged; one alert per invoice (claimed by a conditional write), again only on new money, with distinct texts for unconfirmed money and a failing rate read. - **Balance changes leave `version` alone**: the order is final once `taken`, and the page reads the view for its balance. - **The view says a balance is held, not by whom** (`held`, `heldUntil`): the holder's id would give whoever holds the source's link the holder's capabilities. The page names the holder from its own records. - **`fromBalance` is fixed when the order is made**; a move does not change it.