plans: names balance tasks after review

This commit is contained in:
shum committed 2026-10-22 09:38:50 +00:00
1 parent d8f4ff5a73
commit ff15b910d5
1 file changed
+39 -32
@@ -292,40 +292,42 @@ The owner's decisions of 2026-10-22 remove the code for a taken order and keep a
- **Files:** `StoreService/Web/Names/Owner.hs`; `StoreService/Web/Names.hs` (its route line); `StoreService/Names/Orders.hs`; `StoreService/Web/Names/Orders.hs` (the refusals' responses); `StoreService/Names/Signatures.hs`; `StoreService/Names/Contracts.hs`; `StoreService/Names/Abi.hs`; `StoreService/Names/Store.hs`; `StoreService/Names/View.hs`; the two migration modules (migration `20261022_drop_taken_codes`); `tests/BadgeTests.hs`; `tests/Bots/StoreService/` `NamesOwnerTests.hs`, `NamesOrdersTests.hs`, `NamesStoreTests.hs`, `NamesContractsTests.hs`, `NamesSignatureTests.hs`, `NamesWiringTests.hs`.
- **Do:**
- Delete `takenCodeHandler`, `TakenCodeRequest`, `parseTakenCode` and the route; `issuable`, `issuedCodeHash`, `checkTakenCodeLength`, `issueTakenCode`, `TakenCodeConflict`; the refusals `ORCodeLengthChanged`, `ORCodeUnavailable`, `ORCodeConflict`, and `ORProviderUnavailable` once nothing else uses it, with their lines in `orderRefusalResponse`; `takenCodeMessage`; `codeLengthFor`, and `decodePrices`, its only caller's decoder; `PCodeIssued`, `nrIssuedCodeId`, `setIssuedCode`; the view's `codeLength`, `vrCodeLength` and `readViewChain`'s `taken` and `code_issued` branches, so `unread` names only `intentNonce`.
- Migration `20261022_drop_taken_codes`, SQLite and Postgres: `DROP INDEX` of `idx_name_registrations_issued_code`, then `ALTER TABLE … DROP COLUMN issued_code_id`; the down migration adds both back. SQLite 3.45 drops a column with a `REFERENCES` clause once its index is gone (checked 2026-10-22 with the `sqlite3` CLI); the migration test runs it on the bundled SQLite.
- Migration `20261022_drop_taken_codes`, SQLite and Postgres: first `UPDATE … SET phase = 'taken', price = 0, live = 0 WHERE phase = 'code_issued'` (its worth went into the issued code, which stays a valid prepaid code, so it gets no balance in T23 and every row still decodes); then `DROP INDEX` of `idx_name_registrations_issued_code`, then `ALTER TABLE … DROP COLUMN issued_code_id`. The down migration adds the column and index back; converted rows stay `taken`. No database holds names rows yet; the step keeps the migration correct anyway. SQLite has `DROP COLUMN` from 3.35: checked with the `sqlite3` CLI (3.45.1, `foreign_keys` on) for a column with a `REFERENCES` clause once its index is gone; the bundled SQLCipher is SQLite 3.39.2, and the migration test runs it there.
- `codeConflict` in `Web/Http.hs` stays: badge invoices answer it. `/name` from `taken` stays until T24.
- **Acceptance:** `rg -n 'code_issued|PCodeIssued|takenCode|TakenCode|issued_code_id|IssuedCode|codeLengthFor|decodePrices|"codeLength"|code_length_changed|code_unavailable' apps/simplex-store-service/src src tests` finds nothing; `POST /api/registration/<id>/code` answers `404 not_found`; the badge specs green, their `code_conflict` included.
- **Tests:** deleted with the code: the "a code instead" block, the `/code` half of the polls test, the `codeLengthFor` and `decodePrices` vectors, `takenCodeMessage`'s vector, the taken-order code vectors in `BadgeTests`. Changed: the live table without `code_issued`; the view's field list and `readViewChain`'s cases without `codeLength`; the store's column list; migration up and down with `20261022_drop_taken_codes`, on SQLite and Postgres; the wiring test's `404` for `/code`.
- **Tests:** deleted with the code: the "a code instead" block, the `/code` half of the polls test, the `codeLengthFor` and `decodePrices` vectors, `takenCodeMessage`'s vector, the taken-order code vectors in `BadgeTests`. Changed: the live table without `code_issued`; the view's field list and `readViewChain`'s cases without `codeLength`; the store's column list; migration up and down with `20261022_drop_taken_codes`, on SQLite and Postgres, with a `code_issued` row before it read back as `taken`, price 0, not live; the wiring test's `404` for `/code`.
- **Depends on:** T15 (merged). It runs in wave 8 beside T18, which owns none of its files; both add lines to `Web/Names.hs` and `NamesOwnerTests.hs`, which the integrator merges.
#### 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, and `placeOrder` able to close a balance's overdue holder. Nothing credits, holds or spends yet.
- **Files:** the two migration modules (migration `20261023_name_balances`); `StoreService/Names/Store.hs`; `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).
- **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).
- **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). Up and down, SQLite and Postgres.
- **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 "".
- **Queries**, each one conditional statement reporting whether it applied: `getBalance` (a hold by an order whose invoice is past its expiry with no money received reads as free, by the sweep's `unfundedQuery`); `overdueHolder`; `balanceByReference` (a registration id, or a name invoice id); `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` with 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).
- **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). `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); `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).
- **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.
- **`Names/Balances.hs`:** `freeOverdueHold` (in the caller's transaction, closes the balance's overdue holder by `closeOverdue` and returns it as a `ClosedOrder`); `recordRefund :: Turns -> DBStore -> Text -> Int -> UTCTime -> IO (Either RefundRefusal RefundRecord)` (in the balance's turn and one transaction: the reference resolved, an overdue hold freed, then `refundBalance`; the record names the order, the cents, and what paid for it: the order's invoice and method, and the source of a part paid from a balance).
- **`placeOrder`** closes, besides the overdue orders of the same wallet and name, the extra overdue orders its caller names (a balance's holder) in its transaction, and returns them all for `announceClosed`.
- **The view:** `balance` and `fromBalance` (contract section 6), read with `getBalance` in `registrationView`; `find`'s `invoiceId` null for `RPBalance`.
- **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 balance alone.
- **`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`.
- **`placeOrder`** closes, besides the overdue orders of the same wallet and name, the extra orders its caller names (a balance's holder) in its transaction, and returns them all for `announceClosed`. A named holder no longer live is skipped (the caller's conditional hold or spend then decides); one still live that `closeOverdue` cannot close refuses with `ORBalanceChanged`.
- **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; 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 and a refund; a hold by an overdue holder read as free and freed by `freeOverdueHold` (its invoice `expired`, its registration not live, the hold gone, all in the caller's transaction); a hold and a spend read back through a new store connection (a restart); `balanceByReference` by either id; the `name-balance` `order_hash` changing with each field and not with the timestamp or the signature; `recordRefund`'s refusals (no balance, another amount, held) and its record; `withBalanceTurn` running two holders of one balance one after the other, and nested in `withLabelTurn` without deadlock; the view's `balance` and `fromBalance`.
- **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.
- **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 difference invoice spends its hold when paid and releases it when it closes; `/name` no longer moves a taken order, and a move to a cheaper name keeps the difference.
- **Files:** `StoreService/Names/Worker.hs`; `StoreService/Orders.hs`; `StoreService/Names/Store.hs`; `StoreService/Names/Orders.hs`; `StoreService/Web/Names/Owner.hs`; `tests/Bots/StoreService/` `NamesWorkerTests.hs`, `NamesOrdersTests.hs`, `NamesOwnerTests.hs`, `NamesStoreTests.hs`, `NamesPhasesTests.hs`.
- **Goal:** a paid order that ends `taken` keeps its worth as a balance; 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); `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.
- Every close of an unpaid order releases its hold in the transaction that clears `live`: `closeOrder` (settlement's expiry, cancel, the poller's sweep) and `closeOverdue` (a next order, `/spend`, `recordRefund`). A late payment leaves the balance alone.
- `/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`; its invoice cancelled, expired by the sweep, expired at settlement's read, and closed as overdue by a next order, each releasing the hold; a late payment after the close leaving the balance and alerting; 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, and a dearer one still `price_too_high`; 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 leaving the balance and alerting; 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
@@ -333,7 +335,7 @@ The owner's decisions of 2026-10-22 remove the code for a taken order and keep a
- **Goal:** `POST /api/registration/:id/spend`.
- **Files:** `StoreService/Web/Names/Orders.hs` (the handler; `createAtProvider` takes the invoice's amount and the insert, shared with `/api/invoice`; the resend answer for `RPBalance`); `StoreService/Names/Balances.hs` (the balance check and the order's placement); `StoreService/Names/Signatures.hs` (`spendMessage`); `StoreService/Web/Names.hs` (its route); new `tests/Bots/StoreService/NamesSpendTests.hs`; `tests/Bots/StoreService/NamesWiringTests.hs` (the route's `405` with `Allow`); `tests/Test.hs`; `simplex-chat.cabal`.
- **Do:** the contract's `/spend` in its order of checks, under the new name's label turn and then the balance's turn: the order fields by `parseOrderFields`; existing orders by `existingOrder` with the `name-balance` hash; the balance's overdue holder closed with the wallet's overdue orders in `placeOrder`'s transaction; a covered price by `createBalanceRegistration`, a difference by the provider and then `createPaidRegistration` with its hold; a write refused at the balance answering `balance_changed`, with the provider's invoice cancelled as for an order that lost; answers after the turn (`Answer`), publishing closed orders and cancelling their invoices at the provider.
- **Tests:** a covered spend (`queued`, `fromBalance` the price, the source's `amount` less the price) and an exact one (`amount` 0); a difference spend (the invoice view with `amount` the difference, the registration `unpaid` with `fromBalance` the balance, the source's `reservedBy` its id); an exact resend in `unpaid` and in later phases (moved there by T23's store writes), with a new timestamp and signature, after the balance changed; `balance_changed` for a stale `balance`, a held balance, and an order with none (`balance: null`); a `method` with a covered price, and none with a difference, `bad_request`; another signer, a stale timestamp, an unknown id `404`; `order_exists`; `name_taken`; `catalog_changed`; `name_check_unavailable`; `provider_unavailable`; a balance held by an overdue order spent (that order closed, its invoice `expired`, published, cancelled at the provider); two concurrent spends of one balance for two names, one winning and the other `balance_changed` with its provider invoice cancelled; a spend racing `recordRefund`, one winning; the create limit; the `sealed` and body caps.
- **Tests:** a covered spend (`queued`, `fromBalance` the price, the source's `amount` less the price) and an exact one (`amount` 0); a difference spend (the invoice view with `amount` the difference, the registration `unpaid` with `fromBalance` the balance, the source's balance `held` with `heldUntil` the invoice's expiry); an exact resend of a difference spend in `unpaid` (found by its `name-balance` hash, not met as `order_exists`) and in later phases (moved there by T23's store writes), with a new timestamp and signature, after the balance changed; an `/api/invoice` body with the same order fields not matching it; `balance_changed` for a stale `balance`, a held balance, and an order with none (`balance: null`); a `method` with a covered price, and none with a difference, `bad_request`; another signer, a stale timestamp, an unknown id `404`; `order_exists`; `name_taken`; `catalog_changed`; `name_check_unavailable`; `provider_unavailable`; a balance held by an overdue order spent (that order closed, its invoice `expired`, published, cancelled at the provider); the holder closed meanwhile by a cancel (the spend goes through) and funded meanwhile (`balance_changed`, the provider invoice cancelled); two concurrent spends of one balance for two names, one winning and the other `balance_changed`; the write refused at the balance after the provider invoice was made, by a store write between check 6 and the transaction through a second `DBStore` (`balance_changed`, the provider invoice cancelled); a spend racing `recordRefund`, one winning; the create limit; the `sealed` and body caps.
- **Depends on:** T23. Its paid path is end to end only with T24's settlement: the wave-10 integrator adds that spec (below).
#### T26. The operator's refund
@@ -341,11 +343,11 @@ The owner's decisions of 2026-10-22 remove the code for a taken order and keep a
- **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`).
- **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 btc, invoice <invoiceId>. To record its refund: //refund <reference> 10000". A held balance names its holder, to wait for its invoice to close; 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: <what paid for it>", or why nothing was recorded: no balance; the balance is $X now, not the cents given; held by <id>.
- `//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 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).
- The reference is an invoice id or a registration id, as the page shows it. The command is the service console's `//` only, as `//revoke` of a prepaid code is: it takes the balance's turn, which the group lane does not hold. A second process on the same database (the CLI mode) is kept safe by the conditional write, not the turn.
- Each recorded refund is logged with its reference and cents.
- **Tests:** a refund shown, recorded, then shown as refunded; each refusal; a held balance refused, then recorded once its holder's invoice is cancelled; an overdue holder freed by the refund; a reference by invoice id and by registration id; the usage line.
- **Tests:** a refund shown, recorded, then shown as refunded; each refusal; a held balance refused, then recorded once its holder's invoice is cancelled (T23's release in `closeOrder`); an overdue holder refused with its expiry, then recorded once the sweep closed it; a chain of three orders in the reply; a reference by invoice id and by registration id; the usage line.
- **Depends on:** T23.
### Stages 6 and 7: the service side
@@ -380,11 +382,11 @@ Each wave starts from `sh/store-service` as merged at the end of the last one. "
| 6 | T14, T15, T20 | T14: `Web/Names/Registration.hs`. T15: `Names/Orders.hs`. T20: `Web/Server.hs`, `README.md` |
| 7 | T17 | `Service.hs`, `Names/Worker.hs`, `FakeEthNode.hs` |
| 8 | T18, T22 | T18: `Web/Names/Registration.hs`, `Names/Worker.hs`. T22: `Names/Orders.hs`, `Names/Store.hs`, the migrations, `Names/View.hs`, `Names/Signatures.hs`, `Web/Names/Orders.hs`, `Web/Names/Owner.hs` |
| 9 | T23 | `Names/Store.hs`, the migrations, `Names/Turns.hs`, `Names/Balances.hs`, `Names/View.hs`, `Names/Orders.hs`, and, for `RPBalance`'s case only, `Names/Worker.hs`, `Web/Names/Orders.hs`, `Web/Names/Owner.hs` |
| 9 | T23 | `Names/Store.hs`, the migrations, `Orders.hs`, `Names/Turns.hs`, `Names/Balances.hs`, `Names/View.hs`, `Names/Orders.hs`, and, for `RPBalance`'s case and `balance_changed`'s response only, `Names/Worker.hs`, `Web/Names/Orders.hs`, `Web/Names/Owner.hs` |
| 10 | T24, T25, T26 | T24: `Names/Worker.hs`, `Orders.hs`, `Names/Store.hs`, `Names/Orders.hs`, `Web/Names/Owner.hs`. T25: `Web/Names/Orders.hs`, `Names/Balances.hs`, `Names/Signatures.hs`. T26: `Service.hs`, `README.md` |
| 11 | T21 | `README.md` |
The wave-10 integrator, after merging T24, T25 and T26, adds one end-to-end spec to `NamesSpendTests.hs`: a difference spend paid at `FakeBTCPay` and settled (the order `queued`, the hold spent); the same cancelled (the hold released); a covered spend's order ending `taken` (a balance of its own, its whole `price`); and a `//refund` of the rest.
The wave-10 integrator, after merging T24, T25 and T26, adds one end-to-end spec to `NamesSpendTests.hs`: a difference spend paid at `FakeBTCPay` and settled (the order `queued`, the hold spent); the same cancelled (the hold released); a covered spend's order ending `taken` (a balance of its own, its whole `price`); and a `//refund` of the rest. Wave 10 has no other dependency between its tasks.
Each task is one or more atomic commits (`<scope>: <subject>`, no body) on its own branch. It ends with the build and its `-m "StoreService names: <part>"` tests green in its worktree, and the badge specs green. The manager merges the wave's branches one at a time and runs the build and all `StoreService` specs after each merge.
@@ -405,8 +407,8 @@ 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` and `fromBalance`; `find`'s `invoiceId` null for an order paid wholly from a balance.
- **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 and `method` (`"balance"` when the balance covers it); `OrderRecord.method` gains `"balance"`; `balance_changed` gives the current balance to the caller.
- **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; `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.
@@ -415,13 +417,13 @@ In simplex.domains, branch `sh/names-webapp`, in the webapp's workstream beside
- **Goal:** the buyer sees and uses a balance in Your names, the search, Check your order, the taken page and the payment page.
- **Files:** `src/views/names.ts`, `search.ts`, `checkout.ts`, `order.ts`, `shell.ts` (the support popup); `src/screens/names.ts`, `search.ts`, `checkout.ts`, `order.ts`; their unit tests; `e2e/taken.spec.ts`; new `e2e/balance.spec.ts`.
- **Do:**
- **Your names:** an order whose view has a `balance` with an `amount` shows "Balance $X" with **Use** (the search, `?balance=<registrationId>`) and **Refund** (a popup: "Write to <support address> with the reference <id>; support refunds it by hand", the reference being the order's invoice id, else its registration id); a held balance says which order holds it and links to it; a refunded one "Refunded $X by support". An order paid from a balance says so in its line.
- **Your names:** an order whose view has a `balance` with an `amount` shows "Balance $X" with **Use** (the search, `?balance=<registrationId>`) and **Refund** (a popup: "Write to chat@simplex.chat with the reference <id>; support refunds it by hand", the reference being the order's invoice id, else its registration id); a held balance says it is in use by an open invoice until `heldUntil`, and links the holder when this browser's records have it (`balanceSource`); a refunded one "Refunded $X by support". An order paid from a balance says so in its line.
- **The search with a balance:** "Your balance: $X" in place of the retarget alert; an available name says "Covered by your balance; $Y stays" or "Pay $Z more".
- **Check your order** with a balance: the price, "Balance −$X", then either "Nothing to pay" with Register, or "Pay $Z more" with the payment methods; the order goes out by `flow.spendBalance`; on `balance_changed` the page reads the balance again and says what changed.
- **The taken page:** "Someone registered it first. Your payment of $X is kept as a balance: use it for another name, paying only the difference, or ask support for a refund." with Use and Refund; Choose another name leaves it.
- **The payment page** of a difference invoice: "$X from your balance; this invoice is the rest, $Z."
- A failed order keeps Choose another name; its cheaper-name line says the rest stays as balance.
- The support address is `CONTACT_EMAIL` (`src/views/shell.ts`), as on every support line.
- A failed order keeps Choose another name; its cheaper-name line says the rest stays as balance, from the view's `price` and `balance`, not the record's `paid`.
- The support address is `CONTACT_EMAIL`, chat@simplex.chat (`src/views/shell.ts`), as on every support line (manager's ruling, 2026-10-22).
- **Acceptance:** the flows against the mock in Chromium, Firefox (158 included) and WebKit, with motion and no flashes; Lighthouse as before.
- **Tests:** unit tests of each state above; e2e: a paid order taken, then its balance spent on a cheaper name (covered, the rest kept) and on a dearer one (the difference paid by card in the mock, the name registered, the balance 0); a difference invoice cancelled, the balance free again; a refund by `/control/refund`, shown as refunded; the name code restored in another browser showing the balance.
- **Depends on:** W1f-2.
@@ -445,11 +447,13 @@ Still open and not backend: the dApp host, and the app's menu path in "Use in th
1. **No code when a paid name is taken.** `/code`, `code_issued`, the view's `codeLength` and the page's Take a code go, though T15 built them (ep had asked to keep them; the owner overrules). T22 removes them.
2. **A balance per order.** A taken paid order's payment becomes a balance owned by that order's name code, never merged across orders or wallets. Your names shows it; buying another name with it pays only the difference by card, BTC or XMR, or nothing when it covers the price.
3. **Leftover** stays as balance, usable again, or the buyer asks support for a refund by hand (a popup: write to the support address with the reference).
3. **Leftover** stays as balance, usable again, or the buyer asks support for a refund by hand (a popup: write to chat@simplex.chat with the reference).
4. **Refunds** by support on request only: no automatic refund, no refund address screen. The operator records one with `//refund` (T26).
5. **A balance never expires.**
6. **Prepaid codes are unchanged:** a code order that ends taken releases its code and gets no balance.
Manager rulings (2026-10-22): the refund popup writes to chat@simplex.chat (`CONTACT_EMAIL`); a move to a cheaper name credits the difference to the order's balance. Open with the owner: whether a late payment of a closed name invoice (a difference invoice included) is credited to that order's balance instead of refunded by support; until answered, support refunds it, as today.
## Decided here
- **Label rule:** simplexmq's `SimplexDomain` parser on `label.tld`, not #7390's `validLabel`, which is unmerged and refuses `--` only at positions 3–4. One rule with the relays.
@@ -461,17 +465,20 @@ Still open and not backend: the dApp host, and the app's menu path in "Use in th
- **A balance is its own table, keyed by the order it belongs to**, not columns of the registration: other orders' spends, settlement and the operator write it, and none of them may bump that registration's `version` or lock its row out of order. Rows lock invoice, registration, then balance.
- **US cents**, as every price and invoice amount; refunds are of that amount, paid back as support decides.
- **A spend is a new order**, `POST /api/registration/:id/spend`, signed by the balance's owner, for a new order of that owner: the same name code owns the new name, as `/name` keeps the owner. It is not an `/api/invoice` field, since a covered spend has no invoice, and not `/name`, which moves an order instead of paying for one.
- **A hold, not a deduction, while the difference invoice is open**: only one order's open invoice can hold a balance (a unique `reserved_by`), settlement spends it, and every close releases it in the transaction that closes the order. A hold by an overdue order reads free and is closed by the next spend or refund, as section 1 closes an overdue order.
- **A hold, not a deduction, while the difference invoice is open**: only one order's open invoice can hold a balance (a unique `reserved_by`), settlement spends it, and every close releases it in the transaction that closes the order. A hold by an overdue order reads free and is closed by the next spend, as section 1 closes an overdue order; a refund refuses any held balance and waits for the poller, since the console cannot announce a close or cancel an invoice at the provider.
- **The page states the balance it saw** (`balance` in the body), so the buyer never pays a difference other than the one shown; `balance_changed` carries the current balance.
- **`/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 (owner to confirm; dropping it removes one step of T24).
- **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.
- **Balance changes leave `version` alone**: the order is final once `taken`, and the page reads the view for its balance.
- **Refunds by the `//refund` console command only**, as `//revoke` of a prepaid code: it takes the balance's turn, which the group lane does not hold. It names the reference the page shows (invoice id, else registration id), shows the balance first, and records only the whole amount given.
- **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.
- **`find` returns older orders with a balance** after the 50 newest, since a balance never expires.
- **Refunds by the `//refund` console command only**, as `//revoke` of a prepaid code: it takes the balance's turn, which the group lane does not hold. It names the reference the page shows (invoice id, else registration id), shows the balance and every invoice that funded it back along the chain of balances, and records only the whole amount given.
- **A revoked code** answers `state: revoked`; a `reserved` code names no registration.
- **`find` returns `labelHash` and `sealed`**, the 50 newest, so a name code's verdict names its orders in one request.
- **The intent is signed at the reveal** (gap 34): the mock and the mockup's copy ("keep this page open, or reopen its link within 24 hours") assume it, and the service learns no name before the commit.
- **`/name` from `failed`, `taken`, or a `committed` order whose reveal was refused for its links** (manager's ruling), at most 3 moves, so refused links can be corrected without support and no order can drive endless commits.
- **`/name` from `failed`, or a `committed` order whose reveal was refused for its links** (manager's ruling; `taken` removed 2026-10-22), at most 3 moves, so refused links can be corrected without support and no order can drive endless commits.
- **Alain's prototype differences** (gaps 32, 33): the derived secret and `revealBefore` as the deadline stay; agreeing them with Alain is coordination, not an owner question.
- **One short link per key** (gap 35), as the mock, the redesign and the app's `firstNameLink` already do.
- **Limits** (gap 29): the values in the contract's section 1, every request counted, per-minute windows so `Limit` needs no change, `maxBuckets` 32768, an edit cap of 10 relayed edits an hour per name, `revealMargin` 900 seconds.