From a047ec18012f0b188e33da3e82dcea84fb002d90 Mon Sep 17 00:00:00 2001 From: shum Date: Thu, 22 Oct 2026 09:55:52 +0000 Subject: [PATCH] plans: names late credit tasks after re-review --- .../2026-10-21-store-service-tasks.md | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 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 ee30c24f6d..b12642940b 100644 --- a/plans/names-codes/2026-10-21-store-service-tasks.md +++ b/plans/names-codes/2026-10-21-store-service-tasks.md @@ -301,12 +301,12 @@ The owner's decisions of 2026-10-22 remove the code for a taken order and keep a #### T23. Balances: storage, turns and the view - **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; `simplex-chat.cabal`; `tests/Test.hs`; `tests/Bots/StoreService/NamesStoreTests.hs`; new `tests/Bots/StoreService/NamesBalancesTests.hs`; `tests/Bots/StoreService/NamesOrdersTests.hs` (the view). +- **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). `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). 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. - **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, each with its invoice, method and the cents it paid); `creditLatePayment` (in the caller's transaction, after the invoice's lock: the cents the payment row reports received, 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; a result saying whether the payment row has no cents figure); `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` and `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_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). - **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,22 +314,24 @@ 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; `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`, and `spendHeldBalance` against `refundBalance`, each pair started together many times, exactly one applying each time and `amount` never negative. The safety argument rests on READ COMMITTED re-reading the row's `WHERE` after the lock. +- **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`. +- **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 line); `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`); `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`. - **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. - - **Late payments** (contract sections 1 and 8): every outcome that was `NOLate` (paid after the close, `NotLive`, `LiveOrderExists`, a provider close holding money, a new figure on a closed invoice) and `cancelOpenInvoice`'s late case run `creditLatePayment` in their own transaction, so the closed order's balance gains what arrived and a repeated or growing report credits only its increase. The operator alert becomes an info log naming the invoice, the order and the cents credited: the money is the buyer's balance, and support acts only when the buyer asks. A payment row with no cents figure credits nothing and keeps the alert (`logError`), for support to settle by hand. - - 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. The badge poller's refund alert is unchanged. + - **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. + - **`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. - `/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 growing over two reports (credited twice, the increase only), the same report twice (credited once), a provider close holding a part payment, a cancel meeting money, and a payment row without a cents figure (nothing credited, the alert kept); 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 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. - **Depends on:** T23, T18. #### T25. Spending a balance @@ -349,7 +351,7 @@ The owner's decisions of 2026-10-22 remove the code for a taken order and keep a - `//refund ` runs `recordRefund` and answers "Recorded a refund of $100.00 for order . Refund it now, by: ", or why nothing was recorded: no balance; the balance is $X now, not the cents given; "held by order , its invoice expired at