store: merge the refund command

This commit is contained in:
shum committed 2026-10-22 16:51:53 +00:00
commit cc9b8b90f7
7 files changed
+317 -24

No files matched your search

+67 -2
View File
@@ -128,6 +128,15 @@ The BTCPay API key needs four permissions, each scoped to the one store:
`canmodifyinvoices` so `POST /api/invoice/:id/cancel` can invalidate an invoice at BTCPay
rather than only in this store.
**Do not set `speed_policy = HighSpeed` when names are sold** (`[names]` configured). Under it
BTCPay can mark a payment `Settled` with no confirmation, so the service never credits crypto that
reaches a name invoice after the invoice closed: the money stays pending, the poller alerts about it
once it is older than its read window (about 72 hours), and support settles it by hand. The service
judges each invoice by the policy it was created with, so changing the setting later credits nothing
that arrived under `HighSpeed`. With `MediumSpeed` (the template's value) or a slower policy, late
money is credited to the order's balance once confirmed, at the lower of the invoice's rate and the
rate when it is credited.
Give the service a BTCPay store of its own, and a Stripe account of its own if card payments are
on. The poller lists every invoice and payment intent of the last three days or so, and one it did
not create and cannot read is reported in a warning once an hour until it falls out of that window.
@@ -766,7 +775,8 @@ were already given out, and the command answers with an error. A multi-use code
be revoked, which stops the uses that remain.
Core parses `//...` into `CustomChatCommand` and leaves it to the service's `preCmdHook`, which is
why issuing codes lives in the service rather than in core.
why issuing codes lives in the service rather than in core. `//refund`, the other command of this
console, is in [Refunding a name balance](#refunding-a-name-balance).
### From the group
@@ -776,7 +786,8 @@ and above, `/revoke <code>` for admins and owners. `months` is 1 to 255, `uses`
`count` 1 to 100; a value outside these gets the usage reply. The name forms are
`/issue name <length> [years <Y>]` and `/bulk name <length> [years <Y>] count <B>`, with the
bounds of `//issue name`; a name code issued in the group is single-use and `free`. `/revoke`
takes badge codes only: withdraw a name code with `//revoke` in `--run-cli` mode. A member's role
takes badge codes only: withdraw a name code with `//revoke` in `--run-cli` mode. The group has no
`/refund`. A member's role
is checked as the service last saw it, so a command sent by a moderator just demoted or removed can still run if it
reaches the service first; revoke any code the service posts for them after the change. `uses`
above 1 makes a multi-use code, tracked by a group message showing its remaining uses and the time
@@ -835,3 +846,57 @@ join link still works and its messages hold every code posted there.
The configured `display_name` and `description` apply only to the group the service creates. Editing
them later is logged as not applied and changes nothing.
## Refunding a name balance
A paid name order keeps its money as a balance when it ends `taken` (another buyer registered the
name first), when it moves to a cheaper name (the difference), and when a payment reaches its invoice
after the invoice closed. The buyer spends a balance on another name from Your names, or asks for it
back: the page tells them to write to chat@simplex.chat with the order's reference, which is its
invoice id, or its registration id for an order without an invoice. Nothing is refunded
automatically, and the service never learns where to send money. Support records each refund in
`--run-cli` mode with the service stopped, like the other `//` commands:
```
//refund <reference>
//refund <reference> <cents>
```
1. Run `//refund <reference>` with the reference the buyer sent. It records nothing and answers the
balance, what paid for it and the command that refunds it:
```
Order <id>: balance $100.00 (credited $100.00, refunded $0.00). Paid by: order <id>: $100.00 by btc on invoice <invoice id>. To record its refund: //refund <reference> 10000
```
`Paid by` starts at this order and follows every balance the money came through back to the
first order: each order with what its invoice paid and by which method (`card`, `btc` or
`xmr`), the part paid from the balance of the next order listed, and, marked `late`, money
credited after its invoice closed.
2. Run the command the answer ends with. The cents must equal the whole balance: a refund zeroes it,
and part of a balance is never refunded. Other cents record nothing, and the answer gives the
balance.
3. The answer, `Recorded a refund of $100.00 for order <id>. Refund it now, by: …`, repeats the
chain. Pay the balance's amount in US dollars back now, by the methods it lists. The refund is
recorded first so that the buyer cannot spend the money while it is paid
back. The service logs each recorded refund at info with the order's registration id and the
cents.
A balance that an open order holds is never refunded: that order is buying another name with it and
its invoice is waiting for the difference. The command names the order and its invoice's expiry
instead:
- `its invoice open until <time>`: if the buyer pays it, the balance is spent; if not, it is released
once the invoice expires and the poller, in service mode, closes the order. Run the command again
then.
- `its invoice expired at <time>; start the service so the poller closes that order, 10 minutes
after the expiry at the earliest, then stop it and run //refund again`: the poller runs only in
service mode. Its first pass from 10 minutes after the expiry closes the order and releases the
hold; a card order closes only once Stripe accepts the cancel.
- `whose invoice expired at <time> holding money`: a payment reached the invoice; the hold stays
until that payment settles, which spends the balance, or the provider closes the invoice, which
releases it.
The command also answers when the reference matches no order, when the order has no balance, and
when the cents are not the balance. A refunded balance shows `amount` 0 and its `refunded` total in
Your names; a later late payment credits it again, and a second refund adds to `refunded`.
@@ -19,6 +19,7 @@ module StoreService.Group
TrackerAction (..),
trackerDecision,
codeInTracker,
fmtTime,
noOwnerHint,
orphanHint,
)
@@ -21,17 +21,20 @@ where
import StoreService.Catalog (defaultCatalog)
import StoreService.Codes (issueFailedText, issueOneCode, singleUse)
import StoreService.Config (BadgeIssuerKey (..), ChainConfig (..), NamesConfig (..), ServiceConfig (..), minNameYears, readServiceConfig)
import StoreService.Group (GroupEvent, ensureManagedGroup, groupEvent, hasTracker, refreshTracker, revokeWithTracker, runGroupLane)
import StoreService.Group (GroupEvent, ensureManagedGroup, fmtTime, groupEvent, hasTracker, refreshTracker, revokeWithTracker, runGroupLane)
import StoreService.Group.Command (badgeTypeP, codeP, maxMonths, maxNameYears, nameCodeP, textTokenP)
import StoreService.Names.Balances (RefundRecord (..), RefundRefusal (..), recordRefund)
import StoreService.Names.Codes (issueNameCode, revokeNameCode)
import StoreService.Names.Env (NamesEnv (..), newNamesEnv)
import StoreService.Names.Orders (ordersEnv)
import StoreService.Names.Phases (RegistrationId (..))
import StoreService.Names.Records (runRecordEdits)
import StoreService.Names.Sender (runSender)
import StoreService.Names.Store (Balance (..), BalanceHold (..), BalanceLink (..), LateCredit (..), RegistrationPayment (..), balanceByReference, balanceChain, methodText)
import StoreService.Names.Turns (Turns, newTurns)
import StoreService.Names.Worker (newWorker, runCodeSweep, runWorkers)
import StoreService.Options
import StoreService.Poller (newPollerEnv, newReadHints, runPoller)
import StoreService.Poller (expiryGrace, newPollerEnv, newReadHints, runPoller)
import StoreService.Providers (Provider)
import StoreService.Providers.BTCPay (btcpayProvider)
import StoreService.Providers.Stripe (stripeProvider)
@@ -51,11 +54,11 @@ import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import Data.Char (isSpace)
import Data.Either (fromRight)
import Data.Functor (($>))
import Data.Functor (($>), (<&>))
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust, maybeToList)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Time.Clock (NominalDiffTime, UTCTime, getCurrentTime)
import Data.Word (Word32)
import Simplex.Chat.Badges
import Simplex.Chat.Badges.Code
@@ -67,6 +70,7 @@ import Simplex.Chat.Bot.Store (withDB, withDB')
import Simplex.Chat.Controller
import Simplex.Chat.Core (sendChatCmd, simplexChatCore)
import Simplex.Chat.Options (printDbOpts)
import Simplex.Chat.PaymentService.Types (InvoiceId (..))
import Simplex.Chat.Terminal (terminalChatConfig)
import Simplex.Chat.Terminal.Main (simplexChatCLI')
import Simplex.Chat.Types (AgentInvId (..), User (..))
@@ -74,7 +78,7 @@ import Simplex.Messaging.Agent.Store.Common (DBStore)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BBS (bbsPublicKey)
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util (raceAny_, safeDecodeUtf8, tshow)
import Simplex.Messaging.Util (catchOwn, raceAny_, safeDecodeUtf8, tshow)
import Simplex.Messaging.Version (isCompatible)
import System.Directory (getAppUserDataDirectory)
import System.Exit (exitFailure)
@@ -234,19 +238,20 @@ runBadgeCmd turns cc cmd
issuedReply formatBadgeCode <$> issueBadgeCode cc issueOpts
| Right (len, years, paymentStatus) <- A.parseOnly issueNameCmdP cmd =
issuedReply (formatNameCode . NCPrepaid) <$> issueNameCode cc len years paymentStatus
| Right code <- A.parseOnly revokeCmdP cmd = revokedReply <$> revokeWithTracker cc code
| Right code <- A.parseOnly revokeNameCmdP cmd = revokedReply <$> revokeNameCode turns cc code
| Right code <- A.parseOnly revokeCmdP cmd = textReply <$> revokeWithTracker cc code
| Right code <- A.parseOnly revokeNameCmdP cmd = textReply <$> revokeNameCode turns cc code
| Right (ref, cents_) <- A.parseOnly refundCmdP cmd = textReply <$> maybe (showRefund cc ref) (refundBalanceCmd turns cc ref) cents_
| otherwise =
pure $ chatCmdError $
"Usage: //issue supporter|legend|investor [months 1-" <> show maxMonths <> "] [paid|unpaid|free], //issue name <length "
<> show minNameLength <> "-" <> show maxNameLength <> "> [years " <> show minNameYears <> "-" <> show maxNameYears
<> "] [paid|unpaid|free], or //revoke <code>"
<> "] [paid|unpaid|free], //revoke <code>, or //refund <reference> [<cents>]"
where
issuedReply :: (c -> T.Text) -> Either String c -> Either ChatError ChatResponse
issuedReply format = \case
Right code -> Right CRCustomChatResponse {user_ = Nothing, response = "Code: " <> format code}
Left _ -> chatCmdError (T.unpack issueFailedText)
revokedReply = \case
textReply = \case
Right response -> Right CRCustomChatResponse {user_ = Nothing, response}
Left e -> chatCmdError (T.unpack e)
@@ -261,6 +266,93 @@ revokeNameCmdP = "revoke " *> prepaidCodeP <* (A.skipSpace *> A.endOfInput)
Just (NCPrepaid code) -> pure code
_ -> fail "not a prepaid name code"
-- | The reference, and the cents of the balance to refund; without them the balance is shown.
refundCmdP :: A.Parser (T.Text, Maybe Int)
refundCmdP = do
ref <- "refund " *> (safeDecodeUtf8 <$> A.takeWhile1 (not . isSpace))
cents_ <- optional (A.space *> (A.decimal :: A.Parser Integer))
A.skipSpace
A.endOfInput
(ref,) <$> mapM checkCents cents_
where
-- Integer, as in issueCmdP, so a figure past Int is refused rather than wrapped.
checkCents n
| n >= 1 && n <= toInteger (maxBound :: Int) = pure (fromInteger n)
| otherwise = fail "cents out of range"
-- | Left is a refusal or a failure; both sides are the text to show the operator.
showRefund :: ChatController -> T.Text -> IO (Either T.Text T.Text)
showRefund cc ref = do
now <- getCurrentTime
withDB' "showRefund" cc (\db -> balanceByReference db ref now >>= mapM (withChain db)) <&> \case
Right Nothing -> Left $ "No order has the reference " <> ref <> "."
Right (Just (RegistrationId rid, Nothing)) -> Left $ "Order " <> rid <> " has no balance."
Right (Just (RegistrationId rid, Just (balance@Balance {blAmount, blCredited, blRefunded}, chain))) ->
Right $
"Order " <> rid <> ": balance " <> dollars blAmount <> " (credited " <> dollars blCredited <> ", refunded " <> dollars blRefunded
<> "). Paid by: " <> chainText chain <> ". " <> nextStep now balance
Left _ -> Left "The balance could not be read."
where
withChain db (rid, balance_) = (rid,) <$> mapM (\balance -> (balance,) <$> balanceChain db rid) balance_
nextStep now Balance {blAmount, blHold} = case blHold of
Just hold -> "It cannot be refunded now: " <> heldText now hold <> "."
Nothing
| blAmount > 0 -> "To record its refund: //refund " <> ref <> " " <> tshow blAmount
| otherwise -> "Nothing to refund."
-- | The refund is recorded before support pays it, so the balance cannot be spent meanwhile.
refundBalanceCmd :: Turns -> ChatController -> T.Text -> Int -> IO (Either T.Text T.Text)
refundBalanceCmd turns ChatController {chatStore} ref cents = do
now <- getCurrentTime
((Right <$> recordRefund turns chatStore ref cents now) `catchOwn` (pure . Left)) >>= \case
Right (Right RefundRecord {rdRegistration = RegistrationId rid, rdCents, rdChain}) -> do
-- The registration id, not the reference, since an invoice id is kept out of the logs.
logInfo $ "names: recorded a refund of " <> tshow rdCents <> " cents for order " <> rid
pure $ Right $ "Recorded a refund of " <> dollars rdCents <> " for order " <> rid <> ". Refund it now, by: " <> chainText rdChain <> "."
Right (Left refusal) -> pure $ Left $ "Nothing recorded: " <> refusalText now refusal <> "."
Left e -> Left "The refund could not be recorded." <$ logError ("names: recording a refund failed: " <> tshow e)
where
refusalText now = \case
RefundNoOrder -> "no order has the reference " <> ref
RefundNoBalance -> "the order of " <> ref <> " has no balance"
RefundAmount amount -> "the balance is " <> dollars amount <> " now, not " <> dollars cents
RefundHeld hold -> heldText now hold
heldText :: UTCTime -> BalanceHold -> T.Text
heldText now BalanceHold {bhHolder = RegistrationId holder, bhUntil, bhOverdue}
| bhOverdue = heldBy <> "its invoice expired at " <> fmtTime bhUntil <> "; start the service so the poller closes that order, " <> tshow graceMinutes <> " minutes after the expiry at the earliest, then stop it and run //refund again"
| bhUntil < now = heldBy <> "whose invoice expired at " <> fmtTime bhUntil <> " holding money; it holds until that payment settles or the invoice closes; run the command again then"
| otherwise = heldBy <> "its invoice open until " <> fmtTime bhUntil <> "; run the command again then"
where
heldBy = "held by order " <> holder <> ", "
graceMinutes = round (expiryGrace / minute) :: Int
minute = 60 :: NominalDiffTime
-- | From the balance's own order back to the first; a part paid from a balance names the next order, whose balance paid it.
chainText :: [BalanceLink] -> T.Text
chainText chain = T.intercalate "; " $ zipWith linkText chain (map (Just . lkRegistration) (drop 1 chain) <> [Nothing])
where
linkText BalanceLink {lkRegistration = RegistrationId rid, lkPayment, lkPaid, lkFromBalance, lkLateCredits} source_ =
"order " <> rid <> ": " <> case paid lkPayment lkPaid lkLateCredits <> fromBalance lkFromBalance source_ of
[] -> "no payment recorded"
parts -> T.intercalate ", " parts
paid payment cents lateCredits = case payment of
RPInvoice method (InvoiceId invId) ->
let by = " by " <> methodText method <> " on invoice " <> invId
late = sum $ map lcCents lateCredits
in [dollars cents <> by | cents > 0] <> [dollars late <> " late" <> by | late > 0]
RPCode _ -> ["a prepaid code"]
RPBalance -> []
fromBalance cents = \case
Just (RegistrationId source) | cents > 0 -> [dollars cents <> " from the balance of order " <> source]
_ -> []
dollars :: Int -> T.Text
dollars cents = "$" <> tshow whole <> "." <> T.justifyRight 2 '0' (tshow part)
where
(whole, part) = cents `quotRem` centsPerDollar
centsPerDollar = 100
issueNameCmdP :: A.Parser (Int, Int, BadgeCodePaymentStatus)
issueNameCmdP = do
(len, years) <- "issue " *> nameCodeP
+8 -5
View File
@@ -218,13 +218,16 @@ issueCode cc badgeType months = issueCodeAs cc badgeType months "free"
revokeCodeAs :: HasCallStack => ChatController -> BadgeCode -> IO (Either T.Text T.Text)
revokeCodeAs cc code = revokeRaw cc (formatBadgeCode code)
-- | Left is a refusal or a failure the service answers as a command error.
revokeRaw :: HasCallStack => ChatController -> T.Text -> IO (Either T.Text T.Text)
revokeRaw cc args =
sendChatCmdStr cc ("//revoke " <> T.unpack args) >>= \case
revokeRaw cc args = operatorCmd cc ("//revoke " <> args)
-- | Left is a refusal or a failure the service answers as a command error.
operatorCmd :: HasCallStack => ChatController -> T.Text -> IO (Either T.Text T.Text)
operatorCmd cc cmd =
sendChatCmdStr cc (T.unpack cmd) >>= \case
Right (CRCustomChatResponse _ response) -> pure (Right response)
Left (ChatError (CECommandError e)) -> pure (Left (T.pack e))
r -> error $ "revoke failed: " <> show (() <$ r)
r -> error $ T.unpack cmd <> " failed: " <> show (() <$ r)
issueCodeAs :: HasCallStack => ChatController -> BadgeType -> Int -> String -> IO BadgeCode
issueCodeAs cc badgeType months status =
@@ -1590,4 +1593,4 @@ testRevokeRejectsTrailingInput ps =
-- The text is spelled out rather than taken from the service, so a change there fails here.
badgeCmdUsage :: Text
badgeCmdUsage = "Usage: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free], //issue name <length 6-63> [years 2-10] [paid|unpaid|free], or //revoke <code>"
badgeCmdUsage = "Usage: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free], //issue name <length 6-63> [years 2-10] [paid|unpaid|free], //revoke <code>, or //refund <reference> [<cents>]"
@@ -8,25 +8,28 @@
{-# LANGUAGE TupleSections #-}
-- Members' messages reach a console over separate connections in no fixed order, so gating is asserted from the bot's store.
module Bots.StoreService.GroupIntegrationTests (badgeGroupIntegrationTests, namesBootTests, namesCodesChatTests, namesWorkerBootTests) where
module Bots.StoreService.GroupIntegrationTests (badgeGroupIntegrationTests, namesBootTests, namesCodesChatTests, namesRefundChatTests, namesWorkerBootTests) where
import StoreService.Config (BadgeIssuerKey (..), GroupConfig (..))
import StoreService.Group (GroupAction (..), GroupEvent (..), codeInTracker, ensureManagedGroup)
import StoreService.Group.Command (maxUses)
import StoreService.Group.Command (CmdAction (..), groupCmdAction, maxUses)
import StoreService.Names.Abi (selector, uint256)
import StoreService.Names.Codes (issueNameCode, reserveCode, revokeNameCode)
import StoreService.Names.Phases (RegistrationId (..))
import StoreService.Names.Store (PrepaidCode (..), getPrepaidCode, spendPrepaidCode)
import StoreService.Names.Store (NamePhase (..), NameRegistration (..), PrepaidCode (..), VersionWrite (..), getPrepaidCode, spendPrepaidCode)
import StoreService.Names.Turns (newTurns, withCodeTurn)
import StoreService.Options (StoreServiceOpts (..))
import StoreService.Orders (cancelOpenInvoice, expireOverdue)
import StoreService.Service (ServiceState (..), storeService, newServiceState)
import StoreService.Store (ManagedGroup (..), getManagedGroup)
import Bots.StoreService.BotTests (badgeBotName, badgeCmdUsage, badgeProfile, badgeTypeOf, credentialOf, entryOf, issueCode, issueRaw, mkStoreServiceOpts, newPurchaseKeys, redeemWithQueue, requestObject, revokeRaw, serviceDbPrefix, shouldAnswerError, statementOf, stopStoreService, testIssuerKeyIdx)
import StoreService.Waiters (newWaiters)
import Bots.StoreService.BotTests (badgeBotName, badgeCmdUsage, badgeProfile, badgeTypeOf, credentialOf, entryOf, issueCode, issueRaw, mkStoreServiceOpts, newPurchaseKeys, operatorCmd, redeemWithQueue, requestObject, revokeRaw, serviceDbPrefix, shouldAnswerError, statementOf, stopStoreService, testIssuerKeyIdx, withStoreService)
import Bots.StoreService.ConfigTests (chainSection, controllerAddr, namesSection)
import Bots.StoreService.FakeEthNode (CallAnswer (..), FakeEthNode (..), answerCall, awaitSent, withFakeEthNode)
import Bots.StoreService.WebTests (digestFixture)
import Bots.StoreService.WebTests (capturingStderr, digestFixture)
import Bots.StoreService.NamesSearchTests (aliceHex)
import Bots.StoreService.NamesStoreTests (order, ownerA, placeCode, placePaid)
import qualified Bots.StoreService.NamesBalancesTests as Balances
import Bots.StoreService.NamesStoreTests (order, ownerA, placeCode, placePaid, reg, t0, write)
import ChatClient
import ChatTests.DBUtils
import ChatTests.Utils
@@ -49,6 +52,7 @@ import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Network.HTTP.Client (RequestBody (..), defaultManagerSettings, httpLbs, method, newManager, parseRequest, requestBody, responseBody, responseHeaders, responseStatus)
import Network.HTTP.Types.Status (statusCode)
import Network.Socket (Family (..), SockAddr (..), SocketType (..), close, connect, defaultProtocol, socket, tupleToHostAddress)
@@ -61,12 +65,13 @@ import Simplex.Chat.Controller (ChatCommand (APIDeleteChatItem), ChatConfig (..)
import Simplex.Chat.Core (sendChatCmd, sendChatCmdStr)
import Simplex.Chat.Messages (ChatRef (ChatRef), ChatType (CTGroup))
import Simplex.Chat.Messages.CIContent (CIDeleteMode (CIDMInternal))
import Simplex.Chat.PaymentService.Types (InvoiceId (..))
import Simplex.Chat.Types (AgentInvId (..))
import Simplex.Chat.Types.Preferences (ChatBotCommand (..), FullDeleteGroupPreference (..), GroupFeatureEnabled (..), GroupFeatureI (..), GroupPreferences (..), HistoryGroupPreference (..), SGroupFeature (..), commands_, emptyGroupPrefs, getGroupPreference)
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
import Simplex.Messaging.Agent (disposeAgentClient)
import Simplex.Messaging.Agent.Protocol (AConnShortLink (..), ConnShortLink (..), ContactConnType (..))
import Simplex.Messaging.Agent.Store.Common (withTransaction)
import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction)
import qualified Simplex.Messaging.Agent.Store.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BBS (bbsKeyGen)
@@ -307,6 +312,132 @@ testRevokeTakesCodeTurn ps = do
revokeAfter reserved (releaseTurn reserving) `shouldReturn` Right "Revoked. The order holding it keeps it."
finished (thAction reserving) `shouldReturn` Right (Right ())
namesRefundChatTests :: SpecWith TestParams
namesRefundChatTests = describe "StoreService names: operator refund" $ do
it "shows a balance with what paid for it, and records refunds by registration or invoice id, adding them up" testRefundRecorded
it "refuses an unknown reference, an order without a balance and another amount, and answers the usage to a malformed command" testRefundRefusals
it "refuses a balance held by an open order, naming it and its expiry, and records it once that order's invoice is cancelled" testRefundOpenHolder
it "refuses a balance held by an overdue order, naming its expiry, and records it once the sweep closed that order" testRefundOverdueHolder
it "refuses a balance held by an order whose invoice holds money past its expiry" testRefundFundedHolder
it "lists every order of a chain of three that the balance came through" testRefundChain
it "stops the chain of a late-paid difference order at itself, its cents marked late" testRefundLateChain
it "is not a group command" $ \_ -> groupCmdAction GROwner "/refund s 10000" `shouldBe` IgnoreMsg
refundRaw :: HasCallStack => ChatController -> Text -> IO (Either Text Text)
refundRaw cc args = operatorCmd cc ("//refund " <> args)
minuteText :: UTCTime -> Text
minuteText = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M UTC"
testRefundRecorded :: HasCallStack => TestParams -> IO ()
testRefundRecorded ps = withStoreService ps $ \_ _ cc -> do
let st = chatStore cc
paidBy = "order s: $100.00 by btc on invoice inv-s"
Balances.source st "s" 10000
refundRaw cc "s" `shouldReturn` Right ("Order s: balance $100.00 (credited $100.00, refunded $0.00). Paid by: " <> paidBy <> ". To record its refund: //refund s 10000")
logs <- capturingStderr $ refundRaw cc "inv-s 10000" `shouldReturn` Right ("Recorded a refund of $100.00 for order s. Refund it now, by: " <> paidBy <> ".")
logs `shouldSatisfy` T.isInfixOf "names: recorded a refund of 10000 cents for order s"
Balances.figures st "s" t0 `shouldReturn` Just (0, 10000, 10000, Nothing)
refundRaw cc "inv-s" `shouldReturn` Right ("Order s: balance $0.00 (credited $100.00, refunded $100.00). Paid by: " <> paidBy <> ". Nothing to refund.")
Balances.credit st "s" 2505
refundRaw cc "s 2505" `shouldReturn` Right ("Recorded a refund of $25.05 for order s. Refund it now, by: " <> paidBy <> ".")
Balances.figures st "s" t0 `shouldReturn` Just (0, 12505, 12505, Nothing)
testRefundRefusals :: HasCallStack => TestParams -> IO ()
testRefundRefusals ps = withStoreService ps $ \_ _ cc -> do
let st = chatStore cc
Balances.placeOrderRow st "plain" False 20000 t0
Balances.source st "s" 10000
refundRaw cc "nothing" `shouldReturn` Left "No order has the reference nothing."
refundRaw cc "nothing 10000" `shouldReturn` Left "Nothing recorded: no order has the reference nothing."
refundRaw cc "inv-plain" `shouldReturn` Left "Order plain has no balance."
refundRaw cc "inv-plain 20000" `shouldReturn` Left "Nothing recorded: the order of inv-plain has no balance."
refundRaw cc "s 9000" `shouldReturn` Left "Nothing recorded: the balance is $100.00 now, not $90.00."
refundRaw cc "s 10001" `shouldReturn` Left "Nothing recorded: the balance is $100.00 now, not $100.01."
forM_ ["", "s 0", "s -5", "s 100.00", "s 10000 junk", "s 99999999999999999999"] $ \args ->
(args,) <$> refundRaw cc args `shouldReturn` (args, Left badgeCmdUsage)
Balances.figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Nothing)
-- | The source s with a balance of $100.00 and the order h holding it, whose invoice expires at the given time.
heldSource :: ChatController -> UTCTime -> IO ()
heldSource cc expiry = do
Balances.source (chatStore cc) "s" 10000
Balances.holder (chatStore cc) "h" "s" 10000 expiry `shouldReturn` Right ()
heldRefusals :: HasCallStack => ChatController -> Text -> IO ()
heldRefusals cc held = do
refundRaw cc "s" `shouldReturn` Right ("Order s: balance $100.00 (credited $100.00, refunded $0.00). Paid by: order s: $100.00 by btc on invoice inv-s. It cannot be refunded now: " <> held <> ".")
refundRaw cc "inv-s 10000" `shouldReturn` Left ("Nothing recorded: " <> held <> ".")
Balances.figures (chatStore cc) "s" t0 >>= (`shouldSatisfy` \case Just (10000, 10000, 0, Just _) -> True; _ -> False)
recordedS :: Either Text Text
recordedS = Right "Recorded a refund of $100.00 for order s. Refund it now, by: order s: $100.00 by btc on invoice inv-s."
testRefundOpenHolder :: HasCallStack => TestParams -> IO ()
testRefundOpenHolder ps = withStoreService ps $ \_ _ cc -> do
now <- getCurrentTime
let expiry = addUTCTime 3600 now
heldSource cc expiry
heldRefusals cc $ "held by order h, its invoice open until " <> minuteText expiry <> "; run the command again then"
waiters <- newWaiters
cancelOpenInvoice (chatStore cc) waiters Nothing (InvoiceId "inv-h") now `shouldReturn` True
refundRaw cc "s 10000" `shouldReturn` recordedS
testRefundOverdueHolder :: HasCallStack => TestParams -> IO ()
testRefundOverdueHolder ps = withStoreService ps $ \_ _ cc -> do
now <- getCurrentTime
let expiry = addUTCTime (-120) now
heldSource cc expiry
heldRefusals cc $ "held by order h, its invoice expired at " <> minuteText expiry <> "; start the service so the poller closes that order, 10 minutes after the expiry at the earliest, then stop it and run //refund again"
waiters <- newWaiters
expireOverdue (chatStore cc) waiters Nothing now now [InvoiceId "inv-h"] `shouldReturn` [InvoiceId "inv-h"]
refundRaw cc "s 10000" `shouldReturn` recordedS
testRefundFundedHolder :: HasCallStack => TestParams -> IO ()
testRefundFundedHolder ps = withStoreService ps $ \_ _ cc -> do
now <- getCurrentTime
let expiry = addUTCTime (-120) now
heldSource cc expiry
Balances.payment (chatStore cc) "inv-h" (Balances.cardPaid 100 0) now
heldRefusals cc $ "held by order h, whose invoice expired at " <> minuteText expiry <> " holding money; it holds until that payment settles or the invoice closes; run the command again then"
testRefundChain :: HasCallStack => TestParams -> IO ()
testRefundChain ps = withStoreService ps $ \_ _ cc -> do
let st = chatStore cc
Balances.source st "a" 20000
Balances.holder st "b" "a" 20000 t0 `shouldReturn` Right ()
b <- reg st "b"
withTransaction st (`Balances.settleHolder` b) `shouldReturn` True
taken st "b"
Balances.credit st "b" 40000
Balances.coveredSpend st "c" "b" 10000 40000 `shouldReturn` Right ()
taken st "c"
Balances.credit st "c" 10000
refundRaw cc "c"
`shouldReturn` Right
( "Order c: balance $100.00 (credited $100.00, refunded $0.00). Paid by: order c: $100.00 from the balance of order b; "
<> "order b: $200.00 by btc on invoice inv-b, $200.00 from the balance of order a; order a: $200.00 by btc on invoice inv-a. "
<> "To record its refund: //refund c 10000"
)
where
taken :: DBStore -> Text -> IO ()
taken st r = do
queued <- reg st r
write st queued {nrPhase = PTaken} `shouldReturn` Written (nrVersion queued + 1)
testRefundLateChain :: HasCallStack => TestParams -> IO ()
testRefundLateChain ps = withStoreService ps $ \_ _ cc -> do
let st = chatStore cc
paidBy = "order d: $24.00 late by btc on invoice inv-d"
Balances.source st "s" 15000
Balances.holder st "d" "s" 15000 t0 `shouldReturn` Right ()
waiters <- newWaiters
cancelOpenInvoice st waiters Nothing (InvoiceId "inv-d") t0 `shouldReturn` True
Balances.creditLate st (Balances.lateCredit "d" 1 (Just 40000) 2400) `shouldReturn` True
refundRaw cc "inv-d" `shouldReturn` Right ("Order d: balance $24.00 (credited $24.00, refunded $0.00). Paid by: " <> paidBy <> ". To record its refund: //refund inv-d 2400")
refundRaw cc "inv-d 2400" `shouldReturn` Right ("Recorded a refund of $24.00 for order d. Refund it now, by: " <> paidBy <> ".")
Balances.figures st "s" t0 `shouldReturn` Just (15000, 15000, 0, Nothing)
data TurnHolder a = TurnHolder {thAction :: Async a, thRelease :: MVar ()}
-- | Runs the action until its body is inside the turn, where the body waits for the release.
@@ -5,7 +5,7 @@
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeOperators #-}
module Bots.StoreService.NamesBalancesTests (namesBalancesTests, fieldsN, figures, holdPrice, holder, invariantHolds, placeOrderRow, source) where
module Bots.StoreService.NamesBalancesTests (namesBalancesTests, cardPaid, coveredSpend, credit, creditLate, fieldsN, figures, holdPrice, holder, invariantHolds, lateCredit, payment, placeOrderRow, settleHolder, source) where
import Bots.StoreService.NamesOrdersTests (Ctx (..), withOrders)
import Bots.StoreService.NamesStoreTests (at, btc, invoiceStatus, newReg, ownerA, ownerB, paidOrder, reg, signature, t0, write)
+1
View File
@@ -160,6 +160,7 @@ main = do
describe "StoreService names: boot" namesBootTests
describe "StoreService names: worker" namesWorkerBootTests
namesCodesChatTests
namesRefundChatTests
namesProfilesChatTests
describe "Remote session" remoteTests
#if !defined(dbPostgres)