core: credit store receipts from a standard work queue

This commit is contained in:
spaced4ndy
2026-10-06 17:10:05 +04:00
parent 0d7efd0bc3
commit eee013dfcb
10 changed files with 210 additions and 228 deletions
@@ -109,8 +109,8 @@ final class BadgeStore: ObservableObject {
// kept for this run only: StoreKit lists no deferred purchase, and a declined one delivers nothing
// by invoice id, so a pending purchase shows only under the profile whose record it names
@Published private var waitingForApproval: Set<String> = []
// set when the first sweep has returned, which is after every transaction the store holds was handed
// to core - until then a purchase made while the app was not running is unknown
// set once presentUnfinished has read the store: until then, an Ask to Buy approved while the app was
// closed, or a purchase it died before handing over, are both unknown, so canBuy refuses to buy again
@Published private var reconciledOnce = false
// purchases the user started this run, by transaction: the store is not relied on to echo the invoice back
private var awaitedTransactions: Set<UInt64> = []
@@ -156,8 +156,8 @@ object BadgeStore {
private val buying = mutableStateOf(false)
// by invoice id, so a pending purchase shows only under the profile whose record it names
private val waitingForApproval = mutableStateOf<Set<String>>(emptySet())
// set when the first sweep has returned or failed, which is after every purchase the store holds was handed
// to core - until then a purchase made while the app was not running is unknown
// set once presentUnfinished has read the store, or failed to: until then, a slow payment completed while
// the app was closed, or a purchase it died before handing over, are both unknown, so canBuy refuses
private val reconciledOnce = mutableStateOf(false)
// purchases the user started this run, by token: the store is not relied on to echo the invoice back
private val awaitedTransactions = mutableSetOf<String>()
+6 -1
View File
@@ -294,6 +294,11 @@ data BadgeWorker = BadgeWorker
badgeWork :: TMVar ()
}
data StoreReceiptWorker = StoreReceiptWorker
{ receiptWorker :: Worker,
parkedReceipts :: TVar (Set Int64)
}
data ChatController = ChatController
{ currentUser :: TVar (Maybe User),
randomPresetServers :: NonEmpty PresetOperator,
@@ -328,7 +333,7 @@ data ChatController = ChatController
relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework
-- one badge worker per user: badge state is per profile, and one profile must not stall another
badgeWorkers :: TMap UserId (SessionVar BadgeWorker),
storeReceiptWorkers :: TMap UserId (SessionVar BadgeWorker),
storeReceiptWorkers :: TMap UserId StoreReceiptWorker,
badgeSeq :: TVar Int,
relayGroupLinkChecksAsync :: TVar (Maybe (Async ())),
webPreviewState :: Maybe WebPreviewState,
+84 -66
View File
@@ -105,9 +105,10 @@ import qualified Simplex.Chat.Util as U
import Simplex.Chat.Web (webPreviewWorker)
import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSizeHard)
import Simplex.Messaging.Agent
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
import Simplex.Messaging.Agent.Client (getAgentWorker', hasWorkToDo, waitForUserNetwork, waitForWork, waitWhileSuspended, withWork_)
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), Worker (..), allRoles)
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay, withRetryInterval)
import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), withRetryInterval)
import Simplex.Messaging.Agent.Store.Entity
import Simplex.Messaging.Agent.Store.Interface (execSQL)
import Simplex.Messaging.Agent.Store.Shared (upMigration)
@@ -263,7 +264,7 @@ startChatController mainApp enableSndFiles serviceRequests = do
startRelayRequestWorker_
startCleanupManager
mapM_ startBadgeWork users
mapM_ startStoreReceiptWork users
startStoreReceiptWorkers users
void $ forkIO $ mapM_ startExpireCIs users
startRelayChecks users
startWebPreview users
@@ -282,6 +283,10 @@ startChatController mainApp enableSndFiles serviceRequests = do
runExceptT startRelayRequestWorker >>= \case
Left e -> liftIO $ putStrLn $ "Error starting relay request worker: " <> show e
Right _ -> pure ()
startStoreReceiptWorkers users =
runExceptT (mapM_ startStoreReceiptWork users) >>= \case
Left e -> liftIO $ putStrLn $ "Error starting store receipt workers: " <> show e
Right _ -> pure ()
startCleanupManager = do
cleanupAsync <- asks cleanupManagerAsync
readTVarIO cleanupAsync >>= \case
@@ -374,7 +379,7 @@ stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles,
clearMap deliveryJobWorkers >>= mapM_ cancelWorker
clearMap relayRequestWorkers >>= mapM_ cancelWorker
stopBadgeWorkers badgeWorkers
stopBadgeWorkers storeReceiptWorkers
clearMap storeReceiptWorkers >>= mapM_ (cancelWorker . receiptWorker)
closeFiles sndFiles
closeFiles rcvFiles
atomically $ do
@@ -607,7 +612,7 @@ processChatCommand cxt nm = \case
void . forkIO $ startFilesToReceive users
setAllExpireCIFlags True
mapM_ startBadgeWork users
mapM_ startStoreReceiptWork users
mapM_ startStoreReceiptWork users
ok_
APISuspendChat t -> do
chatWriteVar chatActivated False
@@ -5269,9 +5274,9 @@ handOverStoreReceipt presentingUser echoedInvoiceId payment = do
(StoreReceipt {ownerId, status}, newlyHeld) <-
withStore' (\db -> holdStoreReceipt db g presentingUser echoedInvoiceId txRef payment now)
>>= maybe (throwChatError $ CEInternalError "store receipt was not recorded") pure
owner <- withStore $ \db -> getUser db ownerId
-- before the answer, which can throw: no later hand-over of this receipt signals, and a spare signal costs an empty pass
when newlyHeld $ lift $ startStoreReceiptWork owner
when newlyHeld $ lift $ resumeStoreReceiptWork ownerId
owner <- withStore $ \db -> getUser db ownerId
case status of
RSHeld -> badgeStateResponse owner
RSCredited {badgePurchaseId} -> do
@@ -5348,23 +5353,20 @@ badgeNow = asks (badgeCurrentTime . config) >>= liftIO
-- | A signal carries nothing: each pass derives its work from stored state, so a signal lost or
-- duplicated changes no outcome.
startBadgeWork :: User -> CM' ()
startBadgeWork user = whenM (isJust <$> asks (badgeServiceAddress . config)) $ void $ getBadgeWorker badgeWorkers runBadgeWorker user
startStoreReceiptWork :: User -> CM' ()
startStoreReceiptWork user = whenM (isJust <$> asks (badgeServiceAddress . config)) $ void $ getBadgeWorker storeReceiptWorkers runStoreReceiptWorker user
startBadgeWork user = whenM (isJust <$> asks (badgeServiceAddress . config)) $ void $ getBadgeWorker user
-- | Exactly one caller starts the thread and the rest wait for it: the lookup and the create cannot
-- be one transaction, because starting a thread is not STM.
getBadgeWorker :: (ChatController -> TM.TMap UserId (SessionVar BadgeWorker)) -> (UserId -> TMVar () -> CM ()) -> User -> CM' BadgeWorker
getBadgeWorker workers runWorker User {userId} = do
ws <- asks workers
getBadgeWorker :: User -> CM' BadgeWorker
getBadgeWorker User {userId} = do
ws <- asks badgeWorkers
seq' <- asks badgeSeq
now <- liftIO getCurrentTime
withGetSessVar' seq' userId ws now startWorker signalWorker
where
startWorker v = do
badgeWork <- newEmptyTMVarIO
badgeWorkerAsync <- async $ void $ runExceptT $ runWorker userId badgeWork
badgeWorkerAsync <- async $ void $ runExceptT $ runBadgeWorker userId badgeWork
let w = BadgeWorker {badgeWorkerAsync, badgeWork}
w <$ atomically (putTMVar (sessionVar v) w)
signalWorker v = do
@@ -5434,46 +5436,74 @@ waitBadgeWake badgeWork now = \case
badgeMaxWake :: NominalDiffTime
badgeMaxWake = 100 * 365 * nominalDay
-- | The only sender of held store receipts; each row's next_attempt_at is when it is tried again. The
-- wait is floored, so a row still due because its outcome could not be written does not spin the worker.
runStoreReceiptWorker :: UserId -> TMVar () -> CM ()
runStoreReceiptWorker userId work = forever $ do
lift waitChatStartedAndActivated
nextAt_ <- creditStoreReceipts userId `catchAllErrors` \e -> eToView e >> Just <$> badgeNow
now <- badgeNow
ri <- asks $ badgeRetryInterval . config
liftIO $ waitBadgeWake work now $ max (retryFloor ri `addUTCTime` now) <$> nextAt_
startStoreReceiptWork :: User -> CM ()
startStoreReceiptWork User {userId} = do
due <- withStore' $ \db -> getNextDueStoreReceipt db userId S.empty
when (either (const False) isJust due) $ lift $ resumeStoreReceiptWork userId
-- | The due rows are read once, so a pass tries each at most once.
creditStoreReceipts :: UserId -> CM (Maybe UTCTime)
creditStoreReceipts userId = do
now <- badgeNow
due <- withStore' $ \db -> getDueStoreReceipts db userId now
forM_ due $ \r -> creditStoreReceipt userId r `catchAllErrors` eToView
withStore' (`getNextStoreReceiptAttempt` userId)
resumeStoreReceiptWork :: UserId -> CM' ()
resumeStoreReceiptWork userId = do
StoreReceiptWorker {receiptWorker, parkedReceipts} <- getStoreReceiptWorker userId
atomically $ writeTVar parkedReceipts S.empty >> hasWorkToDo receiptWorker
data StoreReceiptOutcome = SROCredited (Maybe User) | SRORefused | SRODeferred
getStoreReceiptWorker :: UserId -> CM' StoreReceiptWorker
getStoreReceiptWorker userId = do
ws <- asks storeReceiptWorkers
a <- asks smpAgent
getAgentWorker' receiptWorker newStoreReceiptWorker "store_receipt" False a userId ws $
runStoreReceiptWorker a userId
where
newStoreReceiptWorker w = StoreReceiptWorker w <$> newTVar S.empty
runStoreReceiptWorker :: AgentClient -> UserId -> StoreReceiptWorker -> CM ()
runStoreReceiptWorker a userId StoreReceiptWorker {receiptWorker = Worker {doWork}, parkedReceipts} =
forever $ do
lift $ waitForWork doWork
withWork_ a doWork getNextReceipt $ \r@HeldStoreReceipt {receiptId} ->
(creditStoreReceipt a userId r `catchAllErrors` \e -> False <$ eToView e) >>= \case
True -> pure ()
-- unlike the agent's workers, a final failure keeps the row: the purchase is paid for, so the next trigger tries it again
False -> atomically $ modifyTVar' parkedReceipts $ S.insert receiptId
where
getNextReceipt = do
parked <- readTVarIO parkedReceipts
withStore' $ \db -> getNextDueStoreReceipt db userId parked
data StoreReceiptOutcome = SROCredited (Maybe User) | SRORefused | SRORetry (Maybe Word32) | SROFailed
-- | Under the owner's badge lock, which keeps a profile's requests to the service serial.
creditStoreReceipt :: UserId -> HeldStoreReceipt -> CM ()
creditStoreReceipt userId HeldStoreReceipt {receiptId, stash = stash@BadgeStash {masterKey}, payment, retryDelay} = do
outcome <- withEntityLock "badgePurchase" (CLBadgeUser userId) $
tryAllErrors attempt >>= \case
Right (Right (present_, _)) -> pure $ SROCredited present_
Right (Left (code, retryAfter))
| storeReceiptRefused code -> SRORefused <$ withStore' (\db -> refuseStoreReceipt db receiptId $ serviceFailure code retryAfter)
| otherwise -> SRODeferred <$ defer (serviceFailure code retryAfter) retryAfter
Left e -> SRODeferred <$ defer (badgeIssueFailure e) Nothing
-- only a settlement is announced, and the app's sweep in answer signals this worker only for a newly held receipt, so it cannot loop
case outcome of
SROCredited present_ -> do
user <- withStore $ \db -> getUser db userId
toView . CEvtBadgeChanged user =<< getUserBadgeState user
toView $ CEvtStorePurchaseSettled user
-- last, so a failed broadcast cannot lose the settlement; outside the badge lock, as it takes the chat lock
mapM_ presentUserBadgeToContacts present_
SRORefused -> withStore (`getUser` userId) >>= toView . CEvtStorePurchaseSettled
SRODeferred -> pure ()
creditStoreReceipt :: AgentClient -> UserId -> HeldStoreReceipt -> CM Bool
creditStoreReceipt a userId HeldStoreReceipt {receiptId, stash = stash@BadgeStash {masterKey}, payment, retryDelay} = do
ri <- asks $ badgeRetryInterval . config
notBefore <- newTVarIO Nothing
withRetryInterval (maybe ri (\d -> ri {initialInterval = d, increaseAfter = 0}) retryDelay) $ \delay loop -> do
liftIO $ waitWhileSuspended a
liftIO $ waitForUserNetwork a
now <- badgeNow
readTVarIO notBefore >>= \case
Just at | now < at -> loop
_ -> do
outcome <- withEntityLock "badgePurchase" (CLBadgeUser userId) $
tryAllErrors attempt >>= \case
Right (Right (present_, _)) -> pure $ SROCredited present_
Right (Left (code, retryAfter))
| storeReceiptRefused code -> SRORefused <$ withStore' (\db -> refuseStoreReceipt db receiptId $ serviceFailure code retryAfter)
| otherwise -> failed delay (serviceFailure code retryAfter) retryAfter
Left e -> failed delay (badgeIssueFailure e) Nothing
-- only a settlement is announced, and the app's sweep in answer signals this worker only for a newly held receipt, so it cannot loop
case outcome of
SROCredited present_ -> do
user <- withStore $ \db -> getUser db userId
toView . CEvtBadgeChanged user =<< getUserBadgeState user
toView $ CEvtStorePurchaseSettled user
-- last, so a failed broadcast cannot lose the settlement; outside the badge lock, as it takes the chat lock
mapM_ presentUserBadgeToContacts present_
pure True
SRORefused -> True <$ (withStore (`getUser` userId) >>= toView . CEvtStorePurchaseSettled)
SRORetry retryAfter_ -> do
atomically $ writeTVar notBefore $ (`addUTCTime` now) . fromIntegral <$> retryAfter_
loop
SROFailed -> pure False
where
attempt = do
user <- withStore $ \db -> getUser db userId
@@ -5482,21 +5512,9 @@ creditStoreReceipt userId HeldStoreReceipt {receiptId, stash = stash@BadgeStash
payment' <- maybe (throwChatError $ CEInternalError "held store payment does not decode") pure $ decodeJSON payment
requestBadgeStash NRMBackground user sendTarget stash BSCPurchaseBadge {masterKey, payment = payment', upgrade = Nothing}
serviceFailure code retryAfter = BIFServiceError {code = boundedServiceErrorCode code, retryable = isJust retryAfter}
defer failure retryAfter = do
ri <- asks $ badgeRetryInterval . config
now <- badgeNow
let (delay, retryDelay') = storeReceiptRetry ri failure retryAfter retryDelay
withStore' $ \db -> deferStoreReceipt db receiptId (delay `addUTCTime` now) retryDelay' failure
-- | The service's retryAfter when given; otherwise a failure that can clear grows its own delay, and any
-- other waits a day. Unlike a renewal, an answered internal grows too: a buyer is watching the purchase.
storeReceiptRetry :: RetryInterval -> BadgeIssueFailure -> Maybe Word32 -> Maybe Int64 -> (NominalDiffTime, Maybe Int64)
storeReceiptRetry ri@RetryInterval {initialInterval} failure retryAfter retryDelay
| isJust retryAfter = (badgeRetryAfter ri retryAfter, retryDelay)
| badgeFailureTransient failure = (fromIntegral delay / 1000000, Just delay)
| otherwise = (badgeStalledInterval, retryDelay)
where
delay = maybe initialInterval (\d -> nextRetryDelay 0 d ri) retryDelay
failed delay failure retryAfter = do
withStore' $ \db -> recordStoreReceiptFailure db receiptId delay failure
pure $ if badgeFailureTransient failure then SRORetry retryAfter else SROFailed
-- | Retire what has ended, renew what is due, then report the next wake. Waking early, late or not
-- at all changes only timing: each run reads stored state and works out what to do.
+24 -35
View File
@@ -26,9 +26,8 @@ module Simplex.Chat.Store.Badges
holdStoreReceipt,
createBadgeStoreReceipt,
getOpenStorePurchases,
getDueStoreReceipts,
getNextStoreReceiptAttempt,
deferStoreReceipt,
getNextDueStoreReceipt,
recordStoreReceiptFailure,
refuseStoreReceipt,
deleteUnfundedStoreReceipts,
deleteBadgeCodeRedemption,
@@ -50,7 +49,10 @@ import qualified Data.Aeson as J
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Int (Int64)
import Data.List (find)
import Data.Maybe (isJust, mapMaybe)
import Data.Set (Set)
import qualified Data.Set as S
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Simplex.Chat.Badges
@@ -59,7 +61,7 @@ import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType
import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..), OpenStorePurchase (..))
import Simplex.Chat.PaymentService (ServicePayment)
import Simplex.Chat.PaymentService.Types (StoreTransactionRef (..))
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Chat.Store.Shared (StoreError, insertedRowId)
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol (UserId)
import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..))
@@ -139,8 +141,8 @@ holdStoreReceipt db g User {userId} invoiceId_ txRef@StoreTransactionRef {provid
forM_ invoiceId_ $ \invoiceId ->
DB.execute
db
"UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, payment = ?, next_attempt_at = ? WHERE invoice_id = ? AND transaction_ref IS NULL"
(provider, transactionRef, paymentJSON, now, invoiceId)
"UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, payment = ? WHERE invoice_id = ? AND transaction_ref IS NULL"
(provider, transactionRef, paymentJSON, invoiceId)
getStoreReceipt db txRef >>= \case
Just r -> pure $ Just (r, True)
Nothing -> do
@@ -160,11 +162,11 @@ holdStoreReceipt db g User {userId} invoiceId_ txRef@StoreTransactionRef {provid
db
[sql|
INSERT INTO badge_store_receipts
(user_id, invoice_id, provider, transaction_ref, payment, next_attempt_at, purchase_key, purchase_priv_key, master_key, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
(user_id, invoice_id, provider, transaction_ref, payment, purchase_key, purchase_priv_key, master_key, created_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT DO NOTHING
|]
((userId, invoiceId', provider, transactionRef, paymentJSON, now) :. (purchaseKey, purchasePrivKey, Binary mk, now))
((userId, invoiceId', provider, transactionRef, paymentJSON) :. (purchaseKey, purchasePrivKey, Binary mk, now))
-- | held is a CASE because in Postgres a comparison is boolean, which BoolInt rejects.
getStoreReceipt :: DB.Connection -> StoreTransactionRef -> IO (Maybe StoreReceipt)
@@ -219,48 +221,35 @@ data HeldStoreReceipt = HeldStoreReceipt
retryDelay :: Maybe Int64
}
-- | Due only from next_attempt_at, so a pass a signal starts cannot send a deferred receipt early.
getDueStoreReceipts :: DB.Connection -> UserId -> UTCTime -> IO [HeldStoreReceipt]
getDueStoreReceipts db userId now =
map toHeld
getNextDueStoreReceipt :: DB.Connection -> UserId -> Set Int64 -> IO (Either StoreError (Maybe HeldStoreReceipt))
getNextDueStoreReceipt db userId parked =
Right . find notParked . map toHeld
<$> DB.query
db
[sql|
SELECT r.badge_store_receipt_id, r.purchase_key, r.purchase_priv_key, r.master_key, r.payment, r.retry_delay
FROM badge_store_receipts r
JOIN users u ON u.user_id = r.user_id
WHERE r.user_id = ? AND r.payment IS NOT NULL AND r.next_attempt_at <= ? AND u.shown_badge_id IS NULL
ORDER BY r.next_attempt_at
WHERE r.user_id = ? AND r.payment IS NOT NULL AND u.shown_badge_id IS NULL
ORDER BY r.badge_store_receipt_id
|]
(userId, now)
(Only userId)
where
notParked HeldStoreReceipt {receiptId} = receiptId `S.notMember` parked
toHeld (stashRow@(receiptId, _, _, _) :. (payment, retryDelay)) =
HeldStoreReceipt {receiptId, stash = toBadgeStash BSRStoreReceipt stashRow, payment, retryDelay}
getNextStoreReceiptAttempt :: DB.Connection -> UserId -> IO (Maybe UTCTime)
getNextStoreReceiptAttempt db userId =
fmap join . maybeFirstRow fromOnly $
DB.query
db
[sql|
SELECT MIN(r.next_attempt_at)
FROM badge_store_receipts r
JOIN users u ON u.user_id = r.user_id
WHERE r.user_id = ? AND r.payment IS NOT NULL AND u.shown_badge_id IS NULL
|]
(Only userId)
deferStoreReceipt :: DB.Connection -> Int64 -> UTCTime -> Maybe Int64 -> BadgeIssueFailure -> IO ()
deferStoreReceipt db receiptId nextAttemptAt retryDelay failure =
recordStoreReceiptFailure :: DB.Connection -> Int64 -> Int64 -> BadgeIssueFailure -> IO ()
recordStoreReceiptFailure db receiptId retryDelay failure =
DB.execute
db
"UPDATE badge_store_receipts SET next_attempt_at = ?, retry_delay = ?, credit_error = ? WHERE badge_store_receipt_id = ? AND payment IS NOT NULL"
(nextAttemptAt, retryDelay, failure, receiptId)
"UPDATE badge_store_receipts SET retry_delay = ?, credit_error = ? WHERE badge_store_receipt_id = ? AND payment IS NOT NULL"
(retryDelay, failure, receiptId)
-- | Kept rather than deleted, so that a later hand-over of the same transaction is answered from the record.
refuseStoreReceipt :: DB.Connection -> Int64 -> BadgeIssueFailure -> IO ()
refuseStoreReceipt db receiptId refusal =
DB.execute db "UPDATE badge_store_receipts SET payment = NULL, credit_error = ? WHERE badge_store_receipt_id = ?" (refusal, receiptId)
DB.execute db "UPDATE badge_store_receipts SET payment = NULL, retry_delay = NULL, credit_error = ? WHERE badge_store_receipt_id = ?" (refusal, receiptId)
-- | A held record is paid for and a credited one is referenced by its purchase, so neither is deleted.
deleteUnfundedStoreReceipts :: DB.Connection -> UTCTime -> IO ()
@@ -296,7 +285,7 @@ deleteBadgeCodeRedemption db User {userId} code =
createStashBadgePurchase :: DB.Connection -> User -> BadgeStash -> BadgeCredential -> UTCTime -> IO (Int64, Bool)
createStashBadgePurchase db User {userId} stash credential now = do
-- the credit settles a held receipt in the transaction that stores its purchase, a replay included
forM_ storeReceiptId_ $ \storeReceiptId -> DB.execute db "UPDATE badge_store_receipts SET payment = NULL WHERE badge_store_receipt_id = ?" (Only storeReceiptId)
forM_ storeReceiptId_ $ \storeReceiptId -> DB.execute db "UPDATE badge_store_receipts SET payment = NULL, retry_delay = NULL WHERE badge_store_receipt_id = ?" (Only storeReceiptId)
getStashBadgePurchase db stash >>= \case
Just purchaseId -> pure (purchaseId, False)
Nothing -> do
@@ -24,7 +24,6 @@ CREATE TABLE badge_store_receipts(
master_key BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
payment TEXT,
next_attempt_at TIMESTAMPTZ,
retry_delay BIGINT,
credit_error TEXT,
UNIQUE(provider, transaction_ref)
@@ -307,7 +307,6 @@ CREATE TABLE test_chat_schema.badge_store_receipts (
master_key bytea NOT NULL,
created_at timestamp with time zone NOT NULL,
payment text,
next_attempt_at timestamp with time zone,
retry_delay bigint,
credit_error text
);
@@ -23,7 +23,6 @@ CREATE TABLE badge_store_receipts(
master_key BLOB NOT NULL,
created_at TEXT NOT NULL,
payment TEXT,
next_attempt_at TEXT,
retry_delay INTEGER,
credit_error TEXT,
UNIQUE(provider, transaction_ref)
@@ -993,7 +993,6 @@ CREATE TABLE badge_store_receipts(
master_key BLOB NOT NULL,
created_at TEXT NOT NULL,
payment TEXT,
next_attempt_at TEXT,
retry_delay INTEGER,
credit_error TEXT,
UNIQUE(provider, transaction_ref)
+92 -118
View File
@@ -18,7 +18,7 @@ import BadgeService.Options
import BadgeService.Service
import BadgeService.Store (NewStorePurchase (..), createStorePurchase)
import BadgeService.Store.Invoices (markCodePaid)
import BadgeService.StoreReceipts (StoreVerifier, noStoreVerifier)
import BadgeService.StoreReceipts (StoreRefusal (..), StoreVerifier (..), noStoreVerifier)
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import qualified Simplex.Messaging.Agent.Store.DB as DB
import ChatClient
@@ -39,6 +39,7 @@ import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.List (isPrefixOf, stripPrefix)
import qualified Data.Map.Strict as M
import Data.Maybe (isJust, isNothing)
import qualified Data.Set as S
import Data.String (fromString)
import System.Timeout (timeout)
import Data.Text (Text)
@@ -57,7 +58,7 @@ import Simplex.Chat.Options (CoreChatOpts (..))
import Simplex.Chat.Options.DB
import Simplex.Chat.PaymentService (ServicePayment (..))
import Simplex.Chat.PaymentService.Types (InvoiceId (..), PaymentProvider (..))
import Simplex.Chat.Store.Badges (getDueStoreReceipts, getNextStoreReceiptAttempt)
import Simplex.Chat.Store.Badges (getNextDueStoreReceipt)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..))
import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..))
import Simplex.Messaging.Agent.Store.Common (withTransaction)
@@ -153,14 +154,13 @@ badgeServiceTests = do
it "should refuse an invoice with no service configured or while a badge is held, creating no record" testInvoiceRefusedBeforeCharge
it "should hold a receipt for an invoice while a badge is held, without attempting it" testInvoiceWhileBadgeHeld
it "should list only the asking profile's open store purchases" testInvoiceStateOtherProfile
it "should credit a receipt the store could not verify once its retryAfter has passed, with no second hand-over" testStoreReceiptRetried
it "should send nothing for a deferred receipt handed over again before it is due" testStoreReceiptNotSentEarly
it "should retry a receipt with no verifier a day later, and never drop it" testStoreReceiptRetriedDaily
it "should credit a receipt the store could not verify once its retryAfter has passed, with no trigger at all" testStoreReceiptRetried
it "should keep a receipt with no verifier held, and attempt it again only on the next trigger" testStoreReceiptRetriedOnTrigger
it "should answer a refused receipt from the record, sending nothing, and not list it" testStoreRefusalKept
it "should announce a settlement to an owner that is not active, and print nothing for a hidden one" testStoreSettledOtherProfile
it "should resolve one new transaction handed over twice at once to one record" testStoreReceiptTwoAtOnce
it "should delete a month-old record that will fund no badge, and keep held and credited ones" testStoreReceiptCleanup
it "should defer a receipt whose attempt throws, and not send it again in the pass" testStoreReceiptAttemptThrows
it "should keep a receipt whose attempt throws, sending nothing" testStoreReceiptAttemptThrows
badgeProfile :: Profile
badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
@@ -1592,32 +1592,28 @@ testStoreVerifiedOtherTransaction ps =
testStoreQuantityRefused :: HasCallStack => TestParams -> IO ()
testStoreQuantityRefused ps =
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClientCfg, bsClock, bsController = cc, bsStore = FakeStore {appleQuantityJWS}} -> do
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClientCfg, bsController = cc, bsStore = FakeStore {appleQuantityJWS}} -> do
(purchaseKey, masterKey) <- newPurchaseKeys
refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey SPApple {jws = appleQuantityJWS}) `shouldReturn` (BSEInternal, Nothing)
nothingPurchased cc
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
since <- testClockTime bsClock
alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleQuantityJWS})
storePurchaseOpen alice ""
(at, failure) <- waitStoreReceiptDeferred (chatController alice) since
failure `shouldBe` "service_error final internal"
at `shouldSatisfy` (< addUTCTime 60 since)
waitStoreReceiptError (chatController alice) "service_error final internal"
heldStoreReceipts (chatController alice) `shouldReturn` 1
nothingPurchased cc
testPurchaseWithNoVerifier :: HasCallStack => TestParams -> IO ()
testPurchaseWithNoVerifier ps =
withBadgeServiceVerifier ps (const noStoreVerifier) $ \env@BadgeServiceEnv {bsClientCfg, bsClock, bsController = cc} -> do
withBadgeServiceVerifier ps (const noStoreVerifier) $ \env@BadgeServiceEnv {bsClientCfg, bsController = cc} -> do
(purchaseKey, masterKey) <- newPurchaseKeys
refusalOf <$> serviceCmd env purchaseKey (purchaseCmd masterKey supporterPlay) `shouldReturn` (BSEProviderNotConfigured, Nothing)
nothingPurchased cc
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
since <- testClockTime bsClock
alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay)
storePurchaseOpen alice ""
-- not yet rather than never, so the receipt stays held for the retry once a verifier is deployed
snd <$> waitStoreReceiptDeferred (chatController alice) since `shouldReturn` "service_error final provider_not_configured"
waitStoreReceiptError (chatController alice) "service_error final provider_not_configured"
heldStoreReceipts (chatController alice) `shouldReturn` 1
alice ##> "/_badge state 1"
alice .<## ", not credited: service_error final provider_not_configured"
@@ -1716,7 +1712,7 @@ testPurchaseBadgeAppStore ps =
testPurchaseStash :: HasCallStack => TestParams -> IO ()
testPurchaseStash ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock, bsController = cc, bsStore = store} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do
let records = rowCount (chatController alice) "badge_store_receipts"
refused = "/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase")
-- the store does not vouch for it, so it can never be credited, and the record says so
@@ -1732,12 +1728,12 @@ testPurchaseStash ps =
records `shouldReturn` 1
nothingPurchased cc
-- pending keeps the receipt held, and the settled purchase is credited to its keys, once
since <- testClockTime bsClock
alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" googlePendingToken))
storePurchaseOpen alice ""
snd <$> waitStoreReceiptDeferred (chatController alice) since `shouldReturn` "service_error retry payment_pending"
waitStoreReceiptError (chatController alice) "service_error retry payment_pending"
settlePending store
retryStoreReceiptsAfter alice bsClock 300 $ creditedLines "" "1: supporter"
passRetryAfter bsClock
mapM_ (alice <##.) $ creditedLines "" "1: supporter"
records `shouldReturn` 2
heldStoreReceipts (chatController alice) `shouldReturn` 0
rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1
@@ -1763,12 +1759,11 @@ testPurchaseStashReceiptUsed ps =
testPurchaseUnsentPlayToken :: HasCallStack => TestParams -> IO ()
testPurchaseUnsentPlayToken ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock, bsController = cc} ->
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsController = cc} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
since <- testClockTime bsClock
alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "token/other"))
storePurchaseOpen alice ""
snd <$> waitStoreReceiptDeferred (chatController alice) since `shouldReturn` "service_error retry provider_unavailable"
waitStoreReceiptError (chatController alice) "service_error retry provider_unavailable"
heldStoreReceipts (chatController alice) `shouldReturn` 1
nothingPurchased cc
@@ -1781,7 +1776,7 @@ testPurchaseWhileBadgeHeld ps =
alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay)
alice <##. "1: supporter"
storePurchaseOpen alice ""
selectableStoreReceipts (chatController alice) 1 `shouldReturn` (0, Nothing)
storeReceiptDue (chatController alice) 1 `shouldReturn` False
heldStoreReceipts (chatController alice) `shouldReturn` 1
rowCount cc "sx_badge_service_payments" `shouldReturn` 0
@@ -1803,19 +1798,19 @@ testPurchaseSameReceiptOtherProfile ps =
testPurchaseStrandedUnderOtherProfile :: HasCallStack => TestParams -> IO ()
testPurchaseStrandedUnderOtherProfile ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock, bsController = cc, bsStore = store} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do
let unsettled userId = "/_badge purchase " <> show (userId :: Int) <> " " <> paymentArg (googlePayment "badge_supporter_01" googlePendingToken)
since <- testClockTime bsClock
alice ##> unsettled 1
storePurchaseOpen alice ""
_ <- waitStoreReceiptDeferred (chatController alice) since
waitStoreReceiptError (chatController alice) "service_error retry payment_pending"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
settlePending store
-- handed over again under whichever profile is active, the purchase stays with the keys alice holds
alice ##> unsettled 2
storePurchaseOpen alice "[user: alice] "
retryStoreReceiptsAfter alice bsClock 300 $ creditedLines "[user: alice] " "1: supporter"
passRetryAfter bsClock
mapM_ (alice <##.) $ creditedLines "[user: alice] " "1: supporter"
(alice </)
rowCount (chatController alice) "badge_store_receipts" `shouldReturn` 1
rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1
@@ -1825,12 +1820,11 @@ testPurchaseStrandedUnderOtherProfile ps =
testPurchaseDeliveredToHiddenProfile :: HasCallStack => TestParams -> IO ()
testPurchaseDeliveredToHiddenProfile ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock, bsStore = store} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do
let unsettled userId = "/_badge purchase " <> show (userId :: Int) <> " " <> paymentArg (googlePayment "badge_supporter_01" googlePendingToken)
since <- testClockTime bsClock
alice ##> unsettled 1
storePurchaseOpen alice ""
_ <- waitStoreReceiptDeferred (chatController alice) since
waitStoreReceiptError (chatController alice) "service_error retry payment_pending"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/_hide user 1 \"password\""
@@ -1841,7 +1835,7 @@ testPurchaseDeliveredToHiddenProfile ps =
-- the answer and the credit are the hidden owner's, so the view prints none of them
alice ##> unsettled 2
(alice </)
retryStoreReceiptsAfter alice bsClock 300 []
passRetryAfter bsClock
waitHeldStoreReceipts (chatController alice) 0
(alice </)
alice ##> "/user alice password"
@@ -1905,21 +1899,19 @@ testInvoiceAgedOut ps =
unpaid <- createInvoice alice 1
presented <- createInvoice alice 1
refused <- createInvoice alice 1
since <- testClockTime clock
alice ##> purchaseWithInvoice 1 presented (googlePayment "badge_supporter_01" googlePendingToken)
alice <## ("store purchase open: invoice " <> unpaid)
alice <##. ("store purchase open: invoice " <> presented <> ", transaction ")
alice <## ("store purchase open: invoice " <> refused)
_ <- waitStoreReceiptDeferred (chatController alice) since
alice ##> purchaseWithInvoice 1 refused (googlePayment "badge_supporter_01" "not-a-purchase")
inAnyOrder
alice
[ [ "store purchase open: invoice " <> unpaid,
"store purchase open: invoice " <> presented <> ", transaction ",
"store purchase open: invoice " <> presented,
"store purchase open: invoice " <> refused <> ", transaction "
],
["store purchase settled"]
]
alice ##> purchaseWithInvoice 1 presented (googlePayment "badge_supporter_01" googlePendingToken)
alice <## ("store purchase open: invoice " <> unpaid)
alice <##. ("store purchase open: invoice " <> presented <> ", transaction ")
waitStoreReceiptError (chatController alice) "service_error retry payment_pending"
-- no age hides a record: one with no receipt and one held stay listed, and only a settled one goes
getCurrentTime >>= setClockAt clock . addUTCTime (8 * nominalDay)
alice ##> "/_badge state 1"
@@ -1951,7 +1943,7 @@ testInvoiceWhileBadgeHeld ps =
alice ##> purchaseWithInvoice 1 invoiceId supporterPlay
alice <##. "1: supporter"
alice <##. ("store purchase open: invoice " <> invoiceId <> ", transaction ")
selectableStoreReceipts (chatController alice) 1 `shouldReturn` (0, Nothing)
storeReceiptDue (chatController alice) 1 `shouldReturn` False
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack invoiceId), True)]
heldStoreReceipts (chatController alice) `shouldReturn` 1
rowCount cc "sx_badge_service_payments" `shouldReturn` 0
@@ -1973,54 +1965,38 @@ testInvoiceStateOtherProfile ps =
(alice </)
testStoreReceiptRetried :: HasCallStack => TestParams -> IO ()
testStoreReceiptRetried ps =
withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg, bsClock, bsController = cc, bsStore = store} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
setGoogleDown store True
since <- testClockTime bsClock
alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay)
storePurchaseOpen alice ""
(at, failure) <- waitStoreReceiptDeferred (chatController alice) since
failure `shouldBe` "service_error retry provider_unavailable"
at `shouldSatisfy` (> addUTCTime 290 since)
setGoogleDown store False
retryStoreReceiptsAfter alice bsClock 300 $ creditedLines "" "1: supporter"
rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1
testStoreReceiptNotSentEarly :: HasCallStack => TestParams -> IO ()
testStoreReceiptNotSentEarly ps = do
testStoreReceiptRetried ps = do
hook <- newIORef (pure ())
verifications <- countGoogleVerifications hook
withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \BadgeServiceEnv {bsClientCfg, bsClock, bsStore = store} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \BadgeServiceEnv {bsClientCfg, bsClock, bsController = cc, bsStore = store} ->
withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do
setGoogleDown store True
let purchase = "/_badge purchase 1 " <> paymentArg supporterPlay
since <- testClockTime bsClock
alice ##> purchase
storePurchaseOpen alice ""
deferred <- waitStoreReceiptDeferred (chatController alice) since
verifications `shouldReturn` 1
alice ##> purchase
storePurchaseOpen alice ""
alice ##> "/_app activate"
alice <## "ok"
(alice </)
verifications `shouldReturn` 1
waitStoreReceiptDeferred (chatController alice) since `shouldReturn` deferred
testStoreReceiptRetriedDaily :: HasCallStack => TestParams -> IO ()
testStoreReceiptRetriedDaily ps =
withBadgeServiceVerifier ps (const noStoreVerifier) $ \BadgeServiceEnv {bsClientCfg, bsClock} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
since <- testClockTime bsClock
alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay)
storePurchaseOpen alice ""
(firstAt, _) <- waitStoreReceiptDeferred (chatController alice) since
firstAt `shouldSatisfy` (> addUTCTime (nominalDay - 60) since)
retryStoreReceiptsAfter alice bsClock nominalDay []
(secondAt, failure) <- waitStoreReceiptDeferred (chatController alice) firstAt
failure `shouldBe` "service_error final provider_not_configured"
secondAt `shouldSatisfy` (> addUTCTime (nominalDay - 60) firstAt)
waitStoreReceiptError (chatController alice) "service_error retry provider_unavailable"
setGoogleDown store False
threadDelay 1000000
verifications `shouldReturn` 1
passRetryAfter bsClock
mapM_ (alice <##.) $ creditedLines "" "1: supporter"
verifications `shouldReturn` 2
rowCount cc "sx_badge_service_badge_purchases" `shouldReturn` 1
testStoreReceiptRetriedOnTrigger :: HasCallStack => TestParams -> IO ()
testStoreReceiptRetriedOnTrigger ps = do
hook <- newIORef (pure ())
verifications <- countGoogleVerifications hook
withBadgeServiceVerifier ps (notConfiguredGoogle hook) $ \BadgeServiceEnv {bsClientCfg} ->
withNewTestChatCfg ps (fastRetryCfg bsClientCfg) "alice" aliceProfile $ \alice -> do
alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay)
storePurchaseOpen alice ""
waitStoreReceiptError (chatController alice) "service_error final provider_not_configured"
threadDelay 1000000
verifications `shouldReturn` 1
alice ##> "/_app activate"
alice <## "ok"
waitGoogleVerifications verifications 2
(alice </)
heldStoreReceipts (chatController alice) `shouldReturn` 1
testStoreRefusalKept :: HasCallStack => TestParams -> IO ()
@@ -2083,13 +2059,12 @@ testStoreReceiptCleanup ps =
unpaid <- createInvoice alice 1
alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" "not-a-purchase"))
inAnyOrder alice [["store purchase open: invoice " <> unpaid, openPurchase ""], ["store purchase settled"]]
since <- testClockTime bsClock
alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS})
inAnyOrder alice [["store purchase open: invoice " <> unpaid, openPurchase ""], creditedLines "" "1: supporter"]
alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" googleUnreachableToken))
alice <##. "1: supporter"
alice <## ("store purchase open: invoice " <> unpaid)
storePurchaseOpen alice ""
_ <- waitStoreReceiptDeferred (chatController alice) since
alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS})
inAnyOrder alice [["store purchase open: invoice " <> unpaid, openPurchase "", openPurchase ""], creditedLines "" "1: supporter"]
storeReceiptRows (chatController alice) `shouldReturn` [(1, Just (T.pack unpaid), False), (1, Nothing, True), (1, Nothing, True), (1, Nothing, True)]
-- a month on, the record no receipt reached and the refusal go; the credited and the held one stay
testClockTime bsClock >>= setClockAt bsClock . addUTCTime (31 * nominalDay)
@@ -2109,20 +2084,17 @@ testStoreReceiptAttemptThrows :: HasCallStack => TestParams -> IO ()
testStoreReceiptAttemptThrows ps = do
hook <- newIORef (pure ())
verifications <- countGoogleVerifications hook
withBadgeServiceVerifier ps (googleVerifierWithHook hook) $ \BadgeServiceEnv {bsClientCfg, bsClock, bsStore = store} ->
withBadgeServiceVerifier ps (notConfiguredGoogle hook) $ \BadgeServiceEnv {bsClientCfg} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
setGoogleDown store True
since <- testClockTime bsClock
alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay)
storePurchaseOpen alice ""
(firstAt, _) <- waitStoreReceiptDeferred (chatController alice) since
waitStoreReceiptError (chatController alice) "service_error final provider_not_configured"
verifications `shouldReturn` 1
-- a stored payment this version cannot read makes the attempt throw before anything is sent
setStoreReceiptPayment (chatController alice) "not a payment"
retryStoreReceiptsAfter alice bsClock 300 []
(secondAt, failure) <- waitStoreReceiptDeferred (chatController alice) firstAt
failure `shouldBe` "unexpected held store payment does not decode"
secondAt `shouldSatisfy` (> addUTCTime (nominalDay - 60) firstAt)
alice ##> "/_app activate"
alice <## "ok"
waitStoreReceiptError (chatController alice) "unexpected held store payment does not decode"
(alice </)
verifications `shouldReturn` 1
heldStoreReceipts (chatController alice) `shouldReturn` 1
@@ -2152,13 +2124,10 @@ waitStoreReceiptRows cc n = loop (100 :: Int)
rows <- storeReceiptRows cc
if length rows == n then pure rows else threadDelay 50000 >> loop (i - 1)
-- | The receipts the owner's worker could select, and when it would wake for them. Read two days ahead, so
-- a receipt the worker has already deferred by a day is still counted.
selectableStoreReceipts :: ChatController -> Int64 -> IO (Int, Maybe UTCTime)
selectableStoreReceipts ChatController {chatStore} userId = do
later <- addUTCTime (2 * nominalDay) <$> getCurrentTime
storeReceiptDue :: ChatController -> Int64 -> IO Bool
storeReceiptDue ChatController {chatStore} userId =
withTransaction chatStore $ \db ->
(,) <$> (length <$> getDueStoreReceipts db userId later) <*> getNextStoreReceiptAttempt db userId
getNextDueStoreReceipt db userId S.empty >>= either (error . show) (pure . isJust)
heldStoreReceipts :: ChatController -> IO Int
heldStoreReceipts ChatController {chatStore} =
@@ -2172,31 +2141,30 @@ waitHeldStoreReceipts cc n = loop (100 :: Int)
loop 0 = heldStoreReceipts cc >>= (`shouldBe` n)
loop i = heldStoreReceipts cc >>= \held -> if held == n then pure () else threadDelay 50000 >> loop (i - 1)
-- | The next attempt and the failure of the receipt deferred last, once an attempt made after since has
-- deferred it - every deferral is at least the retry floor ahead, which a fresh hand-over's due time is not.
waitStoreReceiptDeferred :: HasCallStack => ChatController -> UTCTime -> IO (UTCTime, Text)
waitStoreReceiptDeferred ChatController {chatStore} since = loop (100 :: Int)
waitStoreReceiptError :: HasCallStack => ChatController -> Text -> IO ()
waitStoreReceiptError ChatController {chatStore} failure = loop (100 :: Int)
where
loop i = do
rows :: [(UTCTime, Text)] <-
withTransaction chatStore $ \db ->
DB.query_ db "SELECT next_attempt_at, credit_error FROM badge_store_receipts WHERE credit_error IS NOT NULL ORDER BY next_attempt_at DESC LIMIT 1"
case rows of
[r@(at, _)] | at > addUTCTime 10 since -> pure r
_ | i == 0 -> error $ "expected a store receipt deferred after " <> show since <> ", got " <> show rows
| otherwise -> threadDelay 50000 >> loop (i - 1)
loop 0 = heldFailures >>= (`shouldContain` [failure])
loop i = heldFailures >>= \failures -> if failure `elem` failures then pure () else threadDelay 50000 >> loop (i - 1)
heldFailures =
withTransaction chatStore $ \db ->
map fromOnly <$> DB.query_ db "SELECT credit_error FROM badge_store_receipts WHERE payment IS NOT NULL AND credit_error IS NOT NULL"
setStoreReceiptPayment :: ChatController -> Text -> IO ()
setStoreReceiptPayment ChatController {chatStore} payment =
withTransaction chatStore $ \db -> DB.execute db "UPDATE badge_store_receipts SET payment = ?" (Only payment)
-- | Moves the badge clock past a deferred receipt's next attempt, and signals the worker as the app's
-- return to the foreground does; the events the worker prints may come before or after the "ok".
retryStoreReceiptsAfter :: HasCallStack => TestCC -> TestClock -> NominalDiffTime -> [String] -> IO ()
retryStoreReceiptsAfter cc clock wait events = do
testClockTime clock >>= setClockAt clock . addUTCTime (wait + 1)
cc ##> "/_app activate"
inAnyOrder cc $ ["ok"] : [events | not (null events)]
-- | Past the 300 seconds the service asks a client to wait while a store is pending or unreachable.
passRetryAfter :: TestClock -> IO ()
passRetryAfter clock = testClockTime clock >>= setClockAt clock . addUTCTime 301
fastRetryCfg :: ChatConfig -> ChatConfig
fastRetryCfg cfg = cfg {badgeRetryInterval = RetryInterval {initialInterval = 100000, increaseAfter = 0, maxInterval = 500000}}
-- | Google answered as by a service with no Play verifier deployed, each verification counted by the hook.
notConfiguredGoogle :: IORef (IO ()) -> FakeStore -> StoreVerifier
notConfiguredGoogle hook store =
googleVerifierWithHook hook store {fakeVerifier = noStoreVerifier {verifyGoogle = Just $ \_ _ -> pure $ Left SRNotConfigured}}
-- | Each block's lines in order, the blocks in any order: the worker is signalled before the command that
-- signalled it has answered, so its events can print first. Every line is matched as a prefix.
@@ -2222,6 +2190,12 @@ countGoogleVerifications hook = do
writeIORef hook count
pure $ readIORef n
waitGoogleVerifications :: HasCallStack => IO Int -> Int -> IO ()
waitGoogleVerifications verifications n = loop (100 :: Int)
where
loop 0 = verifications `shouldReturn` n
loop i = verifications >>= \made -> if made == n then pure () else threadDelay 50000 >> loop (i - 1)
storePurchaseOpen :: HasCallStack => TestCC -> String -> IO ()
storePurchaseOpen cc userPrefix = cc <##. openPurchase userPrefix