mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 19:27:55 +00:00
plans: names late credit tasks, third round
This commit is contained in:
1 parent
6f47428c2d
commit
f32753a1fd
1 file changed
+14
-11
@@ -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). Name invoices gain `late_credited` (cents of a late payment already credited from this invoice, default 0). Payments gain `confirmed_amount` (cents of confirmed money, default 0; T24 writes it, only rising). `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), `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.
|
||||
- **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_credited`); `creditLatePayment` (in the caller's transaction, after the invoice's lock: the payment row's `confirmed_amount` less the invoice's `late_credited`, added to the order's balance by `creditBalance`, and `late_credited` raised to it, conditional on its old value; nothing when the increase is not positive; it returns the cents credited, and whether money is seen but not confirmed: received money with `confirmed_amount` at or below `late_credited`); `uncreditedLateInvoices` (closed unpaid name invoices past a given time whose payment row holds money and whose `late_credited` is 0); `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` (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).
|
||||
- **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,24 +314,27 @@ 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` with nothing confirmed, with a confirmed rise, and repeated; `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); `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`.
|
||||
- **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`); `StoreService/Providers/BTCPay.hs` (each payment's `value` decoded, the settled sum); `StoreService/Providers/Stripe.hs` (the succeeded amount as confirmed); `StoreService/Store/Invoices.hs` (`confirmed_amount` in the payment upsert, only rising); the fake providers in the tests; `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`); `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.
|
||||
- **Confirmed money** (contract section 8): `Received` gains `rcvConfirmed`. BTCPay: the sum of the chosen method's payments with status `Settled`, each `value` times the method's rate, in cents. Stripe: the succeeded amount (`SigSettled` only), else 0. The payment upsert writes `confirmed_amount` with the larger of the old and new values, and `newPayment` counts a rise in it as a new payment even when the totals are unchanged, so an `Expired` invoice whose late payment confirms later reaches the late outcome again. Badge settlement ignores it.
|
||||
- **Late payments:** every outcome that was `NOLate` (paid after the close, `NotLive`, a provider close holding money, a new figure on a closed invoice) and `cancelOpenInvoice`'s late case run `creditLatePayment` inside the same transaction, beside `upsertPayment`, never after its commit (a crash between two commits would lose the credit: the same figure is not a new payment again). `NameOutcome`'s late case carries the cents credited out of the transaction for the log. A credit is an info log naming the invoice, the order and the cents: the money is the buyer's balance, and support acts only when the buyer asks. Money seen but not confirmed (a mempool payment, Monero confirming with a zero figure) credits nothing and is an info log saying it is pending.
|
||||
- **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.
|
||||
- **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. Its daily pass alerts (`logError`, once a day per invoice) for each of `uncreditedLateInvoices` past `settleWindow`: money seen that never confirmed within the poller's read window, for support to check at the provider. 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 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 <id> of order <id> holds money not all credited (<received>, <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.
|
||||
- `/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 by the daily pass once `settleWindow` has passed; Monero's zero figure while confirming logged as pending, not alerted; the BTCPay settled sum decoded from a recorded payment-methods body with one `Processing` and one `Settled` payment; Stripe's succeeded amount confirmed; the credit and the payment written in one transaction (a failure after the credit leaves neither); `LiveOrderExists` at settlement closing the order, crediting and alerting; 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 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.
|
||||
- **Depends on:** T23, T18.
|
||||
|
||||
#### T25. Spending a balance
|
||||
@@ -345,7 +348,7 @@ The owner's decisions of 2026-10-22 remove the code for a taken order and keep a
|
||||
#### T26. The operator's refund
|
||||
|
||||
- **Goal:** support records a refund, which zeroes a balance (contract section 8, Balances).
|
||||
- **Files:** `StoreService/Service.hs` (the `//refund` parser, its run and replies, the usage line); `apps/simplex-store-service/README.md` (the operator commands, `//refund` with support's steps); `tests/Bots/StoreService/GroupIntegrationTests.hs` (the operator command specs); `tests/Bots/StoreService/BotTests.hs` (`badgeCmdUsage`).
|
||||
- **Files:** `StoreService/Service.hs` (the `//refund` parser, its run and replies, the usage line); `apps/simplex-store-service/README.md` (the operator commands, `//refund` with support's steps, and the `[btcpay] speed_policy` warning: no `HighSpeed` when names are sold, as late crypto then never credits); `tests/Bots/StoreService/GroupIntegrationTests.hs` (the operator command specs); `tests/Bots/StoreService/BotTests.hs` (`badgeCmdUsage`).
|
||||
- **Do:**
|
||||
- `//refund <reference>` answers the balance and the command that records it: "Order <id>: balance $100.00 (credited $100.00, refunded $0.00). Paid by: <the chain>. To record its refund: //refund <reference> 10000". The chain lists, from this order back along `balance_source` to the first, each order's invoice, method and cents, a late payment's cents marked as such. A held balance names its holder and its invoice's expiry; an order without a balance says so.
|
||||
- `//refund <reference> <cents>` runs `recordRefund` and answers "Recorded a refund of $100.00 for order <id>. Refund it now, by: <the chain>", or why nothing was recorded: no balance; the balance is $X now, not the cents given; "held by order <id>, its invoice expired at <time>; the poller closes it within <grace>; run the command again then" (or "open until <time>" for a holder not yet overdue).
|
||||
@@ -425,7 +428,7 @@ In simplex.domains, branch `sh/names-webapp`, in the webapp's workstream beside
|
||||
|
||||
- **Goal:** the mock serves the contract's balances and `/spend`; `src/api.ts` and `src/flow.ts` use them.
|
||||
- **Files:** `dev/mock/service.ts`; `dev/mock.ts`; `src/api.ts`; `src/flow.ts`; `src/store.ts`; `test/api.test.ts`; `test/flow.test.ts`; the mock's tests.
|
||||
- **Do (mock):** a balance per order (`amount`, `credited`, `refunded`, the holder); credited with `price` when a paid order is taken (`/control/take`), and with the difference on a cheaper `retarget`; `taken` not live and not movable; `spend` in the contract's order of checks, with `balance_changed`; settlement (`/control/settle`) queues the order and spends its hold; a cancel, an expiry (`/control/expire`, `/control/overdue`) and an overdue order closed by a next order or a spend release it; a hold by an overdue order reads free; `POST /control/refund/:reference`, with optional `cents`, answering as `recordRefund`; the view's `balance` (`amount`, `held`, `heldUntil`, `refunded`) and `fromBalance`; a refund of a held balance refused; a late payment (`/control/settle` on a closed invoice, and on an expired one with a part payment) credited to that order's balance, each confirmed figure once, and a `/control/partial` or `/control/confirming` on a closed invoice crediting nothing until settled; `find`'s `invoiceId` null for an order paid wholly from a balance, and older orders with a balance after the 50 newest.
|
||||
- **Do (mock):** a balance per order (`amount`, `credited`, `refunded`, the holder); credited with `price` when a paid order is taken (`/control/take`), and with the difference on a cheaper `retarget`; `taken` not live and not movable; `spend` in the contract's order of checks, with `balance_changed`; settlement (`/control/settle`) queues the order and spends its hold; a cancel, an expiry (`/control/expire`, `/control/overdue`) and an overdue order closed by a next order or a spend release it; a hold by an overdue order reads free; `POST /control/refund/:reference`, with optional `cents`, answering as `recordRefund`; the view's `balance` (`amount`, `held`, `heldUntil`, `refunded`) and `fromBalance`; a refund of a held balance refused; a late payment (`/control/settle` on a closed invoice, and on an expired one with a part payment) credited to that order's balance, each confirmed figure once, at the lower of the invoice's rate and a current rate the tests set (`/control/rate`), and a `/control/partial` or `/control/confirming` on a closed invoice crediting nothing until settled; `find`'s `invoiceId` null for an order paid wholly from a balance, and older orders with a balance after the 50 newest.
|
||||
- **Do (client):** `RegistrationView` gains `balance` and `fromBalance`; `api.spend`; `flow.spendBalance(record, label, links, quote, method)` builds the order with the source record's owner code (the same owner, `sealed` under it), signs `simplex.domains spend …`, and keeps the new order's record with that owner code, `method` (`"balance"` when the balance covers it) and `balanceSource` (the paying order's id, so the page can name a balance's holder); `OrderRecord.method` gains `"balance"`; `paid` is the registration's `price`, not the invoice `amount`, for every order (`paidWith` today takes `amount`, which for a difference invoice is the difference only); `balance_changed` gives the current balance to the caller.
|
||||
- **Tests:** the mock's `spend` in each answer and refusal of T25's list; settlement and every close of a hold; a refund; `spendBalance`'s message and body against a vector from a fixed key, the same vector T25's test pastes.
|
||||
- **Depends on:** W1f-1.
|
||||
@@ -490,7 +493,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 payments `Settled`, Stripe succeeded), in cents at the invoice's rate, each rise once (`confirmed_amount` per payment, `late_credited` per invoice), inside settlement's own transaction. Reason: a 0-conf payment can be replaced or dropped, and a credit spent by a covered `/spend` registers a name at once. Money seen but unconfirmed is logged as pending; only an invoice still holding money with nothing credited after `settleWindow` alerts.
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
Reference in new issue
Block a user