mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 06:32:59 +00:00
store: merge balance storage
This commit is contained in:
commit
ba65a718fa
19 files changed
+1730
-135
No files matched your search
@@ -0,0 +1,56 @@
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
|
||||
module StoreService.Names.Balances
|
||||
( RefundRefusal (..),
|
||||
RefundRecord (..),
|
||||
recordRefund,
|
||||
)
|
||||
where
|
||||
|
||||
import Data.Text (Text)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction)
|
||||
import StoreService.Names.Phases (RegistrationId)
|
||||
import StoreService.Names.Store (Balance (..), BalanceHold, BalanceLink, balanceByReference, balanceChain, refundBalance)
|
||||
import StoreService.Names.Turns (Turns, withBalanceTurn)
|
||||
|
||||
-- | RefundAmount: the balance's amount now, which is not the cents given. RefundHeld: an order holds it, overdue or not,
|
||||
-- since only the poller's sweep can close a holder and cancel its invoice at the provider.
|
||||
data RefundRefusal = RefundNoOrder | RefundNoBalance | RefundAmount Int | RefundHeld BalanceHold
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | The chain names every order the money came through, for support to pay it back by the methods that paid it.
|
||||
data RefundRecord = RefundRecord
|
||||
{ rdRegistration :: RegistrationId,
|
||||
rdCents :: Int,
|
||||
rdChain :: [BalanceLink]
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | The reference is the order's registration id or its name invoice's id. In the balance's turn and one transaction.
|
||||
recordRefund :: Turns -> DBStore -> Text -> Int -> UTCTime -> IO (Either RefundRefusal RefundRecord)
|
||||
recordRefund turns st ref cents now =
|
||||
withTransaction st (\db -> balanceByReference db ref now) >>= \case
|
||||
Nothing -> pure $ Left RefundNoOrder
|
||||
Just (rid, _) -> withBalanceTurn turns rid $ withTransaction st (refund True)
|
||||
where
|
||||
refund retry db =
|
||||
balanceByReference db ref now >>= \case
|
||||
Nothing -> pure $ Left RefundNoOrder
|
||||
Just (rid, balance) -> case refusal balance of
|
||||
Just r -> pure $ Left r
|
||||
Nothing ->
|
||||
refundBalance db rid cents now >>= \case
|
||||
True -> Right . RefundRecord rid cents <$> balanceChain db rid
|
||||
-- only a writer outside the turn, as a late credit, changes the balance between the read and the write, so it is read again once
|
||||
False
|
||||
| retry -> refund False db
|
||||
| otherwise -> pure $ Left $ RefundAmount $ maybe 0 blAmount balance
|
||||
refusal :: Maybe Balance -> Maybe RefundRefusal
|
||||
refusal = \case
|
||||
Nothing -> Just RefundNoBalance
|
||||
Just Balance {blHold = Just hold} -> Just $ RefundHeld hold
|
||||
Just Balance {blAmount}
|
||||
| blAmount /= cents || blAmount <= 0 -> Just $ RefundAmount blAmount
|
||||
| otherwise -> Nothing
|
||||
@@ -48,7 +48,7 @@ import qualified Data.Aeson.KeyMap as KM
|
||||
import Data.Bifunctor (first)
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Int (Int64)
|
||||
import Data.Maybe (isJust)
|
||||
import Data.Maybe (catMaybes, isJust)
|
||||
import Data.Scientific (toBoundedInteger)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
@@ -70,7 +70,7 @@ import StoreService.Names.Env (NamesEnv (..))
|
||||
import StoreService.Names.Phases (Phases, RegistrationId (..))
|
||||
import StoreService.Names.Abi (bytes32FromHex, hex)
|
||||
import StoreService.Names.Signatures (OrderFields (..), orderMessage, parseChecksumAddress, parseSignature, personalDigest)
|
||||
import StoreService.Names.Store (LiveOrder (..), NamePhase (..), NameRegistration (..), NewRegistration (..), PrepaidCode (..), RegistrationPayment (..), RegistrationRefused (..), NameTransaction (..), TxFor (..), TxStatus (..), VersionWrite (..), closeOverdue, createCodeRegistration, getLiveOrders, getPrepaidCode, getPrepaidCodeById, getRegistrationByOrderHash, isLabelRegistering, registrationTransactions, retargetRegistration, updateRegistration)
|
||||
import StoreService.Names.Store (Balance, LiveOrder (..), NamePhase (..), NameRegistration (..), NewRegistration (..), PrepaidCode (..), RegistrationPayment (..), RegistrationRefused (..), NameTransaction (..), TxFor (..), TxStatus (..), VersionWrite (..), closeOverdue, createCodeRegistration, getBalance, getLiveOrders, getPrepaidCode, getPrepaidCodeById, getRegistration, getRegistrationByOrderHash, isLabelRegistering, registrationTransactions, retargetRegistration, updateRegistration)
|
||||
import StoreService.Names.Turns (Turns, withCodeTurn)
|
||||
|
||||
data OrdersEnv = OrdersEnv
|
||||
@@ -103,6 +103,7 @@ data OrderRefusal
|
||||
| ORCatalogChanged Int
|
||||
| ORCode CodeRefusal
|
||||
| ORPriceTooHigh Int
|
||||
| ORBalanceChanged (Maybe Balance)
|
||||
deriving (Eq, Show)
|
||||
|
||||
newtype OrderRefused = OrderRefused OrderRefusal
|
||||
@@ -193,10 +194,11 @@ overdueOrdersMet db OrderRequest {orOwner, orFields = OrderFields {ofLabelHash,
|
||||
closable LiveOrder {loRegistration = NameRegistration {nrOwner, nrLabelHash}, loOverdue} =
|
||||
loOverdue && nrOwner == orOwner && nrLabelHash == ofLabelHash
|
||||
|
||||
-- | In one transaction, the overdue orders the request meets are closed, then the order is inserted.
|
||||
-- | In one transaction, the overdue orders the request meets and the balance holders the caller names are closed, then the order is inserted.
|
||||
-- A named holder no longer live is skipped, and one still live that cannot be closed refuses with the balance it holds.
|
||||
-- Refused, changing nothing, when it meets an order it cannot close, or the insert refuses.
|
||||
placeOrder :: OrdersEnv -> OrderRequest -> (DB.Connection -> UTCTime -> IO (Either OrderRefusal ())) -> IO (Either OrderRefusal [ClosedOrder])
|
||||
placeOrder OrdersEnv {oeStore, oeNow} req insert = do
|
||||
placeOrder :: OrdersEnv -> OrderRequest -> [NameRegistration] -> (DB.Connection -> UTCTime -> IO (Either OrderRefusal ())) -> IO (Either OrderRefusal [ClosedOrder])
|
||||
placeOrder OrdersEnv {oeStore, oeNow} req holders insert = do
|
||||
now <- oeNow
|
||||
first (\(OrderRefused refusal) -> refusal) <$> E.try (withTransaction oeStore $ \db -> transaction db now)
|
||||
where
|
||||
@@ -204,12 +206,21 @@ placeOrder OrdersEnv {oeStore, oeNow} req insert = do
|
||||
overdueOrdersMet db req now >>= \case
|
||||
Just overdue -> do
|
||||
closed <- mapM (close db now) overdue
|
||||
insert db now >>= either (E.throwIO . OrderRefused) (const $ pure closed)
|
||||
closedHolders <- catMaybes <$> mapM (closeHolder db now) holders
|
||||
insert db now >>= either (E.throwIO . OrderRefused) (const $ pure $ closed <> closedHolders)
|
||||
Nothing -> E.throwIO $ OrderRefused OROrderExists
|
||||
close db now r =
|
||||
closeOverdue db r now >>= \case
|
||||
Just v -> pure ClosedOrder {coRegistration = r, coVersion = v}
|
||||
Nothing -> E.throwIO $ OrderRefused OROrderExists
|
||||
-- read again here, since another order of its own name may have closed it meanwhile
|
||||
closeHolder db now NameRegistration {nrId} =
|
||||
getRegistration db nrId >>= \case
|
||||
Just holder@NameRegistration {nrLive = True, nrBalanceSource} ->
|
||||
closeOverdue db holder now >>= \case
|
||||
Just v -> pure $ Just ClosedOrder {coRegistration = holder, coVersion = v}
|
||||
Nothing -> E.throwIO . OrderRefused . ORBalanceChanged =<< maybe (pure Nothing) (\source -> getBalance db source now) nrBalanceSource
|
||||
_ -> pure Nothing
|
||||
|
||||
-- | In the code's turn, the code as it reads there must pay for the name; the order's insert then reserves it.
|
||||
placeCodeOrder :: OrdersEnv -> OrderRequest -> RegistrationId -> PrepaidCode -> IO (Either OrderRefusal [ClosedOrder])
|
||||
@@ -220,7 +231,7 @@ placeCodeOrder env@OrdersEnv {oeStore, oeTurns, oeNow} req@OrderRequest {orOwner
|
||||
Nothing -> pure $ Left $ ORCode CRInvalid
|
||||
Just code
|
||||
| Just refusal <- codeRefusal now rid ofLength code -> pure $ Left $ ORCode refusal
|
||||
| otherwise -> placeOrder env req $ codeOrderInsert NewRegistration {nrgId = rid, nrgOwner = orOwner, nrgOrder = fields, nrgYears = pcYears} code
|
||||
| otherwise -> placeOrder env req [] $ codeOrderInsert NewRegistration {nrgId = rid, nrgOwner = orOwner, nrgOrder = fields, nrgYears = pcYears} code
|
||||
|
||||
-- | The code was checked in its turn, so only a writer outside this process can make its reservation fail.
|
||||
codeOrderInsert :: NewRegistration -> PrepaidCode -> DB.Connection -> UTCTime -> IO (Either OrderRefusal ())
|
||||
@@ -229,6 +240,8 @@ codeOrderInsert new@NewRegistration {nrgId, nrgOrder = OrderFields {ofLength}} c
|
||||
Right () -> pure $ Right ()
|
||||
Left RRLiveOrder -> pure $ Left OROrderExists
|
||||
Left RRCodeNotReservable -> Left . ORCode <$> reserveRefusal db now pcCodeHash nrgId ofLength
|
||||
-- a code order holds no balance
|
||||
Left RRBalanceChanged -> pure $ Left OROrderExists
|
||||
|
||||
checkTimestamp :: UTCTime -> Word64 -> Either OrderRefusal ()
|
||||
checkTimestamp now timestamp =
|
||||
@@ -267,6 +280,7 @@ unixSeconds = floor . utcTimeToPOSIXSeconds
|
||||
movable :: NameRegistration -> Bool
|
||||
movable NameRegistration {nrPhase, nrPayment, nrLinksRefusedAt} = case nrPhase of
|
||||
PTaken | RPInvoice {} <- nrPayment -> True
|
||||
PTaken | RPBalance <- nrPayment -> True
|
||||
PFailed -> True
|
||||
PCommitted -> isJust nrLinksRefusedAt
|
||||
_ -> False
|
||||
@@ -297,6 +311,7 @@ retargetOrder OrdersEnv {oeStore, oeTurns, oeNow} r@NameRegistration {nrId, nrPa
|
||||
let write db = versionWritten <$> retargetRegistration db r fields price now
|
||||
case nrPayment of
|
||||
RPInvoice {} -> withTransaction oeStore write
|
||||
RPBalance -> withTransaction oeStore write
|
||||
RPCode codeId ->
|
||||
withTransaction oeStore (`getPrepaidCodeById` codeId) >>= \case
|
||||
Nothing -> pure $ Left $ WRRefused $ ORCode CRInvalid
|
||||
@@ -343,6 +358,7 @@ confirmLanded OrdersEnv {oeStore, oeContracts, oeTurns, oeNow} r@NameRegistratio
|
||||
write db = versionWritten <$> updateRegistration db registered now
|
||||
case nrPayment of
|
||||
RPInvoice {} -> withTransaction oeStore write
|
||||
RPBalance -> withTransaction oeStore write
|
||||
RPCode codeId -> spendLandedCode oeTurns oeStore now codeId nrId write >>= traverse (paidWith codeId)
|
||||
paidWith codeId (v, spent) = do
|
||||
unless spent $ do
|
||||
|
||||
@@ -16,6 +16,14 @@ module StoreService.Names.Store
|
||||
VersionWrite (..),
|
||||
LiveOrder (..),
|
||||
OrderTerms (..),
|
||||
Balance (..),
|
||||
BalanceHold (..),
|
||||
BalanceLink (..),
|
||||
LateCredit (..),
|
||||
LateCreditState (..),
|
||||
LatePending (..),
|
||||
LateAlert (..),
|
||||
CleanupKept (..),
|
||||
PrepaidCode (..),
|
||||
NewPrepaidCode (..),
|
||||
CodeHashTaken (..),
|
||||
@@ -35,6 +43,7 @@ module StoreService.Names.Store
|
||||
textMethod,
|
||||
createPaidRegistration,
|
||||
createCodeRegistration,
|
||||
createBalanceRegistration,
|
||||
getRegistration,
|
||||
getRegistrationByOrderHash,
|
||||
getLiveOrders,
|
||||
@@ -47,6 +56,21 @@ module StoreService.Names.Store
|
||||
unpaidCleanupCandidates,
|
||||
deleteUnpaidRegistration,
|
||||
failedCodeOrdersBefore,
|
||||
heldBy,
|
||||
getBalance,
|
||||
overdueHolder,
|
||||
balanceByReference,
|
||||
balanceChain,
|
||||
creditBalance,
|
||||
spendHeldBalance,
|
||||
releaseHeldBalance,
|
||||
refundBalance,
|
||||
lateCreditState,
|
||||
creditLatePayment,
|
||||
latePending,
|
||||
uncreditedLateInvoices,
|
||||
lateAlertDue,
|
||||
claimLateAlert,
|
||||
insertPrepaidCode,
|
||||
getPrepaidCode,
|
||||
getPrepaidCodeById,
|
||||
@@ -78,9 +102,10 @@ import Data.ByteArray.Encoding (Base (Base16), convertToBase)
|
||||
import Data.ByteString (ByteString)
|
||||
import qualified Data.ByteString as B
|
||||
import Data.Int (Int64)
|
||||
import Data.Functor ((<&>))
|
||||
import Data.Maybe (isJust, listToMaybe)
|
||||
import Data.String (fromString)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Time.Clock (UTCTime)
|
||||
import Data.Word (Word64)
|
||||
@@ -138,9 +163,11 @@ instance FromField NamePhase where fromField = fromTextField_ textDecode
|
||||
|
||||
instance ToField NamePhase where toField = toField . textEncode
|
||||
|
||||
-- | RPBalance: an order paid wholly from another order's balance, with neither invoice nor code.
|
||||
data RegistrationPayment
|
||||
= RPInvoice ServicePaymentMethod InvoiceId
|
||||
| RPCode Int64
|
||||
| RPBalance
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | The name, links and owner's intent, all set by an accepted reveal and cleared together.
|
||||
@@ -168,6 +195,8 @@ data NameRegistration = NameRegistration
|
||||
nrYears :: Int,
|
||||
nrPrice :: Int,
|
||||
nrPayment :: RegistrationPayment,
|
||||
nrBalanceSource :: Maybe RegistrationId,
|
||||
nrBalanceUsed :: Int,
|
||||
nrReveal :: Maybe NameReveal,
|
||||
nrCommitTx :: Maybe TxHash,
|
||||
nrCommitBlock :: Maybe Word64,
|
||||
@@ -203,7 +232,8 @@ data PaidOrder = PaidOrder
|
||||
poProviderRef :: Text
|
||||
}
|
||||
|
||||
data RegistrationRefused = RRLiveOrder | RRCodeNotReservable
|
||||
-- | RRBalanceChanged: the source's balance no longer has the amount seen, or another order holds it.
|
||||
data RegistrationRefused = RRLiveOrder | RRCodeNotReservable | RRBalanceChanged
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | NotLive: the write would make a closed order live. LiveOrderExists: another live order holds its commitment, or its owner and label hash.
|
||||
@@ -217,8 +247,99 @@ data LiveOrder = LiveOrder
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | What an order pays with, as its hash covers it.
|
||||
data OrderTerms = OTInvoice ServicePaymentMethod Int | OTCode ByteString
|
||||
-- | What an order pays with, as its hash covers it. OTBalance: the paying order, the price, the balance seen, and the method of a difference.
|
||||
data OrderTerms = OTInvoice ServicePaymentMethod Int | OTCode ByteString | OTBalance RegistrationId Int Int (Maybe ServicePaymentMethod)
|
||||
|
||||
-- | Cents left to spend, and over the balance's life the cents credited and refunded.
|
||||
data Balance = Balance
|
||||
{ blRegistration :: RegistrationId,
|
||||
blAmount :: Int,
|
||||
blCredited :: Int,
|
||||
blRefunded :: Int,
|
||||
blHold :: Maybe BalanceHold,
|
||||
blRefundedAt :: Maybe UTCTime
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | The order whose open invoice holds a balance, until that invoice expires; overdue past it with no money received.
|
||||
data BalanceHold = BalanceHold
|
||||
{ bhHolder :: RegistrationId,
|
||||
bhUntil :: UTCTime,
|
||||
bhOverdue :: Bool
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | An order the money of a balance came through. lkPaid and lkFromBalance count only an order that went past unpaid,
|
||||
-- since closing an unpaid one released its source's balance.
|
||||
data BalanceLink = BalanceLink
|
||||
{ lkRegistration :: RegistrationId,
|
||||
lkPayment :: RegistrationPayment,
|
||||
lkPaid :: Int,
|
||||
lkFromBalance :: Int,
|
||||
lkLateCredits :: [LateCredit]
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | Money paid on a name invoice after its order closed, credited to that order's balance. Rates are decimal text; card money has no units or rates.
|
||||
data LateCredit = LateCredit
|
||||
{ lcInvoice :: InvoiceId,
|
||||
lcCreditNo :: Int,
|
||||
lcRegistration :: RegistrationId,
|
||||
lcUnits :: Maybe Int64,
|
||||
lcInvoiceRate :: Maybe Text,
|
||||
lcCurrentRate :: Maybe Text,
|
||||
lcRateUsed :: Maybe Text,
|
||||
lcCents :: Int
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | A name invoice's payment figures, what was credited from it, and its order's phase and live flag. Card money is counted in cents, crypto in units.
|
||||
data LateCreditState = LateCreditState
|
||||
{ lsInvoice :: InvoiceId,
|
||||
lsRegistration :: RegistrationId,
|
||||
lsPhase :: NamePhase,
|
||||
lsLive :: Bool,
|
||||
lsCard :: Bool,
|
||||
lsReceivedCents :: Int,
|
||||
lsConfirmedCents :: Int,
|
||||
lsReceivedUnits :: Maybe Int64,
|
||||
lsConfirmedUnits :: Maybe Int64,
|
||||
lsInvoiceRate :: Maybe Text,
|
||||
lsCreditedUnits :: Int64,
|
||||
lsCreditedCents :: Int,
|
||||
lsCredits :: Int,
|
||||
lsPaymentCreatedAt :: UTCTime
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | Why a closed order's money is not all credited: Monero confirming with a zero figure, confirmed money not credited yet,
|
||||
-- money received and not confirmed, or crypto received that could not be counted in units.
|
||||
data LatePending = LPConfirming | LPUncredited | LPUnconfirmed | LPUncounted
|
||||
deriving (Eq, Show, Enum, Bounded)
|
||||
|
||||
instance TextEncoding LatePending where
|
||||
textEncode = \case
|
||||
LPConfirming -> "confirming"
|
||||
LPUncredited -> "uncredited"
|
||||
LPUnconfirmed -> "unconfirmed"
|
||||
LPUncounted -> "uncounted"
|
||||
textDecode t = lookup t [(textEncode p, p) | p <- [minBound .. maxBound]]
|
||||
|
||||
instance FromField LatePending where fromField = fromTextField_ textDecode
|
||||
|
||||
-- | A closed order's pending money to alert on, with the received figure the alert names and the one the last alert named.
|
||||
data LateAlert = LateAlert
|
||||
{ laState :: LateCreditState,
|
||||
laPending :: LatePending,
|
||||
laReceived :: Maybe Int64,
|
||||
laAlertedAt :: Maybe UTCTime,
|
||||
laAlertedReceived :: Maybe Int64
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | Why the cleanup keeps an unpaid order: a payment was recorded for it, or it holds the balance of the given order.
|
||||
data CleanupKept = CKPaid | CKHolder RegistrationId
|
||||
deriving (Eq, Show)
|
||||
|
||||
data PrepaidCode = PrepaidCode
|
||||
{ pcId :: Int64,
|
||||
@@ -331,6 +452,7 @@ orderHash owner OrderFields {ofLabelHash = LabelHash lh, ofLength, ofDuration, o
|
||||
(tag, payment) = case terms of
|
||||
OTInvoice method price -> ("name-invoice", [encodeUtf8 $ methodText method, num price])
|
||||
OTCode codeHash -> ("name-code", [codeHash])
|
||||
OTBalance (RegistrationId source) price seen method -> ("name-balance", [encodeUtf8 source, num price, num seen, maybe "" (encodeUtf8 . methodText) method])
|
||||
field bs = i2ospOf_ orderHashFieldBytes (toInteger $ B.length bs) <> bs
|
||||
num :: Show a => a -> ByteString
|
||||
num = encodeUtf8 . tshow
|
||||
@@ -347,47 +469,70 @@ methodText = \case
|
||||
textMethod :: Text -> Maybe ServicePaymentMethod
|
||||
textMethod t = lookup t [(methodText m, m) | m <- [SPMCard CPStripe, SPMCrypto CCBtc, SPMCrypto CCXmr]]
|
||||
|
||||
createPaidRegistration :: DB.Connection -> NewRegistration -> PaidOrder -> UTCTime -> IO (Either RegistrationRefused ())
|
||||
createPaidRegistration db new@NewRegistration {nrgId = RegistrationId rid, nrgOwner, nrgOrder} PaidOrder {poMethod, poPrice, poInvoiceId = InvoiceId invId, poProviderRef} now' = do
|
||||
-- | A difference order (OTBalance) holds all of the balance seen while its invoice is open. The hold is written after the registration,
|
||||
-- so the registration's row is locked before the balance's.
|
||||
createPaidRegistration :: DB.Connection -> NewRegistration -> OrderTerms -> PaidOrder -> UTCTime -> IO (Either RegistrationRefused ())
|
||||
createPaidRegistration db new@NewRegistration {nrgId = regId@(RegistrationId rid), nrgOwner, nrgOrder} terms PaidOrder {poMethod, poPrice, poInvoiceId = InvoiceId invId, poProviderRef} now' = do
|
||||
let now = truncateToSecond now'
|
||||
hash = orderHash nrgOwner nrgOrder (OTInvoice poMethod poPrice)
|
||||
inserted <- insertRegistration db new PUnpaid poPrice (Just poMethod) Nothing hash now
|
||||
when inserted $
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO sx_badge_service_name_invoices (invoice_id, registration_id, provider_ref, created_at) VALUES (?,?,?,?)"
|
||||
(invId, rid, poProviderRef, now)
|
||||
pure $ if inserted then Right () else Left RRLiveOrder
|
||||
held = case terms of
|
||||
OTBalance source _ seen _ -> Just (source, seen)
|
||||
OTInvoice {} -> Nothing
|
||||
OTCode _ -> Nothing
|
||||
inserted <- insertRegistration db new PUnpaid poPrice (Just poMethod) Nothing held (orderHash nrgOwner nrgOrder terms) now
|
||||
if not inserted
|
||||
then pure $ Left RRLiveOrder
|
||||
else do
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO sx_badge_service_name_invoices (invoice_id, registration_id, provider_ref, created_at) VALUES (?,?,?,?)"
|
||||
(invId, rid, poProviderRef, now)
|
||||
held' <- maybe (pure True) (\(source, seen) -> holdBalance db source seen regId now) held
|
||||
if held' then pure $ Right () else Left RRBalanceChanged <$ deleteRegistration db regId
|
||||
|
||||
-- | Reserving the code is the conditional update of the transaction that creates the registration.
|
||||
createCodeRegistration :: DB.Connection -> NewRegistration -> PrepaidCode -> UTCTime -> IO (Either RegistrationRefused ())
|
||||
createCodeRegistration db new@NewRegistration {nrgId = regId@(RegistrationId rid), nrgOwner, nrgOrder} PrepaidCode {pcId, pcCodeHash} now' = do
|
||||
createCodeRegistration db new@NewRegistration {nrgId = regId, nrgOwner, nrgOrder} PrepaidCode {pcId, pcCodeHash} now' = do
|
||||
let now = truncateToSecond now'
|
||||
inserted <- insertRegistration db new PQueued 0 Nothing (Just pcId) (orderHash nrgOwner nrgOrder (OTCode pcCodeHash)) now
|
||||
inserted <- insertRegistration db new PQueued 0 Nothing (Just pcId) Nothing (orderHash nrgOwner nrgOrder (OTCode pcCodeHash)) now
|
||||
if not inserted
|
||||
then pure $ Left RRLiveOrder
|
||||
else
|
||||
reservePrepaidCode db pcId regId now >>= \case
|
||||
True -> pure $ Right ()
|
||||
False -> do
|
||||
DB.execute db "DELETE FROM sx_badge_service_name_registrations WHERE registration_id = ?" (Only rid)
|
||||
pure $ Left RRCodeNotReservable
|
||||
False -> Left RRCodeNotReservable <$ deleteRegistration db regId
|
||||
|
||||
-- | A queued order whose price is spent from the source's balance in the same transaction, where its amount is the one seen and nothing holds it.
|
||||
createBalanceRegistration :: DB.Connection -> NewRegistration -> RegistrationId -> Int -> Int -> UTCTime -> IO (Either RegistrationRefused ())
|
||||
createBalanceRegistration db new@NewRegistration {nrgId, nrgOwner, nrgOrder} source price seen now' = do
|
||||
let now = truncateToSecond now'
|
||||
inserted <- insertRegistration db new PQueued price Nothing Nothing (Just (source, price)) (orderHash nrgOwner nrgOrder (OTBalance source price seen Nothing)) now
|
||||
if not inserted
|
||||
then pure $ Left RRLiveOrder
|
||||
else
|
||||
spendBalance db source price seen now >>= \case
|
||||
True -> pure $ Right ()
|
||||
False -> Left RRBalanceChanged <$ deleteRegistration db nrgId
|
||||
|
||||
-- | The name invoice row goes with it.
|
||||
deleteRegistration :: DB.Connection -> RegistrationId -> IO ()
|
||||
deleteRegistration db (RegistrationId rid) = DB.execute db "DELETE FROM sx_badge_service_name_registrations WHERE registration_id = ?" (Only rid)
|
||||
|
||||
-- | False when the registration would be a second live one for its commitment, or for its owner and label hash.
|
||||
insertRegistration :: DB.Connection -> NewRegistration -> NamePhase -> Int -> Maybe ServicePaymentMethod -> Maybe Int64 -> ByteString -> UTCTime -> IO Bool
|
||||
insertRegistration db NewRegistration {nrgId = RegistrationId rid, nrgOwner, nrgOrder, nrgYears} phase price method codeId hash now =
|
||||
-- fromBalance: the order whose balance pays, and the cents it pays.
|
||||
insertRegistration :: DB.Connection -> NewRegistration -> NamePhase -> Int -> Maybe ServicePaymentMethod -> Maybe Int64 -> Maybe (RegistrationId, Int) -> ByteString -> UTCTime -> IO Bool
|
||||
insertRegistration db NewRegistration {nrgId = RegistrationId rid, nrgOwner, nrgOrder, nrgYears} phase price method codeId fromBalance hash now =
|
||||
(> 0)
|
||||
<$> executeChanging
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO sx_badge_service_name_registrations
|
||||
(registration_id, version, label_hash, length, duration, owner, commitment, sealed, phase, years,
|
||||
price, method, name_prepaid_code_id, live, order_hash, moves, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?, ?,?,?,?,?,?,?,?)
|
||||
price, method, name_prepaid_code_id, live, order_hash, moves, created_at, updated_at, balance_source, balance_used)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?, ?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT DO NOTHING
|
||||
|]
|
||||
( (rid, initialVersion, Binary lh, ofLength, int64 ofDuration, Binary $ unAddress nrgOwner, Binary ofCommitment, ofSealed, phase, nrgYears)
|
||||
:. (price, methodText <$> method, codeId, BI True, Binary hash, 0 :: Int, now, now)
|
||||
:. (price, methodText <$> method, codeId, BI True, Binary hash, 0 :: Int, now, now, (\(RegistrationId s, _) -> s) <$> fromBalance, maybe 0 snd fromBalance)
|
||||
)
|
||||
where
|
||||
OrderFields {ofLabelHash = LabelHash lh, ofLength, ofDuration, ofCommitment, ofSealed} = nrgOrder
|
||||
@@ -398,7 +543,7 @@ registrationColumns =
|
||||
SELECT r.registration_id, r.version, r.label_hash, r.length, r.duration, r.owner, r.commitment, r.sealed, r.phase, r.years,
|
||||
r.price, r.method, ni.invoice_id, r.name_prepaid_code_id, r.label, r.secret, r.contact_link, r.channel_link, r.intent_nonce, r.intent_deadline,
|
||||
r.intent_signature, r.commit_tx, r.commit_block, r.register_tx, r.register_block, r.reveal_after, r.reveal_before, r.expires_at, r.registered_at,
|
||||
r.failed_at, r.error, r.live, r.order_hash, r.moves, r.links_refused_at, r.created_at, r.updated_at
|
||||
r.failed_at, r.error, r.live, r.order_hash, r.moves, r.links_refused_at, r.created_at, r.updated_at, r.balance_source, r.balance_used
|
||||
|]
|
||||
|
||||
registrationFrom :: Query
|
||||
@@ -416,19 +561,20 @@ type RegistrationRow =
|
||||
(Text, Int64, Binary ByteString, Int, Int64, Binary ByteString, Binary ByteString, Text, NamePhase, Int)
|
||||
:. (Int, Maybe Text, Maybe Text, Maybe Int64, Maybe Text, Maybe (Binary ByteString), Maybe Text, Maybe Text, Maybe Int64, Maybe Int64)
|
||||
:. (Maybe (Binary ByteString), Maybe (Binary ByteString), Maybe Int64, Maybe (Binary ByteString), Maybe Int64, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime)
|
||||
:. (Maybe UTCTime, Maybe Text, BoolInt, Binary ByteString, Int, Maybe UTCTime, UTCTime, UTCTime)
|
||||
:. (Maybe UTCTime, Maybe Text, BoolInt, Binary ByteString, Int, Maybe UTCTime, UTCTime, UTCTime, Maybe Text, Int)
|
||||
|
||||
toRegistration :: RegistrationRow -> Either Text NameRegistration
|
||||
toRegistration
|
||||
( (rid, nrVersion, Binary lh, nrLength, duration, Binary owner, Binary nrCommitment, nrSealed, nrPhase, nrYears)
|
||||
:. (nrPrice, method_, invoiceId_, codeId_, label_, secret_, nrContactLink, nrChannelLink, nonce_, deadline_)
|
||||
:. (signature_, commitTx, commitBlock, registerTx, registerBlock, revealAfter, revealBefore, expiresAt, nrRegisteredAt)
|
||||
:. (nrFailedAt, nrError, BI nrLive, Binary nrOrderHash, nrMoves, nrLinksRefusedAt, nrCreatedAt, nrUpdatedAt)
|
||||
:. (nrFailedAt, nrError, BI nrLive, Binary nrOrderHash, nrMoves, nrLinksRefusedAt, nrCreatedAt, nrUpdatedAt, source_, nrBalanceUsed)
|
||||
) = do
|
||||
nrOwner <- decodeAddress owner
|
||||
nrPayment <- case (method_, invoiceId_, codeId_) of
|
||||
(Just m, Just invId, Nothing) -> (`RPInvoice` InvoiceId invId) <$> note ("name_registrations.method: " <> m) (textMethod m)
|
||||
(Nothing, Nothing, Just codeId) -> Right $ RPCode codeId
|
||||
(Nothing, Nothing, Nothing) | isJust source_ -> Right RPBalance
|
||||
_ -> Left $ "name_registrations payment: " <> rid
|
||||
nrReveal <- case (label_, secret_, nonce_, deadline_, signature_) of
|
||||
(Nothing, Nothing, Nothing, Nothing, Nothing) -> Right Nothing
|
||||
@@ -450,6 +596,8 @@ toRegistration
|
||||
nrYears,
|
||||
nrPrice,
|
||||
nrPayment,
|
||||
nrBalanceSource = RegistrationId <$> source_,
|
||||
nrBalanceUsed,
|
||||
nrReveal,
|
||||
nrCommitTx = txHash <$> commitTx,
|
||||
nrCommitBlock = word64 <$> commitBlock,
|
||||
@@ -502,14 +650,15 @@ unfunded :: Query
|
||||
unfunded = unfundedQuery "i.invoice_id"
|
||||
|
||||
-- | Run in the transaction that inserts the next order, so the unique constraints let it in; the registration's new version.
|
||||
-- Nothing when the order changed since it was read, or its invoice is no longer open or holds money: the caller rolls back,
|
||||
-- since the invoice is written before the registration, as settlement, cancel and the sweep write them.
|
||||
-- Nothing when the order changed since it was read, or its invoice is no longer open, not past its expiry, or holds money: the caller rolls back,
|
||||
-- since the invoice is written before the registration, as settlement, cancel and the sweep write them. A balance it held is released.
|
||||
closeOverdue :: DB.Connection -> NameRegistration -> UTCTime -> IO (Maybe Int64)
|
||||
closeOverdue db r@NameRegistration {nrId = RegistrationId rid, nrVersion, nrPayment} now = do
|
||||
closeOverdue db r@NameRegistration {nrId = regId@(RegistrationId rid), nrVersion, nrPayment} now = do
|
||||
-- locked first, so the funding check below, a later statement, sees a payment a concurrent settlement committed
|
||||
mapM_ (\invId -> lockInvoice db invId now) $ case nrPayment of
|
||||
RPInvoice _ invId -> Just invId
|
||||
RPCode _ -> Nothing
|
||||
RPBalance -> Nothing
|
||||
expired <-
|
||||
executeChanging
|
||||
db
|
||||
@@ -519,17 +668,17 @@ closeOverdue db r@NameRegistration {nrId = RegistrationId rid, nrVersion, nrPaym
|
||||
SELECT i.invoice_id FROM sx_badge_service_name_invoices ni
|
||||
JOIN sx_badge_service_name_registrations r ON r.registration_id = ni.registration_id
|
||||
JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id
|
||||
WHERE ni.registration_id = ? AND r.version = ? AND r.live = 1
|
||||
WHERE ni.registration_id = ? AND r.version = ? AND r.live = 1 AND i.expires_at < ?
|
||||
|]
|
||||
<> unfunded
|
||||
<> " )"
|
||||
)
|
||||
(invoiceStatusText ISExpired, truncateToSecond now, invoiceStatusText ISOpen, rid, nrVersion)
|
||||
(invoiceStatusText ISExpired, truncateToSecond now, invoiceStatusText ISOpen, rid, nrVersion, truncateToSecond now)
|
||||
if expired > 0
|
||||
then
|
||||
updateRegistration db r {nrLive = False} now <&> \case
|
||||
Written v -> Just v
|
||||
_ -> Nothing
|
||||
updateRegistration db r {nrLive = False} now >>= \case
|
||||
Written v -> Just v <$ releaseHeldBalance db regId now
|
||||
_ -> pure Nothing
|
||||
else pure Nothing
|
||||
|
||||
-- | Whether an order can still progress to a registration for its name. The unique live indexes allow one such order per commitment,
|
||||
@@ -545,6 +694,7 @@ orderLive NameRegistration {nrPhase, nrPayment, nrLive} = case nrPhase of
|
||||
PFailed -> True
|
||||
PTaken -> case nrPayment of
|
||||
RPInvoice {} -> True
|
||||
RPBalance -> True
|
||||
RPCode _ -> False
|
||||
PRegistered -> False
|
||||
|
||||
@@ -611,11 +761,15 @@ retargetRegistration db r OrderFields {ofLabelHash, ofLength, ofDuration, ofComm
|
||||
nrError = Nothing
|
||||
}
|
||||
|
||||
-- | Newest first, at most findLimit.
|
||||
-- | Newest first: findLimit of them, then every older one holding a balance above 0, since a balance never expires.
|
||||
findRegistrations :: DB.Connection -> Address -> IO [NameRegistration]
|
||||
findRegistrations db owner =
|
||||
DB.query db (selectRegistrations <> " WHERE r.owner = ? ORDER BY r.created_at DESC, r.registration_id DESC LIMIT ?") (Binary $ unAddress owner, findLimit)
|
||||
>>= decodeAll toRegistration
|
||||
findRegistrations db owner = do
|
||||
newest <- DB.query db (selectRegistrations <> " WHERE r.owner = ?" <> newestFirst <> " LIMIT ?") (Binary $ unAddress owner, findLimit) >>= decodeAll toRegistration
|
||||
holding <- DB.query db (selectRegistrations <> " WHERE r.owner = ?" <> withBalance <> newestFirst) (Only $ Binary $ unAddress owner) >>= decodeAll toRegistration
|
||||
pure $ newest <> filter ((`notElem` map nrId newest) . nrId) holding
|
||||
where
|
||||
newestFirst = " ORDER BY r.created_at DESC, r.registration_id DESC"
|
||||
withBalance = " AND EXISTS (SELECT 1 FROM sx_badge_service_name_balances b WHERE b.registration_id = r.registration_id AND b.amount > 0)"
|
||||
|
||||
isLabelRegistering :: DB.Connection -> LabelHash -> IO Bool
|
||||
isLabelRegistering db (LabelHash lh) =
|
||||
@@ -647,26 +801,29 @@ unpaidCleanupCandidates db expiredBefore =
|
||||
|]
|
||||
(PUnpaid, truncateToSecond expiredBefore)
|
||||
|
||||
-- | Deletes an unpaid registration and its invoice, unless a payment was recorded for it.
|
||||
deleteUnpaidRegistration :: DB.Connection -> RegistrationId -> IO Bool
|
||||
deleteUnpaidRegistration db (RegistrationId rid) = do
|
||||
invoiceIds <- DB.query db "SELECT invoice_id FROM sx_badge_service_name_invoices WHERE registration_id = ?" (Only rid)
|
||||
deleted <-
|
||||
executeChanging
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM sx_badge_service_name_registrations
|
||||
WHERE registration_id = ? AND phase = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sx_badge_service_name_invoices ni
|
||||
JOIN sx_badge_service_payments p ON p.invoice_id = ni.invoice_id
|
||||
WHERE ni.registration_id = ?
|
||||
)
|
||||
|]
|
||||
(rid, PUnpaid, rid)
|
||||
when (deleted > 0) $
|
||||
mapM_ (DB.execute db "DELETE FROM sx_badge_service_invoices WHERE invoice_id = ?" . Only . fromOnly) (invoiceIds :: [Only Text])
|
||||
pure $ deleted > 0
|
||||
-- | Deletes an unpaid registration and its invoice, unless a payment was recorded for it or it holds a balance.
|
||||
deleteUnpaidRegistration :: DB.Connection -> RegistrationId -> IO (Either CleanupKept ())
|
||||
deleteUnpaidRegistration db (RegistrationId rid) =
|
||||
DB.query db "SELECT registration_id FROM sx_badge_service_name_balances WHERE reserved_by = ?" (Only rid) >>= \case
|
||||
Only source : _ -> pure $ Left $ CKHolder $ RegistrationId source
|
||||
[] -> do
|
||||
invoiceIds <- DB.query db "SELECT invoice_id FROM sx_badge_service_name_invoices WHERE registration_id = ?" (Only rid)
|
||||
deleted <-
|
||||
executeChanging
|
||||
db
|
||||
[sql|
|
||||
DELETE FROM sx_badge_service_name_registrations
|
||||
WHERE registration_id = ? AND phase = ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sx_badge_service_name_invoices ni
|
||||
JOIN sx_badge_service_payments p ON p.invoice_id = ni.invoice_id
|
||||
WHERE ni.registration_id = ?
|
||||
)
|
||||
|]
|
||||
(rid, PUnpaid, rid)
|
||||
if deleted > 0
|
||||
then Right () <$ mapM_ (DB.execute db "DELETE FROM sx_badge_service_invoices WHERE invoice_id = ?" . Only . fromOnly) (invoiceIds :: [Only Text])
|
||||
else pure $ Left CKPaid
|
||||
|
||||
-- | Code orders failed before the cutoff that still hold their unspent code.
|
||||
failedCodeOrdersBefore :: DB.Connection -> UTCTime -> IO [(RegistrationId, Int64)]
|
||||
@@ -683,6 +840,303 @@ failedCodeOrdersBefore db cutoff =
|
||||
|]
|
||||
(PFailed, truncateToSecond cutoff)
|
||||
|
||||
-- | The hold, unless it is overdue, which reads as none.
|
||||
heldBy :: Balance -> Maybe BalanceHold
|
||||
heldBy Balance {blHold} = case blHold of
|
||||
Just BalanceHold {bhOverdue = False} -> blHold
|
||||
_ -> Nothing
|
||||
|
||||
-- | A holder is overdue as getLiveOrders reads an order overdue: its invoice past its expiry, not paid, and holding no money.
|
||||
getBalance :: DB.Connection -> RegistrationId -> UTCTime -> IO (Maybe Balance)
|
||||
getBalance db (RegistrationId rid) now =
|
||||
firstDecoded toBalance $
|
||||
DB.query
|
||||
db
|
||||
( "SELECT b.registration_id, b.amount, b.credited, b.refunded, b.reserved_by, i.expires_at,"
|
||||
<> " CASE WHEN h.phase = ? AND i.status <> ? AND i.expires_at < ?"
|
||||
<> unfunded
|
||||
<> " THEN 1 ELSE 0 END, b.refunded_at"
|
||||
<> " FROM sx_badge_service_name_balances b"
|
||||
<> " LEFT JOIN sx_badge_service_name_registrations h ON h.registration_id = b.reserved_by"
|
||||
<> " LEFT JOIN sx_badge_service_name_invoices ni ON ni.registration_id = b.reserved_by"
|
||||
<> " LEFT JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id"
|
||||
<> " WHERE b.registration_id = ?"
|
||||
)
|
||||
(PUnpaid, invoiceStatusText ISPaid, truncateToSecond now, rid)
|
||||
|
||||
toBalance :: (Text, Int, Int, Int, Maybe Text, Maybe UTCTime, BoolInt, Maybe UTCTime) -> Either Text Balance
|
||||
toBalance (rid, blAmount, blCredited, blRefunded, holder_, until_, BI overdue, blRefundedAt) = do
|
||||
blHold <- case (holder_, until_) of
|
||||
(Nothing, _) -> Right Nothing
|
||||
(Just holder, Just bhUntil) -> Right $ Just BalanceHold {bhHolder = RegistrationId holder, bhUntil, bhOverdue = overdue}
|
||||
(Just _, Nothing) -> Left $ "name_balances holder without an invoice: " <> rid
|
||||
pure Balance {blRegistration = RegistrationId rid, blAmount, blCredited, blRefunded, blHold, blRefundedAt}
|
||||
|
||||
-- | The order holding the balance, when its hold is overdue, for the next spend to close.
|
||||
overdueHolder :: DB.Connection -> RegistrationId -> UTCTime -> IO (Maybe NameRegistration)
|
||||
overdueHolder db rid now =
|
||||
getBalance db rid now >>= \case
|
||||
Just Balance {blHold = Just BalanceHold {bhHolder, bhOverdue = True}} -> getRegistration db bhHolder
|
||||
_ -> pure Nothing
|
||||
|
||||
-- | The order a support reference names, by its registration id or its name invoice's id, with its balance.
|
||||
balanceByReference :: DB.Connection -> Text -> UTCTime -> IO (Maybe (RegistrationId, Maybe Balance))
|
||||
balanceByReference db ref now =
|
||||
DB.query
|
||||
db
|
||||
"SELECT registration_id FROM sx_badge_service_name_registrations WHERE registration_id = ? UNION ALL SELECT registration_id FROM sx_badge_service_name_invoices WHERE invoice_id = ?"
|
||||
(ref, ref)
|
||||
>>= \case
|
||||
Only rid : _ -> (\balance -> Just (RegistrationId rid, balance)) <$> getBalance db (RegistrationId rid) now
|
||||
[] -> pure Nothing
|
||||
|
||||
-- | From the order back along balance_source to the first, following it only from orders past unpaid.
|
||||
balanceChain :: DB.Connection -> RegistrationId -> IO [BalanceLink]
|
||||
balanceChain db rid =
|
||||
getRegistration db rid >>= \case
|
||||
Nothing -> pure []
|
||||
Just NameRegistration {nrPhase, nrPayment, nrBalanceSource, nrBalanceUsed} -> do
|
||||
let pastUnpaid = nrPhase /= PUnpaid
|
||||
lkPaid <- case nrPayment of
|
||||
RPInvoice _ (InvoiceId invId) | pastUnpaid -> maybe 0 fromOnly . listToMaybe <$> (DB.query db "SELECT amount FROM sx_badge_service_invoices WHERE invoice_id = ?" (Only invId) :: IO [Only Int])
|
||||
_ -> pure 0
|
||||
lkLateCredits <- case nrPayment of
|
||||
RPInvoice _ invId -> lateCredits db invId
|
||||
_ -> pure []
|
||||
let link = BalanceLink {lkRegistration = rid, lkPayment = nrPayment, lkPaid, lkFromBalance = if pastUnpaid then nrBalanceUsed else 0, lkLateCredits}
|
||||
(link :) <$> maybe (pure []) (balanceChain db) (if pastUnpaid then nrBalanceSource else Nothing)
|
||||
|
||||
lateCredits :: DB.Connection -> InvoiceId -> IO [LateCredit]
|
||||
lateCredits db (InvoiceId invId) =
|
||||
map toLateCredit
|
||||
<$> DB.query
|
||||
db
|
||||
"SELECT invoice_id, credit_no, registration_id, units, invoice_rate, current_rate, rate_used, cents FROM sx_badge_service_name_late_credits WHERE invoice_id = ? ORDER BY credit_no"
|
||||
(Only invId)
|
||||
where
|
||||
toLateCredit (inv, lcCreditNo, reg, lcUnits, lcInvoiceRate, lcCurrentRate, lcRateUsed, lcCents) =
|
||||
LateCredit {lcInvoice = InvoiceId inv, lcCreditNo, lcRegistration = RegistrationId reg, lcUnits, lcInvoiceRate, lcCurrentRate, lcRateUsed, lcCents}
|
||||
|
||||
-- | Adds to the order's balance, making it on its first credit.
|
||||
creditBalance :: DB.Connection -> RegistrationId -> Int -> UTCTime -> IO ()
|
||||
creditBalance db (RegistrationId rid) cents now' =
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO sx_badge_service_name_balances (registration_id, amount, credited, refunded, created_at, updated_at)
|
||||
VALUES (?,?,?,0,?,?)
|
||||
ON CONFLICT (registration_id) DO UPDATE SET
|
||||
amount = sx_badge_service_name_balances.amount + excluded.amount,
|
||||
credited = sx_badge_service_name_balances.credited + excluded.credited,
|
||||
updated_at = excluded.updated_at
|
||||
|]
|
||||
(rid, cents, cents, now, now)
|
||||
where
|
||||
now = truncateToSecond now'
|
||||
|
||||
-- | The price, above 0, spent at once, where the amount is the one seen and nothing holds it.
|
||||
spendBalance :: DB.Connection -> RegistrationId -> Int -> Int -> UTCTime -> IO Bool
|
||||
spendBalance db (RegistrationId rid) price seen now =
|
||||
(> 0)
|
||||
<$> executeChanging
|
||||
db
|
||||
"UPDATE sx_badge_service_name_balances SET amount = amount - ?, updated_at = ? WHERE registration_id = ? AND amount = ? AND amount >= ? AND ? > 0 AND reserved_by IS NULL"
|
||||
(price, now, rid, seen, price, price)
|
||||
|
||||
-- | Held by a difference order, where the amount is the one seen, above 0, and nothing holds it.
|
||||
holdBalance :: DB.Connection -> RegistrationId -> Int -> RegistrationId -> UTCTime -> IO Bool
|
||||
holdBalance db (RegistrationId rid) seen (RegistrationId holder) now =
|
||||
(> 0)
|
||||
<$> executeChanging
|
||||
db
|
||||
"UPDATE sx_badge_service_name_balances SET reserved_by = ?, updated_at = ? WHERE registration_id = ? AND amount = ? AND ? > 0 AND reserved_by IS NULL"
|
||||
(holder, now, rid, seen, seen)
|
||||
|
||||
-- | The holder's hold spent and cleared, where this holder holds the balance and it still has the cents, above 0.
|
||||
spendHeldBalance :: DB.Connection -> RegistrationId -> Int -> UTCTime -> IO Bool
|
||||
spendHeldBalance db (RegistrationId holder) used now =
|
||||
(> 0)
|
||||
<$> executeChanging
|
||||
db
|
||||
"UPDATE sx_badge_service_name_balances SET amount = amount - ?, reserved_by = NULL, updated_at = ? WHERE reserved_by = ? AND amount >= ? AND ? > 0"
|
||||
(used, truncateToSecond now, holder, used, used)
|
||||
|
||||
releaseHeldBalance :: DB.Connection -> RegistrationId -> UTCTime -> IO Bool
|
||||
releaseHeldBalance db (RegistrationId holder) now =
|
||||
(> 0)
|
||||
<$> executeChanging
|
||||
db
|
||||
"UPDATE sx_badge_service_name_balances SET reserved_by = NULL, updated_at = ? WHERE reserved_by = ?"
|
||||
(truncateToSecond now, holder)
|
||||
|
||||
-- | All of the balance, where it is the cents given, above 0, and nothing holds it; refunded counts every refund.
|
||||
refundBalance :: DB.Connection -> RegistrationId -> Int -> UTCTime -> IO Bool
|
||||
refundBalance db (RegistrationId rid) cents now' =
|
||||
(> 0)
|
||||
<$> executeChanging
|
||||
db
|
||||
[sql|
|
||||
UPDATE sx_badge_service_name_balances SET amount = 0, refunded = refunded + ?, refunded_at = ?, updated_at = ?
|
||||
WHERE registration_id = ? AND amount = ? AND amount > 0 AND reserved_by IS NULL
|
||||
|]
|
||||
(cents, now, now, rid, cents)
|
||||
where
|
||||
now = truncateToSecond now'
|
||||
|
||||
-- | A name invoice with its order, its invoice row and its payment row.
|
||||
lateFrom :: Query
|
||||
lateFrom =
|
||||
" FROM sx_badge_service_name_invoices ni"
|
||||
<> " JOIN sx_badge_service_name_registrations r ON r.registration_id = ni.registration_id"
|
||||
<> " JOIN sx_badge_service_invoices i ON i.invoice_id = ni.invoice_id"
|
||||
<> " JOIN sx_badge_service_payments p ON p.invoice_id = ni.invoice_id"
|
||||
|
||||
-- | Its order closed unpaid, whatever the invoice's status, since settlement queues only a live order; takes the unpaid phase.
|
||||
closedUnpaid :: Query
|
||||
closedUnpaid = " r.phase = ? AND r.live = 0"
|
||||
|
||||
cardInvoice :: Query
|
||||
cardInvoice = " i.payment_crypto_currency IS NULL"
|
||||
|
||||
cryptoInvoice :: Query
|
||||
cryptoInvoice = " i.payment_crypto_currency IS NOT NULL"
|
||||
|
||||
-- | No credit rows sum to 0, not NULL.
|
||||
creditedTotal :: Query -> Query
|
||||
creditedTotal column = " (SELECT " <> integerSum ("COALESCE(SUM(c." <> column <> "), 0)") <> " FROM sx_badge_service_name_late_credits c WHERE c.invoice_id = ni.invoice_id)"
|
||||
|
||||
integerSum :: Query -> Query
|
||||
#if defined(dbPostgres)
|
||||
-- Postgres sums a BIGINT as NUMERIC, which does not decode into an integer
|
||||
integerSum s = "CAST(" <> s <> " AS BIGINT)"
|
||||
#else
|
||||
integerSum = id
|
||||
#endif
|
||||
|
||||
-- | The first alert's old marks are NULL, which = never matches.
|
||||
notDistinctFrom :: Query
|
||||
#if defined(dbPostgres)
|
||||
notDistinctFrom = " IS NOT DISTINCT FROM "
|
||||
#else
|
||||
notDistinctFrom = " IS "
|
||||
#endif
|
||||
|
||||
-- | Confirmed money not all credited: cents for card, units for crypto.
|
||||
uncreditedMoney :: Query
|
||||
uncreditedMoney =
|
||||
" ((" <> cardInvoice <> " AND p.confirmed_amount >" <> creditedTotal "cents" <> ")"
|
||||
<> " OR (" <> cryptoInvoice <> " AND p.confirmed_units >" <> creditedTotal "units" <> "))"
|
||||
|
||||
-- | Why a closed order's money is not all credited, by the first case that holds, else NULL.
|
||||
-- Confirmed money waiting comes first, so its alert never asks for a refund by hand.
|
||||
pendingCase :: Query
|
||||
pendingCase =
|
||||
" CASE WHEN" <> uncreditedMoney <> " THEN " <> literal LPUncredited
|
||||
<> " WHEN COALESCE(p.amount, 0) = 0 AND (p.crypto_paid IS NOT NULL OR p.paid_in_full = 1) THEN " <> literal LPConfirming
|
||||
<> " WHEN" <> cardInvoice <> " AND COALESCE(p.amount, 0) > p.confirmed_amount THEN " <> literal LPUnconfirmed
|
||||
<> " WHEN p.received_units > COALESCE(p.confirmed_units, 0) THEN " <> literal LPUnconfirmed
|
||||
<> " WHEN" <> cryptoInvoice <> " AND p.received_units IS NULL AND COALESCE(p.amount, 0) > 0 THEN " <> literal LPUncounted
|
||||
<> " END"
|
||||
where
|
||||
literal p = fromString $ "'" <> T.unpack (textEncode p) <> "'"
|
||||
|
||||
-- | What an alert names as received: units for crypto, and cents for card or for crypto that could not be counted in units.
|
||||
receivedFigure :: Query
|
||||
receivedFigure = " CASE WHEN" <> cardInvoice <> " THEN p.amount ELSE COALESCE(p.received_units, p.amount) END"
|
||||
|
||||
lateColumns :: Query
|
||||
lateColumns =
|
||||
"SELECT ni.invoice_id, ni.registration_id, r.phase, r.live, CASE WHEN" <> cardInvoice <> " THEN 1 ELSE 0 END, COALESCE(p.amount, 0), p.confirmed_amount,"
|
||||
<> " p.received_units, p.confirmed_units, p.invoice_rate,"
|
||||
<> creditedTotal "units"
|
||||
<> ","
|
||||
<> creditedTotal "cents"
|
||||
<> ", (SELECT COUNT(*) FROM sx_badge_service_name_late_credits c WHERE c.invoice_id = ni.invoice_id), p.created_at"
|
||||
|
||||
type LateRow = (Text, Text, NamePhase, BoolInt, BoolInt, Int, Int, Maybe Int64, Maybe Int64, Maybe Text) :. (Int64, Int, Int, UTCTime)
|
||||
|
||||
toLateState :: LateRow -> LateCreditState
|
||||
toLateState
|
||||
( (inv, reg, lsPhase, BI lsLive, BI lsCard, lsReceivedCents, lsConfirmedCents, lsReceivedUnits, lsConfirmedUnits, lsInvoiceRate)
|
||||
:. (lsCreditedUnits, lsCreditedCents, lsCredits, lsPaymentCreatedAt)
|
||||
) =
|
||||
LateCreditState {lsInvoice = InvoiceId inv, lsRegistration = RegistrationId reg, lsPhase, lsLive, lsCard, lsReceivedCents, lsConfirmedCents, lsReceivedUnits, lsConfirmedUnits, lsInvoiceRate, lsCreditedUnits, lsCreditedCents, lsCredits, lsPaymentCreatedAt}
|
||||
|
||||
-- | Nothing without a payment row.
|
||||
lateCreditState :: DB.Connection -> InvoiceId -> IO (Maybe LateCreditState)
|
||||
lateCreditState db (InvoiceId invId) = maybeFirstRow toLateState $ DB.query db (lateColumns <> lateFrom <> " WHERE ni.invoice_id = ?") (Only invId)
|
||||
|
||||
-- | Run after the invoice's lock. The order's balance is credited only when the credit's row is new, so a taken credit number changes neither.
|
||||
creditLatePayment :: DB.Connection -> LateCredit -> UTCTime -> IO Bool
|
||||
creditLatePayment db LateCredit {lcInvoice = InvoiceId invId, lcCreditNo, lcRegistration = regId@(RegistrationId rid), lcUnits, lcInvoiceRate, lcCurrentRate, lcRateUsed, lcCents} now' = do
|
||||
inserted <-
|
||||
executeChanging
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO sx_badge_service_name_late_credits (invoice_id, credit_no, registration_id, units, invoice_rate, current_rate, rate_used, cents, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT DO NOTHING
|
||||
|]
|
||||
(invId, lcCreditNo, rid, lcUnits, lcInvoiceRate, lcCurrentRate, lcRateUsed, lcCents, now)
|
||||
when (inserted > 0) $ creditBalance db regId lcCents now
|
||||
pure $ inserted > 0
|
||||
where
|
||||
now = truncateToSecond now'
|
||||
|
||||
-- | Why the closed order's money on the invoice is not all credited; the one rule of the pending log and the alert.
|
||||
latePending :: DB.Connection -> InvoiceId -> IO (Maybe LatePending)
|
||||
latePending db (InvoiceId invId) =
|
||||
maybe Nothing fromOnly . listToMaybe
|
||||
<$> DB.query db ("SELECT" <> pendingCase <> lateFrom <> " WHERE ni.invoice_id = ? AND" <> closedUnpaid) (invId, PUnpaid)
|
||||
|
||||
-- | Closed orders' invoices with confirmed money not all credited, for the poller's credit pass.
|
||||
uncreditedLateInvoices :: DB.Connection -> IO [InvoiceId]
|
||||
uncreditedLateInvoices db =
|
||||
map (InvoiceId . fromOnly)
|
||||
<$> DB.query db ("SELECT ni.invoice_id" <> lateFrom <> " WHERE" <> closedUnpaid <> " AND" <> uncreditedMoney <> " ORDER BY p.created_at, ni.invoice_id") (Only PUnpaid)
|
||||
|
||||
-- | Pending money whose payment row was created before the cutoff, never alerted, or alerted for less money than now received.
|
||||
lateAlertDue :: DB.Connection -> UTCTime -> IO [LateAlert]
|
||||
lateAlertDue db cutoff =
|
||||
DB.query
|
||||
db
|
||||
( lateColumns
|
||||
<> ","
|
||||
<> pendingCase
|
||||
<> ","
|
||||
<> receivedFigure
|
||||
<> ", ni.late_alerted_at, ni.late_alerted_units"
|
||||
<> lateFrom
|
||||
<> " WHERE"
|
||||
<> closedUnpaid
|
||||
<> " AND p.created_at < ? AND"
|
||||
<> pendingCase
|
||||
<> " IS NOT NULL AND (ni.late_alerted_at IS NULL OR"
|
||||
<> receivedFigure
|
||||
<> " > COALESCE(ni.late_alerted_units, -1)) ORDER BY p.created_at, ni.invoice_id"
|
||||
)
|
||||
(PUnpaid, truncateToSecond cutoff)
|
||||
>>= decodeAll toAlert
|
||||
where
|
||||
toAlert :: LateRow :. (Maybe LatePending, Maybe Int64, Maybe UTCTime, Maybe Int64) -> Either Text LateAlert
|
||||
toAlert (row :. (pending_, laReceived, laAlertedAt, laAlertedReceived)) = case pending_ of
|
||||
Just laPending -> Right LateAlert {laState = toLateState row, laPending, laReceived, laAlertedAt, laAlertedReceived}
|
||||
Nothing -> Left "name invoice alert without pending money"
|
||||
|
||||
-- | Marks the alert sent, unless another instance marked it since it was read; only the instance that marks it logs it.
|
||||
claimLateAlert :: DB.Connection -> LateAlert -> UTCTime -> IO Bool
|
||||
claimLateAlert db LateAlert {laState = LateCreditState {lsInvoice = InvoiceId invId}, laReceived, laAlertedAt, laAlertedReceived} now =
|
||||
(> 0)
|
||||
<$> executeChanging
|
||||
db
|
||||
( "UPDATE sx_badge_service_name_invoices SET late_alerted_at = ?, late_alerted_units = ? WHERE invoice_id = ? AND late_alerted_at"
|
||||
<> notDistinctFrom
|
||||
<> "? AND late_alerted_units"
|
||||
<> notDistinctFrom
|
||||
<> "?"
|
||||
)
|
||||
(truncateToSecond now, laReceived, invId, laAlertedAt, laAlertedReceived)
|
||||
|
||||
insertPrepaidCode :: DB.Connection -> NewPrepaidCode -> UTCTime -> IO (Either CodeHashTaken Int64)
|
||||
insertPrepaidCode db NewPrepaidCode {npcCodeHash, npcMinLength, npcYears, npcPaymentStatus, npcExpiresAt} now = do
|
||||
inserted <-
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
|
||||
-- | A thread that takes a turn it already holds waits forever. Label turns are taken before a code turn.
|
||||
module StoreService.Names.Turns (Turns, newTurns, withLabelTurn, withLabelTurns, withCodeTurn, turnsCount, labelTurnCount) where
|
||||
-- | A thread that takes a turn it already holds waits forever. Label turns are taken before a code turn or a balance turn,
|
||||
-- and a balance turn is never taken with a code turn.
|
||||
module StoreService.Names.Turns (Turns, newTurns, withLabelTurn, withLabelTurns, withCodeTurn, withBalanceTurn, turnsCount, labelTurnCount, balanceTurnCount) where
|
||||
|
||||
import Control.Concurrent.STM
|
||||
import Control.Exception (bracket, bracket_)
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as Map
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Simplex.Messaging.SimplexName (LabelHash (..))
|
||||
import StoreService.Names.Phases (RegistrationId (..))
|
||||
|
||||
-- | Each lock counts the requests holding or waiting for it, and its entry goes when none are left.
|
||||
type TurnLocks = TVar (Map ByteString (TMVar (), Int))
|
||||
|
||||
data Turns = Turns {labelTurns :: TurnLocks, codeTurns :: TurnLocks}
|
||||
data Turns = Turns {labelTurns :: TurnLocks, codeTurns :: TurnLocks, balanceTurns :: TurnLocks}
|
||||
|
||||
newTurns :: IO Turns
|
||||
newTurns = Turns <$> newTVarIO Map.empty <*> newTVarIO Map.empty
|
||||
newTurns = Turns <$> newTVarIO Map.empty <*> newTVarIO Map.empty <*> newTVarIO Map.empty
|
||||
|
||||
withLabelTurn :: Turns -> LabelHash -> IO a -> IO a
|
||||
withLabelTurn Turns {labelTurns} (LabelHash h) = withTurn labelTurns h
|
||||
@@ -28,9 +31,9 @@ withLabelTurns turns a@(LabelHash ha) b@(LabelHash hb) action
|
||||
| ha < hb = withLabelTurn turns a $ withLabelTurn turns b action
|
||||
| otherwise = withLabelTurn turns b $ withLabelTurn turns a action
|
||||
|
||||
-- | The label and code hashes with a holder or a waiter.
|
||||
-- | The label hashes, code hashes and balances with a holder or a waiter.
|
||||
turnsCount :: Turns -> IO Int
|
||||
turnsCount Turns {labelTurns, codeTurns} = atomically $ (+) <$> (Map.size <$> readTVar labelTurns) <*> (Map.size <$> readTVar codeTurns)
|
||||
turnsCount Turns {labelTurns, codeTurns, balanceTurns} = atomically $ sum <$> mapM (fmap Map.size . readTVar) [labelTurns, codeTurns, balanceTurns]
|
||||
|
||||
-- | The requests holding or waiting for the label hash's turn.
|
||||
labelTurnCount :: Turns -> LabelHash -> STM Int
|
||||
@@ -40,6 +43,17 @@ labelTurnCount Turns {labelTurns} (LabelHash h) = maybe 0 snd . Map.lookup h <$>
|
||||
withCodeTurn :: Turns -> ByteString -> IO a -> IO a
|
||||
withCodeTurn Turns {codeTurns} = withTurn codeTurns
|
||||
|
||||
-- | Keyed by the order the balance belongs to.
|
||||
withBalanceTurn :: Turns -> RegistrationId -> IO a -> IO a
|
||||
withBalanceTurn Turns {balanceTurns} = withTurn balanceTurns . balanceKey
|
||||
|
||||
-- | The requests holding or waiting for the balance's turn.
|
||||
balanceTurnCount :: Turns -> RegistrationId -> STM Int
|
||||
balanceTurnCount Turns {balanceTurns} rid = maybe 0 snd . Map.lookup (balanceKey rid) <$> readTVar balanceTurns
|
||||
|
||||
balanceKey :: RegistrationId -> ByteString
|
||||
balanceKey (RegistrationId rid) = encodeUtf8 rid
|
||||
|
||||
withTurn :: TurnLocks -> ByteString -> IO a -> IO a
|
||||
withTurn locks key action =
|
||||
bracket (atomically enter) (const $ atomically leave) $ \lock ->
|
||||
|
||||
@@ -7,6 +7,7 @@ module StoreService.Names.View
|
||||
registrationView,
|
||||
readViewChain,
|
||||
viewJSON,
|
||||
balanceJSON,
|
||||
addressText,
|
||||
tldText,
|
||||
registrationAt,
|
||||
@@ -35,7 +36,7 @@ import StoreService.Names.Abi (hex)
|
||||
import StoreService.Names.Chain (TxHash (..))
|
||||
import StoreService.Names.Contracts (Contracts, sponsorNonce)
|
||||
import StoreService.Names.Phases (RegistrationId (..))
|
||||
import StoreService.Names.Store (NamePhase (..), NameRegistration (..), NameReveal (..), getRegistration)
|
||||
import StoreService.Names.Store (Balance (..), BalanceHold (..), NamePhase (..), NameRegistration (..), NameReveal (..), getBalance, getRegistration, heldBy)
|
||||
|
||||
-- | The view's chain reads; a field whose read failed is null and named in vrUnread.
|
||||
data ViewReads = ViewReads
|
||||
@@ -44,8 +45,11 @@ data ViewReads = ViewReads
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
registrationView :: DBStore -> NamesConfig -> Contracts -> NameRegistration -> IO J.Value
|
||||
registrationView _ cfg ct r = viewJSON cfg r <$> readViewChain ct r
|
||||
-- | The balance as it reads at the given time, when a hold may have become overdue.
|
||||
registrationView :: DBStore -> NamesConfig -> Contracts -> UTCTime -> NameRegistration -> IO J.Value
|
||||
registrationView st cfg ct now r = do
|
||||
balance <- withTransaction st $ \db -> getBalance db (nrId r) now
|
||||
viewJSON cfg r balance <$> readViewChain ct r
|
||||
|
||||
-- | The intent nonce only in committed.
|
||||
readViewChain :: Contracts -> NameRegistration -> IO ViewReads
|
||||
@@ -57,8 +61,8 @@ readViewChain ct NameRegistration {nrPhase, nrOwner} = case nrPhase of
|
||||
where
|
||||
none = ViewReads {vrIntentNonce = Nothing, vrUnread = []}
|
||||
|
||||
viewJSON :: NamesConfig -> NameRegistration -> ViewReads -> J.Value
|
||||
viewJSON NamesConfig {nTLD, nChain = ChainConfig {cChainId, cResolver, cSponsor}} r ViewReads {vrIntentNonce, vrUnread} =
|
||||
viewJSON :: NamesConfig -> NameRegistration -> Maybe Balance -> ViewReads -> J.Value
|
||||
viewJSON NamesConfig {nTLD, nChain = ChainConfig {cChainId, cResolver, cSponsor}} r balance ViewReads {vrIntentNonce, vrUnread} =
|
||||
J.object
|
||||
[ "id" .= rid,
|
||||
"version" .= nrVersion,
|
||||
@@ -77,6 +81,8 @@ viewJSON NamesConfig {nTLD, nChain = ChainConfig {cChainId, cResolver, cSponsor}
|
||||
"years" .= nrYears,
|
||||
"duration" .= nrDuration,
|
||||
"price" .= nrPrice,
|
||||
"fromBalance" .= nrBalanceUsed,
|
||||
"balance" .= (balanceJSON <$> balance),
|
||||
"commitTx" .= (txText <$> nrCommitTx),
|
||||
"registerTx" .= (txText <$> nrRegisterTx),
|
||||
"revealAfter" .= nrRevealAfter,
|
||||
@@ -88,9 +94,16 @@ viewJSON NamesConfig {nTLD, nChain = ChainConfig {cChainId, cResolver, cSponsor}
|
||||
]
|
||||
where
|
||||
NameRegistration {nrId = RegistrationId rid, nrVersion, nrMoves, nrLinksRefusedAt, nrPhase, nrLabelHash = LabelHash lh, nrReveal, nrOwner, nrCommitment, nrSealed} = r
|
||||
NameRegistration {nrYears, nrDuration, nrPrice, nrCommitTx, nrRegisterTx, nrRevealAfter, nrRevealBefore, nrExpiresAt, nrError} = r
|
||||
NameRegistration {nrYears, nrDuration, nrPrice, nrBalanceUsed, nrCommitTx, nrRegisterTx, nrRevealAfter, nrRevealBefore, nrExpiresAt, nrError} = r
|
||||
txText (TxHash h) = hex h
|
||||
|
||||
-- | The holder is not named, so an order's link discloses no other order.
|
||||
balanceJSON :: Balance -> J.Value
|
||||
balanceJSON b@Balance {blAmount, blRefunded} =
|
||||
J.object ["amount" .= blAmount, "held" .= isJust hold, "heldUntil" .= (bhUntil <$> hold), "refunded" .= blRefunded]
|
||||
where
|
||||
hold = heldBy b
|
||||
|
||||
-- | EIP-55, as the page compares owners.
|
||||
addressText :: Address -> Text
|
||||
addressText = safeDecodeUtf8 . strEncode
|
||||
|
||||
@@ -327,6 +327,7 @@ judge w@Worker {wkOrders = oe@OrdersEnv {oeContracts}} now r@NameRegistration {n
|
||||
taken :: Worker -> UTCTime -> NameRegistration -> IO (Maybe Int64, Next)
|
||||
taken Worker {wkOrders = OrdersEnv {oeStore, oeTurns}} now r@NameRegistration {nrId, nrPayment} = case nrPayment of
|
||||
RPInvoice {} -> written r <$> withTransaction oeStore write
|
||||
RPBalance -> written r <$> withTransaction oeStore write
|
||||
RPCode codeId -> written r . fmap fst <$> releaseCode oeTurns oeStore codeId nrId write
|
||||
where
|
||||
write db = versionWritten <$> updateRegistration db r {nrPhase = PTaken, nrError = Nothing} now
|
||||
@@ -396,6 +397,7 @@ retryRegistration oe@OrdersEnv {oeNow} current = do
|
||||
retryCode :: OrdersEnv -> UTCTime -> NameRegistration -> IO (Either CodeRefusal (Maybe PrepaidCode))
|
||||
retryCode OrdersEnv {oeStore} now NameRegistration {nrId, nrLength, nrPayment} = case nrPayment of
|
||||
RPInvoice {} -> pure $ Right Nothing
|
||||
RPBalance -> pure $ Right Nothing
|
||||
RPCode codeId ->
|
||||
withTransaction oeStore (`getPrepaidCodeById` codeId) <&> \case
|
||||
Just code -> maybe (Right $ Just code) Left $ codeRefusal now nrId nrLength code
|
||||
|
||||
@@ -17,7 +17,7 @@ where
|
||||
import StoreService.Log (logError, safelyWith)
|
||||
import StoreService.Names.Phases (Phases, RegistrationId (..))
|
||||
import qualified StoreService.Names.Phases as Phases
|
||||
import StoreService.Names.Store (NamePhase (..), NameRegistration (..), VersionWrite (..), getRegistration, updateRegistration)
|
||||
import StoreService.Names.Store (NamePhase (..), NameRegistration (..), VersionWrite (..), getRegistration, releaseHeldBalance, updateRegistration)
|
||||
import StoreService.Providers (Funded (..), PaymentSignal (..), Received (..))
|
||||
import StoreService.Store.Invoices (InvoicePayment (..), InvoiceProduct (..), InvoiceRow (..), expireOverdueInvoice, lockInvoice, markCodePaid, paymentHolds, paymentStatusText, settlementCodeHash, settlementInvoice, truncateToSecond, updateInvoiceStatus, upsertPayment)
|
||||
import StoreService.Waiters (Waiters, publish, publishPayment)
|
||||
@@ -184,13 +184,13 @@ nameSettled db before after wrotePayment holdsMoney r@NameRegistration {nrId, nr
|
||||
Just ISExpired -> (<> [NOLate nrId | holdsMoney]) . maybe [] (\(rid, v) -> [NOWritten rid v]) <$> closeOrder db r now
|
||||
_ -> pure [NOLate nrId | before /= ISOpen, wrotePayment]
|
||||
|
||||
-- | Clears a live unpaid order's live flag, as its invoice closes in the same transaction.
|
||||
-- | Clears a live unpaid order's live flag, as its invoice closes in the same transaction, and releases a balance it held.
|
||||
closeOrder :: DB.Connection -> NameRegistration -> UTCTime -> IO (Maybe (RegistrationId, Int64))
|
||||
closeOrder db r@NameRegistration {nrId, nrPhase, nrLive} now
|
||||
| nrPhase /= PUnpaid || not nrLive = pure Nothing
|
||||
| otherwise =
|
||||
updateRegistration db r {nrLive = False} now >>= \case
|
||||
Written v -> pure (Just (nrId, v))
|
||||
Written v -> Just (nrId, v) <$ releaseHeldBalance db nrId now
|
||||
_ -> E.throwIO (RegistrationChanged nrId)
|
||||
|
||||
-- | The order of a name invoice whose status this transaction moved to expired.
|
||||
|
||||
@@ -33,7 +33,7 @@ where
|
||||
|
||||
import StoreService.Config (PollConfig (..))
|
||||
import StoreService.Names.Phases (Phases, RegistrationId (..))
|
||||
import StoreService.Names.Store (deleteUnpaidRegistration, unpaidCleanupCandidates)
|
||||
import StoreService.Names.Store (CleanupKept (..), deleteUnpaidRegistration, unpaidCleanupCandidates)
|
||||
import StoreService.Orders (decide, expireOverdue, settleOrder)
|
||||
import StoreService.Providers (ListPass (..), PaymentSignal (..), Provider (..), ProviderError (..), Received (..), expiresItself, settleWindow)
|
||||
import StoreService.Store.Invoices (InvoiceProduct (..), InvoiceRow (..), OverdueInvoice (..), getInvoice, getInvoiceByProviderRef, overdueInvoices, providerText, unpaidRefs)
|
||||
@@ -296,9 +296,10 @@ cleanupUnpaid env@PollerEnv {peStore, peCleanupLeft} now = do
|
||||
Left (ProviderError e) -> keep ("reading invoice " <> invText <> " failed: " <> e)
|
||||
Right signal_ -> do
|
||||
mapM_ (\signal -> settleMoved env irProvider now (irProviderRef, signal)) signal_
|
||||
deleted <- withTransaction peStore (`deleteUnpaidRegistration` rid)
|
||||
unless deleted $ logError ("names cleanup: unpaid registration " <> ridText <> " is kept because invoice " <> invText <> " received a payment; refund it")
|
||||
pure deleted
|
||||
withTransaction peStore (`deleteUnpaidRegistration` rid) >>= \case
|
||||
Right () -> pure True
|
||||
Left CKPaid -> False <$ logError ("names cleanup: unpaid registration " <> ridText <> " is kept because invoice " <> invText <> " received a payment; refund it")
|
||||
Left (CKHolder (RegistrationId source)) -> False <$ logError ("names cleanup: unpaid registration " <> ridText <> " is kept because it still holds the balance of registration " <> source)
|
||||
where
|
||||
keep why = False <$ logWarn ("names cleanup: unpaid registration " <> ridText <> " is kept until the next run, " <> why)
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ schemaMigrations =
|
||||
[ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema),
|
||||
("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops),
|
||||
("20261010_names", m20261010_names, Just down_m20261010_names),
|
||||
("20261022_drop_taken_codes", m20261022_drop_taken_codes, Just down_m20261022_drop_taken_codes)
|
||||
("20261022_drop_taken_codes", m20261022_drop_taken_codes, Just down_m20261022_drop_taken_codes),
|
||||
("20261022_name_balances", m20261022_name_balances, Just down_m20261022_name_balances)
|
||||
]
|
||||
|
||||
-- | The client tables share this database, so the service tables are the same names behind a prefix.
|
||||
@@ -354,6 +355,92 @@ ALTER TABLE @name_registrations ADD COLUMN issued_code_id BIGINT REFERENCES @nam
|
||||
CREATE INDEX @idx_name_registrations_issued_code ON @name_registrations(issued_code_id);
|
||||
|]
|
||||
|
||||
-- amount = credited - spent - refunded, spent being the balance_used of the orders it paid for that went past unpaid.
|
||||
-- Crypto is counted in integer units (satoshi, piconero), so every sum is exact; rates are decimal text, never summed.
|
||||
m20261022_name_balances :: Text
|
||||
m20261022_name_balances =
|
||||
withPrefix
|
||||
servicePrefix
|
||||
[r|
|
||||
CREATE TABLE @name_balances(
|
||||
registration_id TEXT NOT NULL PRIMARY KEY REFERENCES @name_registrations,
|
||||
amount INTEGER NOT NULL CHECK (amount >= 0),
|
||||
credited INTEGER NOT NULL DEFAULT 0 CHECK (credited >= 0),
|
||||
refunded INTEGER NOT NULL DEFAULT 0 CHECK (refunded >= 0),
|
||||
reserved_by TEXT REFERENCES @name_registrations,
|
||||
refunded_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX @idx_name_balances_reserved_by ON @name_balances(reserved_by);
|
||||
|
||||
ALTER TABLE @name_registrations ADD COLUMN balance_source TEXT REFERENCES @name_registrations;
|
||||
|
||||
ALTER TABLE @name_registrations ADD COLUMN balance_used INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX @idx_name_registrations_balance_source ON @name_registrations(balance_source);
|
||||
|
||||
ALTER TABLE @payments ADD COLUMN confirmed_amount INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE @payments ADD COLUMN received_units BIGINT;
|
||||
|
||||
ALTER TABLE @payments ADD COLUMN confirmed_units BIGINT;
|
||||
|
||||
ALTER TABLE @payments ADD COLUMN invoice_rate TEXT;
|
||||
|
||||
ALTER TABLE @name_invoices ADD COLUMN late_alerted_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE @name_invoices ADD COLUMN late_alerted_units BIGINT;
|
||||
|
||||
CREATE TABLE @name_late_credits(
|
||||
name_late_credit_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL REFERENCES @invoices,
|
||||
credit_no INTEGER NOT NULL,
|
||||
registration_id TEXT NOT NULL REFERENCES @name_registrations,
|
||||
units BIGINT,
|
||||
invoice_rate TEXT,
|
||||
current_rate TEXT,
|
||||
rate_used TEXT,
|
||||
cents INTEGER NOT NULL CHECK (cents >= 0),
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE(invoice_id, credit_no)
|
||||
);
|
||||
|
||||
CREATE INDEX @idx_name_late_credits_registration ON @name_late_credits(registration_id);
|
||||
|
||||
UPDATE @name_registrations SET live = 0 WHERE phase = 'taken';
|
||||
|
||||
INSERT INTO @name_balances (registration_id, amount, credited, refunded, created_at, updated_at)
|
||||
SELECT registration_id, price, price, 0, updated_at, updated_at FROM @name_registrations
|
||||
WHERE phase = 'taken' AND name_prepaid_code_id IS NULL AND price > 0;
|
||||
|]
|
||||
|
||||
-- Taken orders stay not live.
|
||||
down_m20261022_name_balances :: Text
|
||||
down_m20261022_name_balances =
|
||||
withPrefix
|
||||
servicePrefix
|
||||
[r|
|
||||
DROP INDEX @idx_name_late_credits_registration;
|
||||
DROP TABLE @name_late_credits;
|
||||
|
||||
ALTER TABLE @name_invoices DROP COLUMN late_alerted_units;
|
||||
ALTER TABLE @name_invoices DROP COLUMN late_alerted_at;
|
||||
|
||||
ALTER TABLE @payments DROP COLUMN invoice_rate;
|
||||
ALTER TABLE @payments DROP COLUMN confirmed_units;
|
||||
ALTER TABLE @payments DROP COLUMN received_units;
|
||||
ALTER TABLE @payments DROP COLUMN confirmed_amount;
|
||||
|
||||
DROP INDEX @idx_name_registrations_balance_source;
|
||||
ALTER TABLE @name_registrations DROP COLUMN balance_used;
|
||||
ALTER TABLE @name_registrations DROP COLUMN balance_source;
|
||||
|
||||
DROP INDEX @idx_name_balances_reserved_by;
|
||||
DROP TABLE @name_balances;
|
||||
|]
|
||||
|
||||
{- TODO [badges] deferred with the draft in M20260915_user_badges, service only.
|
||||
|
||||
ALTER TABLE @payments ADD COLUMN receipt_hash BYTEA;
|
||||
|
||||
@@ -21,7 +21,8 @@ schemaMigrations =
|
||||
[ ("20260915_badge_service_schema", m20260915_badge_service_schema, Just down_m20260915_badge_service_schema),
|
||||
("20260918_badge_group_ops", m20260918_badge_group_ops, Just down_m20260918_badge_group_ops),
|
||||
("20261010_names", m20261010_names, Just down_m20261010_names),
|
||||
("20261022_drop_taken_codes", m20261022_drop_taken_codes, Just down_m20261022_drop_taken_codes)
|
||||
("20261022_drop_taken_codes", m20261022_drop_taken_codes, Just down_m20261022_drop_taken_codes),
|
||||
("20261022_name_balances", m20261022_name_balances, Just down_m20261022_name_balances)
|
||||
]
|
||||
|
||||
-- | The client tables share this database, so the service tables are the same names behind a prefix.
|
||||
@@ -356,6 +357,92 @@ ALTER TABLE @name_registrations ADD COLUMN issued_code_id INTEGER REFERENCES @na
|
||||
CREATE INDEX @idx_name_registrations_issued_code ON @name_registrations(issued_code_id);
|
||||
|]
|
||||
|
||||
-- amount = credited - spent - refunded, spent being the balance_used of the orders it paid for that went past unpaid.
|
||||
-- Crypto is counted in integer units (satoshi, piconero), so every sum is exact; rates are decimal text, never summed.
|
||||
m20261022_name_balances :: Query
|
||||
m20261022_name_balances =
|
||||
withPrefix
|
||||
servicePrefix
|
||||
[sql|
|
||||
CREATE TABLE @name_balances(
|
||||
registration_id TEXT NOT NULL PRIMARY KEY REFERENCES @name_registrations,
|
||||
amount INTEGER NOT NULL CHECK (amount >= 0),
|
||||
credited INTEGER NOT NULL DEFAULT 0 CHECK (credited >= 0),
|
||||
refunded INTEGER NOT NULL DEFAULT 0 CHECK (refunded >= 0),
|
||||
reserved_by TEXT REFERENCES @name_registrations,
|
||||
refunded_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE UNIQUE INDEX @idx_name_balances_reserved_by ON @name_balances(reserved_by);
|
||||
|
||||
ALTER TABLE @name_registrations ADD COLUMN balance_source TEXT REFERENCES @name_registrations;
|
||||
|
||||
ALTER TABLE @name_registrations ADD COLUMN balance_used INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX @idx_name_registrations_balance_source ON @name_registrations(balance_source);
|
||||
|
||||
ALTER TABLE @payments ADD COLUMN confirmed_amount INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE @payments ADD COLUMN received_units INTEGER;
|
||||
|
||||
ALTER TABLE @payments ADD COLUMN confirmed_units INTEGER;
|
||||
|
||||
ALTER TABLE @payments ADD COLUMN invoice_rate TEXT;
|
||||
|
||||
ALTER TABLE @name_invoices ADD COLUMN late_alerted_at TEXT;
|
||||
|
||||
ALTER TABLE @name_invoices ADD COLUMN late_alerted_units INTEGER;
|
||||
|
||||
CREATE TABLE @name_late_credits(
|
||||
name_late_credit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invoice_id TEXT NOT NULL REFERENCES @invoices,
|
||||
credit_no INTEGER NOT NULL,
|
||||
registration_id TEXT NOT NULL REFERENCES @name_registrations,
|
||||
units INTEGER,
|
||||
invoice_rate TEXT,
|
||||
current_rate TEXT,
|
||||
rate_used TEXT,
|
||||
cents INTEGER NOT NULL CHECK (cents >= 0),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(invoice_id, credit_no)
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX @idx_name_late_credits_registration ON @name_late_credits(registration_id);
|
||||
|
||||
UPDATE @name_registrations SET live = 0 WHERE phase = 'taken';
|
||||
|
||||
INSERT INTO @name_balances (registration_id, amount, credited, refunded, created_at, updated_at)
|
||||
SELECT registration_id, price, price, 0, updated_at, updated_at FROM @name_registrations
|
||||
WHERE phase = 'taken' AND name_prepaid_code_id IS NULL AND price > 0;
|
||||
|]
|
||||
|
||||
-- Taken orders stay not live.
|
||||
down_m20261022_name_balances :: Query
|
||||
down_m20261022_name_balances =
|
||||
withPrefix
|
||||
servicePrefix
|
||||
[sql|
|
||||
DROP INDEX @idx_name_late_credits_registration;
|
||||
DROP TABLE @name_late_credits;
|
||||
|
||||
ALTER TABLE @name_invoices DROP COLUMN late_alerted_units;
|
||||
ALTER TABLE @name_invoices DROP COLUMN late_alerted_at;
|
||||
|
||||
ALTER TABLE @payments DROP COLUMN invoice_rate;
|
||||
ALTER TABLE @payments DROP COLUMN confirmed_units;
|
||||
ALTER TABLE @payments DROP COLUMN received_units;
|
||||
ALTER TABLE @payments DROP COLUMN confirmed_amount;
|
||||
|
||||
DROP INDEX @idx_name_registrations_balance_source;
|
||||
ALTER TABLE @name_registrations DROP COLUMN balance_used;
|
||||
ALTER TABLE @name_registrations DROP COLUMN balance_source;
|
||||
|
||||
DROP INDEX @idx_name_balances_reserved_by;
|
||||
DROP TABLE @name_balances;
|
||||
|]
|
||||
|
||||
{- TODO [badges] deferred with the draft in M20260915_user_badges, service only.
|
||||
|
||||
ALTER TABLE @payments ADD COLUMN receipt_hash BLOB;
|
||||
|
||||
@@ -40,7 +40,7 @@ import StoreService.Names.Phases (RegistrationId (..))
|
||||
import StoreService.Names.Signatures (OrderFields (..))
|
||||
import StoreService.Names.Store (NameRegistration (..), NewRegistration (..), OrderTerms (..), PaidOrder (..), PrepaidCode (..), RegistrationPayment (..), createPaidRegistration, getRegistration, newRegistrationId, orderHash, textMethod)
|
||||
import StoreService.Names.Turns (withLabelTurn)
|
||||
import StoreService.Names.View (registrationAt, registrationView)
|
||||
import StoreService.Names.View (balanceJSON, registrationAt, registrationView)
|
||||
import StoreService.Orders (publishRegistration)
|
||||
import StoreService.Providers (OrderDraft (..), Provider (..), ProviderError (..), ProviderInvoice (..), expiresItself)
|
||||
import StoreService.Store.Invoices (InvoiceRow (..), NewNameInvoice (..), getInvoice, insertNameInvoice, newInvoiceId, truncateToSecond)
|
||||
@@ -96,6 +96,7 @@ nameInvoiceHandler env@OrdersEnv {oeStore, oeTurns, oeNow} cfg providers waiters
|
||||
answerResend now r@NameRegistration {nrPayment} = case nrPayment of
|
||||
RPInvoice _ invId -> getInvoice oeStore invId >>= maybe (refuse OROrderExists) (fmap answerNow . viewOf (registrationAt now r) . invoiceAt now)
|
||||
RPCode _ -> refuse OROrderExists
|
||||
RPBalance -> refuse OROrderExists
|
||||
viewOf r row = jsonResponse status200 <$> nameInvoiceView env (confirmationsFor cfg) row r
|
||||
newOrder order@InvoiceOrder {ioRequest = OrderRequest {orFields = OrderFields {ofLabelHash, ofLength, ofDuration}, orTimestamp}, ioMethod, ioPrice} = do
|
||||
now <- oeNow
|
||||
@@ -117,6 +118,7 @@ nameInvoiceHandler env@OrdersEnv {oeStore, oeTurns, oeNow} cfg providers waiters
|
||||
invId <- newInvoiceId
|
||||
now <- truncateToSecond <$> oeNow
|
||||
let amount = CurrencyAmount ioPrice
|
||||
price = fromIntegral ioPrice
|
||||
pCreateInvoice provider ioMethod OrderDraft {odAmount = amount, odCurrency = catalogCurrency} >>= \case
|
||||
Left (ProviderError e) -> do
|
||||
logError ("POST /api/invoice (name) failed: provider refused to create an invoice: " <> e)
|
||||
@@ -125,8 +127,8 @@ nameInvoiceHandler env@OrdersEnv {oeStore, oeTurns, oeNow} cfg providers waiters
|
||||
rid <- newRegistrationId
|
||||
let invoice = NewNameInvoice {nniInvoiceId = invId, nniProvider = pProvider provider, nniAmount = amount, nniCurrency = catalogCurrency, nniDestination = piDestination, nniExpiresAt = addUTCTime (invoiceWindow cfg ioMethod) now, nniCreatedAt = now}
|
||||
registration = NewRegistration {nrgId = rid, nrgOwner = orOwner, nrgOrder = orFields, nrgYears = nYears $ oeConfig env}
|
||||
paid = PaidOrder {poMethod = ioMethod, poPrice = fromIntegral ioPrice, poInvoiceId = invId, poProviderRef = piProviderRef}
|
||||
placeOrder env req (\db at -> insertNameInvoice db invoice >> first (const OROrderExists) <$> createPaidRegistration db registration paid at)
|
||||
paid = PaidOrder {poMethod = ioMethod, poPrice = price, poInvoiceId = invId, poProviderRef = piProviderRef}
|
||||
placeOrder env req [] (\db at -> insertNameInvoice db invoice >> first (const OROrderExists) <$> createPaidRegistration db registration (OTInvoice ioMethod price) paid at)
|
||||
`E.catch` strandedBy provider piProviderRef
|
||||
>>= \case
|
||||
Left refusal -> do
|
||||
@@ -206,6 +208,7 @@ announceClosed OrdersEnv {oeStore, oePhases} providers waiters closed = do
|
||||
paidInvoice = \case
|
||||
RPInvoice _ invId -> Just invId
|
||||
RPCode _ -> Nothing
|
||||
RPBalance -> Nothing
|
||||
|
||||
cancelAtProvider :: Provider -> Text -> IO ()
|
||||
cancelAtProvider Provider {pCancelInvoice} ref =
|
||||
@@ -222,8 +225,9 @@ firstRefusal = \case
|
||||
check : rest -> check >>= either (pure . Just) (const $ firstRefusal rest)
|
||||
|
||||
nameInvoiceView :: OrdersEnv -> Maybe Int -> InvoiceRow -> NameRegistration -> IO J.Value
|
||||
nameInvoiceView OrdersEnv {oeStore, oeConfig, oeContracts} confirmations row@InvoiceRow {irInvoiceId = InvoiceId invId} r = do
|
||||
view <- registrationView oeStore oeConfig oeContracts r
|
||||
nameInvoiceView OrdersEnv {oeStore, oeConfig, oeContracts, oeNow} confirmations row@InvoiceRow {irInvoiceId = InvoiceId invId} r = do
|
||||
now <- oeNow
|
||||
view <- registrationView oeStore oeConfig oeContracts now r
|
||||
pure $ J.object $ ["invoiceId" .= invId, "kind" .= ("name" :: Text), "registration" .= view] <> invoicePairs confirmations row
|
||||
|
||||
orderRefusalResponse :: OrderRefusal -> Response
|
||||
@@ -236,3 +240,4 @@ orderRefusalResponse = \case
|
||||
ORCatalogChanged price -> jsonResponse status409 $ J.object ["error" .= ("catalog_changed" :: Text), "price" .= price]
|
||||
ORCode refusal -> codeRefusalResponse refusal
|
||||
ORPriceTooHigh price -> jsonResponse status409 $ J.object ["error" .= ("price_too_high" :: Text), "price" .= price]
|
||||
ORBalanceChanged balance -> jsonResponse status409 $ J.object ["error" .= ("balance_changed" :: Text), "balance" .= (balanceJSON <$> balance)]
|
||||
@@ -79,6 +79,7 @@ foundJSON NameRegistration {nrId = RegistrationId rid, nrPayment, nrPhase, nrCre
|
||||
invoiceId = case nrPayment of
|
||||
RPInvoice _ (InvoiceId t) -> Just t
|
||||
RPCode _ -> Nothing
|
||||
RPBalance -> Nothing
|
||||
|
||||
-- | POST /api/registration/:id/name, another name or other links, under the turns of both names.
|
||||
retargetHandler :: OrdersEnv -> Text -> Request -> Respond -> IO ResponseReceived
|
||||
@@ -105,7 +106,7 @@ retargetHandler env@OrdersEnv {oeStore, oeConfig, oeContracts, oeTurns, oeNow} r
|
||||
| nrLabelHash stored `notElem` held -> changed
|
||||
| otherwise -> moveTo now (registrationAt now stored) fields timestamp
|
||||
moveTo now r@NameRegistration {nrPhase} fields timestamp
|
||||
| orderFieldsOf r == fields = answerNow . jsonResponse status200 <$> registrationView oeStore oeConfig oeContracts r
|
||||
| orderFieldsOf r == fields = answerNow . jsonResponse status200 <$> registrationView oeStore oeConfig oeContracts now r
|
||||
| Left refusal <- checkTimestamp now timestamp = refuse refusal
|
||||
| not (movable r) = refuse ORBadRequest
|
||||
| nrPhase == PFailed =
|
||||
@@ -122,6 +123,7 @@ retargetHandler env@OrdersEnv {oeStore, oeConfig, oeContracts, oeTurns, oeNow} r
|
||||
Just refusal -> refuse refusal
|
||||
Nothing -> case nrPayment of
|
||||
RPInvoice {} -> checkMovePrice oeContracts r ofLength >>= either refuse (write now r fields)
|
||||
RPBalance -> checkMovePrice oeContracts r ofLength >>= either refuse (write now r fields)
|
||||
RPCode _ -> write now r fields nrPrice
|
||||
write now r fields price =
|
||||
retargetOrder env r fields price >>= \case
|
||||
@@ -130,7 +132,7 @@ retargetHandler env@OrdersEnv {oeStore, oeConfig, oeContracts, oeTurns, oeNow} r
|
||||
Left (WRRefused refusal) -> refuse refusal
|
||||
movedAnswer now v =
|
||||
withTransaction oeStore (`getRegistration` rid) >>= \case
|
||||
Just r -> written env rid v . jsonResponse status200 <$> registrationView oeStore oeConfig oeContracts (registrationAt now r)
|
||||
Just r -> written env rid v . jsonResponse status200 <$> registrationView oeStore oeConfig oeContracts now (registrationAt now r)
|
||||
Nothing -> answerNow internalError <$ logError ("POST /api/registration/:id/name: order " <> ridText <> " is not readable after its move")
|
||||
changed = answerNow <$> registrationChangedResponse env rid
|
||||
refuse = refused "POST /api/registration/:id/name"
|
||||
|
||||
@@ -54,7 +54,7 @@ registrationHandler OrdersEnv {oeStore, oeConfig, oeContracts, oePhases, oeNow}
|
||||
where
|
||||
rid = RegistrationId ridText
|
||||
readNow = oeNow >>= \now -> fmap (now,) <$> withTransaction oeStore (`getRegistration` rid)
|
||||
answer (now, r) = respond . jsonResponse status200 =<< registrationView oeStore oeConfig oeContracts (registrationAt now r)
|
||||
answer (now, r) = respond . jsonResponse status200 =<< registrationView oeStore oeConfig oeContracts now (registrationAt now r)
|
||||
|
||||
-- | What a reveal decided in the name's turn, with the version it wrote; the publish and the answer come after the turn.
|
||||
data RevealDecision
|
||||
@@ -150,7 +150,7 @@ registrationRefusalResponse env tag rid = maybe notFound refusal <$> currentView
|
||||
-- | The registration's view as it reads now.
|
||||
currentView :: OrdersEnv -> RegistrationId -> IO (Maybe J.Value)
|
||||
currentView OrdersEnv {oeStore, oeConfig, oeContracts, oeNow} rid =
|
||||
withTransaction oeStore (`getRegistration` rid) >>= traverse (\r -> oeNow >>= \now -> registrationView oeStore oeConfig oeContracts (registrationAt now r))
|
||||
withTransaction oeStore (`getRegistration` rid) >>= traverse (\r -> oeNow >>= \now -> registrationView oeStore oeConfig oeContracts now (registrationAt now r))
|
||||
|
||||
-- | The hold, cut to end just past the next moment at which the answer changes with time alone.
|
||||
heldFor :: Int -> UTCTime -> [UTCTime] -> Int
|
||||
|
||||
@@ -443,6 +443,7 @@ executable simplex-store-service
|
||||
StoreService.Group.Command
|
||||
StoreService.Log
|
||||
StoreService.Names.Abi
|
||||
StoreService.Names.Balances
|
||||
StoreService.Names.Chain
|
||||
StoreService.Names.Codes
|
||||
StoreService.Names.Contracts
|
||||
@@ -767,6 +768,7 @@ test-suite simplex-chat-test
|
||||
Bots.StoreService.GroupIntegrationTests
|
||||
Bots.StoreService.GroupTests
|
||||
Bots.StoreService.NameRegistrationTests
|
||||
Bots.StoreService.NamesBalancesTests
|
||||
Bots.StoreService.NamesChainTests
|
||||
Bots.StoreService.NamesCodesTests
|
||||
Bots.StoreService.NamesContractsTests
|
||||
@@ -806,6 +808,7 @@ test-suite simplex-chat-test
|
||||
StoreService.Group.Command
|
||||
StoreService.Log
|
||||
StoreService.Names.Abi
|
||||
StoreService.Names.Balances
|
||||
StoreService.Names.Chain
|
||||
StoreService.Names.Codes
|
||||
StoreService.Names.Contracts
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
{-# LANGUAGE CPP #-}
|
||||
{-# LANGUAGE LambdaCase #-}
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE TupleSections #-}
|
||||
{-# LANGUAGE TypeOperators #-}
|
||||
|
||||
module Bots.StoreService.NamesBalancesTests (namesBalancesTests) where
|
||||
|
||||
import Bots.StoreService.NamesOrdersTests (Ctx (..), withOrders)
|
||||
import Bots.StoreService.NamesStoreTests (at, btc, invoiceStatus, newReg, ownerA, ownerB, paidOrder, reg, signature, t0, write)
|
||||
import Bots.StoreService.WebTests (capturingStderr, rcv, withServiceStore, withServiceStores)
|
||||
import Control.Concurrent.Async (async, wait)
|
||||
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
|
||||
import Control.Concurrent.STM (atomically, check)
|
||||
import Control.Monad (forM_)
|
||||
import Data.Bifunctor (first)
|
||||
import Data.Int (Int64)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.Time.Clock (UTCTime, addUTCTime)
|
||||
import Simplex.Chat.PaymentService.Types (CardProvider (..), InvoiceId (..), InvoiceStatus (..), ServicePaymentMethod (..))
|
||||
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.SimplexName (labelHash)
|
||||
import StoreService.Config (ServiceConfig (..))
|
||||
import StoreService.Names.Balances
|
||||
import StoreService.Names.Orders (ClosedOrder (..), OrderRefusal (..), OrderRequest (..), OrdersEnv (..))
|
||||
import qualified StoreService.Names.Orders as Orders
|
||||
import StoreService.Names.Phases (RegistrationId (..))
|
||||
import StoreService.Names.Signatures (OrderFields (..))
|
||||
import StoreService.Names.Store
|
||||
import StoreService.Names.Turns (balanceTurnCount, newTurns, turnsCount, withBalanceTurn, withLabelTurn)
|
||||
import StoreService.Orders (cancelOpenInvoice, expireOverdue, settleOrder)
|
||||
import StoreService.Poller (cleanupUnpaid, newPollerEnv, unpaidRetention)
|
||||
import StoreService.Providers (PaymentSignal (..))
|
||||
import StoreService.Waiters (newWaiters)
|
||||
import StoreService.Web.Server (WebEnv (..))
|
||||
import System.Timeout (timeout)
|
||||
import Test.Hspec
|
||||
|
||||
#if defined(dbPostgres)
|
||||
import Control.Concurrent.Async (concurrently)
|
||||
import Control.Concurrent.STM (modifyTVar', newTVarIO, readTVar)
|
||||
import Data.Either (isRight)
|
||||
import Database.PostgreSQL.Simple (Only (..), (:.) (..))
|
||||
#else
|
||||
import Database.SQLite.Simple (Only (..), (:.) (..))
|
||||
#endif
|
||||
|
||||
namesBalancesTests :: Spec
|
||||
namesBalancesTests = describe "StoreService names: balances" $ do
|
||||
describe "store" $ do
|
||||
it "credits a balance, making it on the first credit" testCredit
|
||||
it "spends a covered price at once from the amount seen, refusing a stale amount, a dearer price or a held balance, changing nothing" testSpendAtOnce
|
||||
it "lets one of two difference orders hold a balance, the losing order not written" testTwoHolds
|
||||
it "spends a hold only for its holder and while the amount covers it, clearing the hold" testSpendHeld
|
||||
it "releases a hold only for its holder" testRelease
|
||||
it "refuses spends, holds and held spends of 0 or fewer cents, and negative credits, refunds and late credits" testNoNegativeCents
|
||||
it "refunds a balance only at its amount, above 0 and not held, refused while held and accepted once released" testRefund
|
||||
it "keeps amount = credited - spent - refunded over spends at once, a held spend, a released hold and a refund, spent counting orders past unpaid" testInvariant
|
||||
it "keeps the invariant over credit, refund, credit, refund, refunded the sum" testCreditRefundTwice
|
||||
it "reads a hold and a spend back through a new store connection" testRestart
|
||||
it "finds an order and its balance by the order's registration id or its invoice id" testByReference
|
||||
it "chains an order back along its balance sources over three orders, with each payment and its cents" testChain
|
||||
it "stops the chain of a closed difference order at itself, with its late credits" testChainOfClosed
|
||||
it "hashes a balance order over its fields, the paying order, price, balance and method, and stores it for a difference spend" testBalanceOrderHash
|
||||
it "returns the 50 newest orders and then every older one holding a balance above 0" testFindBalances
|
||||
describe "closes" $ do
|
||||
it "releases a hold at a cancel, the sweep's expiry, settlement's expiry and the next order's close" testClosesRelease
|
||||
it "reads an overdue holder's hold as none, and closes it in the transaction of the order placed" testOverdueHolderClosed
|
||||
it "skips a named holder no longer live, and refuses with the balance when a live one cannot be closed" testNamedHolders
|
||||
it "refuses with the balance a named holder whose invoice has not expired, leaving it open and holding" testNamedHolderNotDue
|
||||
it "keeps at the cleanup, and logs, an unpaid order still holding a balance" testCleanupKeepsHolder
|
||||
describe "refunds" $ do
|
||||
it "refuses no order, no balance, a balance held by an open or overdue holder, and another amount, then records one with its chain" testRecordRefund
|
||||
describe "late credits" $ do
|
||||
it "credits a late payment once per credit number, a taken number changing neither the rows nor the balance" testCreditLatePayment
|
||||
it "tells each case of pending money and selects confirmed money not all credited, by the order whatever the invoice's status" testLateScans
|
||||
it "leaves 0.1 and 0.2 BTC credited against 0.3 confirmed neither pending nor selected" testExactUnits
|
||||
it "tells confirmed money not credited as such while its cents still read 0, as Monero's can" testConfirmedBeforeCents
|
||||
it "reads crypto received with no confirmed figure as unconfirmed" testNoConfirmedFigure
|
||||
it "alerts on pending money past the cutoff once, again only when more arrives, claimed by one of two instances" testLateAlerts
|
||||
it "alerts again on money that could not be counted in units when more of it arrives" testUncountedAlertRepeats
|
||||
describe "turns" $ do
|
||||
it "runs two holders of one balance one after the other, and nests in a label turn" testBalanceTurn
|
||||
#if defined(dbPostgres)
|
||||
describe "two connections" $ do
|
||||
it "applies one of a spend and a refund, or of a held spend and a refund, and keeps the invariant for a credit and a refund" testConcurrentWrites
|
||||
#endif
|
||||
|
||||
-- fixtures
|
||||
|
||||
-- | Every name and commitment its own, so any number of live orders fit.
|
||||
fieldsN :: Text -> OrderFields
|
||||
fieldsN label =
|
||||
OrderFields
|
||||
{ ofLabelHash = labelHash label,
|
||||
ofLength = T.length label,
|
||||
ofDuration = 69292800,
|
||||
ofCommitment = C.sha256Hash $ encodeUtf8 label,
|
||||
ofSealed = "sealed-" <> label
|
||||
}
|
||||
|
||||
-- | A crypto invoice row, or a card one with its client secret in place of an address.
|
||||
invoiceRow :: DB.Connection -> Text -> Bool -> Int -> UTCTime -> IO ()
|
||||
invoiceRow db invId card cents expiresAt =
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO sx_badge_service_invoices (invoice_id, provider, price, amount, currency, payment_url, payment_address, payment_crypto_currency, payment_crypto_amount, expires_at, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
((invId, provider, cents, cents, "usd" :: Text, url, address, currency, cryptoAmount, expiresAt) :. ("open" :: Text, t0, t0))
|
||||
where
|
||||
destination :: (Text, Maybe Text, Maybe Text, Maybe Text, Maybe Text)
|
||||
destination
|
||||
| card = ("stripe", Just "pi_secret", Nothing, Nothing, Nothing)
|
||||
| otherwise = ("crypto", Nothing, Just "bc1qexampleaddress", Just "btc", Just "0.003")
|
||||
(provider, url, address, currency, cryptoAmount) = destination
|
||||
|
||||
-- | A paid order, unpaid and live, whose invoice of the cents expires at the given time.
|
||||
placeOrderRow :: DBStore -> Text -> Bool -> Int -> UTCTime -> IO ()
|
||||
placeOrderRow st r card cents expiresAt = withTransaction st $ \db -> do
|
||||
invoiceRow db ("inv-" <> r) card cents expiresAt
|
||||
createPaidRegistration db (newReg r ownerA (fieldsN r)) (OTInvoice method cents) (paidOrder ("inv-" <> r)) {poMethod = method, poPrice = cents} t0 `shouldReturn` Right ()
|
||||
where
|
||||
method = if card then SPMCard CPStripe else btc
|
||||
|
||||
-- | A taken paid order with a balance of its price.
|
||||
source :: DBStore -> Text -> Int -> IO ()
|
||||
source st r cents = do
|
||||
placeOrderRow st r False cents (at 60)
|
||||
taken <- reg st r
|
||||
write st taken {nrPhase = PTaken} `shouldReturn` Written 2
|
||||
credit st r cents
|
||||
|
||||
credit :: DBStore -> Text -> Int -> IO ()
|
||||
credit st r cents = withTransaction st $ \db -> creditBalance db (RegistrationId r) cents t0
|
||||
|
||||
holdPrice :: Int
|
||||
holdPrice = 40000
|
||||
|
||||
holdTerms :: Text -> Int -> OrderTerms
|
||||
holdTerms src seen = OTBalance (RegistrationId src) holdPrice seen (Just btc)
|
||||
|
||||
-- | A difference order holding all of the source's balance seen, its invoice of the difference expiring at the given time.
|
||||
holder :: DBStore -> Text -> Text -> Int -> UTCTime -> IO (Either RegistrationRefused ())
|
||||
holder st r src seen expiresAt = withTransaction st $ \db -> do
|
||||
invoiceRow db ("inv-" <> r) False (holdPrice - seen) expiresAt
|
||||
createPaidRegistration db (newReg r ownerA (fieldsN r)) (holdTerms src seen) (paidOrder ("inv-" <> r)) {poPrice = holdPrice} t0
|
||||
|
||||
hold :: Text -> UTCTime -> Bool -> BalanceHold
|
||||
hold h bhUntil bhOverdue = BalanceHold {bhHolder = RegistrationId h, bhUntil, bhOverdue}
|
||||
|
||||
coveredSpend :: DBStore -> Text -> Text -> Int -> Int -> IO (Either RegistrationRefused ())
|
||||
coveredSpend st r src price seen = withTransaction st $ \db -> createBalanceRegistration db (newReg r ownerA (fieldsN r)) (RegistrationId src) price seen t0
|
||||
|
||||
-- | The holder queued and its hold spent in one transaction, as settlement does.
|
||||
settleHolder :: DB.Connection -> NameRegistration -> IO Bool
|
||||
settleHolder db r = do
|
||||
updateRegistration db r {nrPhase = PQueued} t0 `shouldReturn` Written (nrVersion r + 1)
|
||||
spendHeldBalance db (nrId r) (nrBalanceUsed r) t0
|
||||
|
||||
release :: DBStore -> Text -> IO Bool
|
||||
release st h = withTransaction st $ \db -> releaseHeldBalance db (RegistrationId h) t0
|
||||
|
||||
refund :: DBStore -> Text -> Int -> IO Bool
|
||||
refund st r cents = withTransaction st $ \db -> refundBalance db (RegistrationId r) cents (at 5)
|
||||
|
||||
-- | Amount, credited, refunded and the hold, as they read at the given time.
|
||||
figures :: DBStore -> Text -> UTCTime -> IO (Maybe (Int, Int, Int, Maybe BalanceHold))
|
||||
figures st r now = withTransaction st $ \db -> fmap (\Balance {blAmount, blCredited, blRefunded, blHold} -> (blAmount, blCredited, blRefunded, blHold)) <$> getBalance db (RegistrationId r) now
|
||||
|
||||
-- | amount = credited - spent - refunded, spent being the balance_used of the orders past unpaid it paid for.
|
||||
invariantHolds :: DBStore -> Text -> IO ()
|
||||
invariantHolds st r = do
|
||||
Just (amount, credited, refunded, _) <- figures st r t0
|
||||
used <- withTransaction st $ \db -> DB.query db "SELECT balance_used FROM sx_badge_service_name_registrations WHERE balance_source = ? AND phase <> ?" (r, PUnpaid)
|
||||
(r, amount) `shouldBe` (r, credited - sum (map fromOnly (used :: [Only Int])) - refunded)
|
||||
|
||||
absent :: DBStore -> Text -> IO ()
|
||||
absent st r = withTransaction st (\db -> getRegistration db (RegistrationId r)) `shouldReturn` Nothing
|
||||
|
||||
-- | A payment row with the figures the provider's reads left: cents received and confirmed, the crypto text, and units received and confirmed.
|
||||
data Paid = Paid
|
||||
{ pCents :: Int,
|
||||
pConfirmedCents :: Int,
|
||||
pCrypto :: Maybe Text,
|
||||
pUnits :: Maybe Int64,
|
||||
pConfirmedUnits :: Maybe Int64,
|
||||
pInFull :: Bool
|
||||
}
|
||||
|
||||
cryptoPaid :: Int64 -> Int64 -> Paid
|
||||
cryptoPaid units confirmed = Paid {pCents = 20000, pConfirmedCents = 0, pCrypto = Just "0.003", pUnits = Just units, pConfirmedUnits = Just confirmed, pInFull = False}
|
||||
|
||||
cardPaid :: Int -> Int -> Paid
|
||||
cardPaid cents confirmed = Paid {pCents = cents, pConfirmedCents = confirmed, pCrypto = Nothing, pUnits = Nothing, pConfirmedUnits = Nothing, pInFull = False}
|
||||
|
||||
payment :: DBStore -> Text -> Paid -> UTCTime -> IO ()
|
||||
payment st invId Paid {pCents, pConfirmedCents, pCrypto, pUnits, pConfirmedUnits, pInFull} createdAt =
|
||||
withTransaction st $ \db ->
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO sx_badge_service_payments (payment_id, invoice_id, provider, amount, currency, crypto_paid, paid_in_full, status, confirmed_amount, received_units, confirmed_units, invoice_rate, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
|
||||
((invId, invId, "crypto" :: Text, pCents, "usd" :: Text, pCrypto, if pInFull then 1 else 0 :: Int, "pending" :: Text, pConfirmedCents, pUnits) :. (pConfirmedUnits, Just ("62500" :: Text), createdAt, createdAt))
|
||||
|
||||
-- | An order closed unpaid, by crypto unless card, with a late payment row created at the given time.
|
||||
closedWith :: DBStore -> Text -> Bool -> Paid -> UTCTime -> IO ()
|
||||
closedWith st r card paid createdAt = do
|
||||
placeOrderRow st r card 20000 (at 60)
|
||||
open <- reg st r
|
||||
write st open {nrLive = False} `shouldReturn` Written 2
|
||||
payment st ("inv-" <> r) paid createdAt
|
||||
|
||||
lateCredit :: Text -> Int -> Maybe Int64 -> Int -> LateCredit
|
||||
lateCredit r n units cents =
|
||||
LateCredit
|
||||
{ lcInvoice = InvoiceId ("inv-" <> r),
|
||||
lcCreditNo = n,
|
||||
lcRegistration = RegistrationId r,
|
||||
lcUnits = units,
|
||||
lcInvoiceRate = Just "62500",
|
||||
lcCurrentRate = Just "60000.5",
|
||||
lcRateUsed = Just "60000.5",
|
||||
lcCents = cents
|
||||
}
|
||||
|
||||
creditLate :: DBStore -> LateCredit -> IO Bool
|
||||
creditLate st c = withTransaction st $ \db -> creditLatePayment db c (at 3)
|
||||
|
||||
orderRequest :: Text -> OrderRequest
|
||||
orderRequest r = OrderRequest {orOwner = ownerA, orFields = fieldsN r, orTimestamp = 0, orSignature = signature}
|
||||
|
||||
spendInsert :: Text -> Text -> Int -> Int -> DB.Connection -> UTCTime -> IO (Either OrderRefusal ())
|
||||
spendInsert r src price seen db now = first (const $ ORBalanceChanged Nothing) <$> createBalanceRegistration db (newReg r ownerA (fieldsN r)) (RegistrationId src) price seen now
|
||||
|
||||
closedIds :: Either OrderRefusal [ClosedOrder] -> Either OrderRefusal [(RegistrationId, Int64)]
|
||||
closedIds = fmap $ map $ \ClosedOrder {coRegistration, coVersion} -> (nrId coRegistration, coVersion)
|
||||
|
||||
-- store
|
||||
|
||||
testCredit :: IO ()
|
||||
testCredit = withServiceStore $ \st -> do
|
||||
placeOrderRow st "s" False 20000 (at 60)
|
||||
figures st "s" t0 `shouldReturn` Nothing
|
||||
credit st "s" 20000
|
||||
credit st "s" 2500
|
||||
figures st "s" t0 `shouldReturn` Just (22500, 22500, 0, Nothing)
|
||||
|
||||
testSpendAtOnce :: IO ()
|
||||
testSpendAtOnce = withServiceStore $ \st -> do
|
||||
source st "s" 10000
|
||||
coveredSpend st "c1" "s" 6000 10000 `shouldReturn` Right ()
|
||||
c1 <- reg st "c1"
|
||||
(nrPhase c1, nrLive c1, nrPrice c1, nrPayment c1, nrBalanceSource c1, nrBalanceUsed c1) `shouldBe` (PQueued, True, 6000, RPBalance, Just (RegistrationId "s"), 6000)
|
||||
nrOrderHash c1 `shouldBe` orderHash ownerA (fieldsN "c1") (OTBalance (RegistrationId "s") 6000 10000 Nothing)
|
||||
figures st "s" t0 `shouldReturn` Just (4000, 10000, 0, Nothing)
|
||||
coveredSpend st "c2" "s" 3000 10000 `shouldReturn` Left RRBalanceChanged
|
||||
coveredSpend st "c3" "s" 5000 4000 `shouldReturn` Left RRBalanceChanged
|
||||
mapM_ (absent st) ["c2", "c3"]
|
||||
figures st "s" t0 `shouldReturn` Just (4000, 10000, 0, Nothing)
|
||||
holder st "h" "s" 4000 (at 60) `shouldReturn` Right ()
|
||||
coveredSpend st "c4" "s" 1000 4000 `shouldReturn` Left RRBalanceChanged
|
||||
absent st "c4"
|
||||
release st "h" `shouldReturn` True
|
||||
coveredSpend st "c5" "s" 4000 4000 `shouldReturn` Right ()
|
||||
figures st "s" t0 `shouldReturn` Just (0, 10000, 0, Nothing)
|
||||
|
||||
testTwoHolds :: IO ()
|
||||
testTwoHolds = withServiceStore $ \st -> do
|
||||
source st "s" 10000
|
||||
holder st "h1" "s" 10000 (at 60) `shouldReturn` Right ()
|
||||
holder st "h2" "s" 10000 (at 60) `shouldReturn` Left RRBalanceChanged
|
||||
absent st "h2"
|
||||
h1 <- reg st "h1"
|
||||
(nrPhase h1, nrPayment h1, nrPrice h1, nrBalanceSource h1, nrBalanceUsed h1) `shouldBe` (PUnpaid, RPInvoice btc (InvoiceId "inv-h1"), holdPrice, Just (RegistrationId "s"), 10000)
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Just $ hold "h1" (at 60) False)
|
||||
release st "h1" `shouldReturn` True
|
||||
holder st "h3" "s" 9000 (at 60) `shouldReturn` Left RRBalanceChanged
|
||||
absent st "h3"
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Nothing)
|
||||
|
||||
testSpendHeld :: IO ()
|
||||
testSpendHeld = withServiceStore $ \st -> do
|
||||
source st "s" 10000
|
||||
source st "other" 5000
|
||||
holder st "h" "s" 10000 (at 60) `shouldReturn` Right ()
|
||||
holder st "g" "other" 5000 (at 60) `shouldReturn` Right ()
|
||||
let spendBy :: Text -> Int -> IO Bool
|
||||
spendBy h used = withTransaction st $ \db -> spendHeldBalance db (RegistrationId h) used t0
|
||||
spendBy "nobody" 10000 `shouldReturn` False
|
||||
spendBy "h" 10001 `shouldReturn` False
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Just $ hold "h" (at 60) False)
|
||||
spendBy "g" 5000 `shouldReturn` True
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Just $ hold "h" (at 60) False)
|
||||
figures st "other" t0 `shouldReturn` Just (0, 5000, 0, Nothing)
|
||||
spendBy "h" 10000 `shouldReturn` True
|
||||
figures st "s" t0 `shouldReturn` Just (0, 10000, 0, Nothing)
|
||||
spendBy "h" 0 `shouldReturn` False
|
||||
|
||||
testRelease :: IO ()
|
||||
testRelease = withServiceStore $ \st -> do
|
||||
source st "s" 10000
|
||||
holder st "h" "s" 10000 (at 60) `shouldReturn` Right ()
|
||||
release st "g" `shouldReturn` False
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Just $ hold "h" (at 60) False)
|
||||
release st "h" `shouldReturn` True
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Nothing)
|
||||
release st "h" `shouldReturn` False
|
||||
|
||||
testNoNegativeCents :: IO ()
|
||||
testNoNegativeCents = withServiceStore $ \st -> do
|
||||
source st "s" 10000
|
||||
mapM (\(r, cents) -> coveredSpend st r "s" cents 10000) [("c0", 0), ("c1", -1)] `shouldReturn` replicate 2 (Left RRBalanceChanged)
|
||||
mapM_ (absent st) ["c0", "c1"]
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Nothing)
|
||||
holder st "h" "s" 10000 (at 60) `shouldReturn` Right ()
|
||||
mapM (\used -> withTransaction st $ \db -> spendHeldBalance db (RegistrationId "h") used t0) [0, -1] `shouldReturn` [False, False]
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Just $ hold "h" (at 60) False)
|
||||
source st "z" 0
|
||||
holder st "hz" "z" 0 (at 60) `shouldReturn` Left RRBalanceChanged
|
||||
figures st "z" t0 `shouldReturn` Just (0, 0, 0, Nothing)
|
||||
placeOrderRow st "x" False 20000 (at 60)
|
||||
credit st "x" 5000
|
||||
creditLate st (lateCredit "x" 1 Nothing (-1)) `shouldThrow` anyException
|
||||
figures st "x" t0 `shouldReturn` Just (5000, 5000, 0, Nothing)
|
||||
forM_ ["credited", "refunded"] $ \column ->
|
||||
withTransaction st (\db -> DB.execute_ db $ "UPDATE sx_badge_service_name_balances SET " <> column <> " = -1 WHERE registration_id = 's'") `shouldThrow` anyException
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Just $ hold "h" (at 60) False)
|
||||
|
||||
testRefund :: IO ()
|
||||
testRefund = withServiceStore $ \st -> do
|
||||
source st "s" 10000
|
||||
holder st "h" "s" 10000 (at 60) `shouldReturn` Right ()
|
||||
refund st "s" 10000 `shouldReturn` False
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Just $ hold "h" (at 60) False)
|
||||
release st "h" `shouldReturn` True
|
||||
mapM (refund st "s") [9999, 10001] `shouldReturn` [False, False]
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Nothing)
|
||||
refund st "s" 10000 `shouldReturn` True
|
||||
Just Balance {blAmount, blCredited, blRefunded, blRefundedAt} <- withTransaction st $ \db -> getBalance db (RegistrationId "s") t0
|
||||
(blAmount, blCredited, blRefunded, blRefundedAt) `shouldBe` (0, 10000, 10000, Just (at 5))
|
||||
mapM (refund st "s") [0, 10000] `shouldReturn` [False, False]
|
||||
|
||||
testInvariant :: IO ()
|
||||
testInvariant = withServiceStore $ \st -> do
|
||||
source st "s" 30000
|
||||
invariantHolds st "s"
|
||||
coveredSpend st "c1" "s" 5000 30000 `shouldReturn` Right ()
|
||||
invariantHolds st "s"
|
||||
holder st "h1" "s" 25000 (at 60) `shouldReturn` Right ()
|
||||
invariantHolds st "s"
|
||||
h1 <- reg st "h1"
|
||||
withTransaction st (`settleHolder` h1) `shouldReturn` True
|
||||
figures st "s" t0 `shouldReturn` Just (0, 30000, 0, Nothing)
|
||||
invariantHolds st "s"
|
||||
credit st "s" 8000
|
||||
holder st "h2" "s" 8000 (at 60) `shouldReturn` Right ()
|
||||
h2 <- reg st "h2"
|
||||
withTransaction st (\db -> closeOverdue db h2 (at 61)) `shouldReturn` Just (nrVersion h2 + 1)
|
||||
figures st "s" t0 `shouldReturn` Just (8000, 38000, 0, Nothing)
|
||||
invariantHolds st "s"
|
||||
refund st "s" 8000 `shouldReturn` True
|
||||
figures st "s" t0 `shouldReturn` Just (0, 38000, 8000, Nothing)
|
||||
invariantHolds st "s"
|
||||
|
||||
testCreditRefundTwice :: IO ()
|
||||
testCreditRefundTwice = withServiceStore $ \st -> do
|
||||
source st "s" 10000
|
||||
refund st "s" 10000 `shouldReturn` True
|
||||
credit st "s" 4000
|
||||
refund st "s" 4000 `shouldReturn` True
|
||||
figures st "s" t0 `shouldReturn` Just (0, 14000, 14000, Nothing)
|
||||
invariantHolds st "s"
|
||||
|
||||
testRestart :: IO ()
|
||||
testRestart = withServiceStores $ \open -> do
|
||||
st <- open
|
||||
source st "s" 10000
|
||||
source st "t" 8000
|
||||
holder st "h" "s" 10000 (at 60) `shouldReturn` Right ()
|
||||
coveredSpend st "c" "t" 3000 8000 `shouldReturn` Right ()
|
||||
let readBack :: DBStore -> IO (Maybe (Int, Int, Int, Maybe BalanceHold), Maybe (Int, Int, Int, Maybe BalanceHold), NameRegistration, NameRegistration)
|
||||
readBack store = (,,,) <$> figures store "s" t0 <*> figures store "t" t0 <*> reg store "h" <*> reg store "c"
|
||||
stored@(held, spent, _, c) <- readBack st
|
||||
(held, spent, nrBalanceUsed c) `shouldBe` (Just (10000, 10000, 0, Just $ hold "h" (at 60) False), Just (5000, 8000, 0, Nothing), 3000)
|
||||
restarted <- open
|
||||
readBack restarted `shouldReturn` stored
|
||||
|
||||
testByReference :: IO ()
|
||||
testByReference = withServiceStore $ \st -> do
|
||||
source st "s" 10000
|
||||
placeOrderRow st "plain" False 20000 (at 60)
|
||||
let byRef :: Text -> IO (Maybe (RegistrationId, Maybe Int))
|
||||
byRef ref = withTransaction st $ \db -> fmap (fmap (fmap blAmount)) <$> balanceByReference db ref t0
|
||||
mapM byRef ["s", "inv-s"] `shouldReturn` replicate 2 (Just (RegistrationId "s", Just 10000))
|
||||
mapM byRef ["plain", "inv-plain"] `shouldReturn` replicate 2 (Just (RegistrationId "plain", Nothing))
|
||||
byRef "nothing" `shouldReturn` Nothing
|
||||
|
||||
testChain :: IO ()
|
||||
testChain = withServiceStore $ \st -> do
|
||||
source st "a" 20000
|
||||
holder st "b" "a" 20000 (at 60) `shouldReturn` Right ()
|
||||
b <- reg st "b"
|
||||
withTransaction st (`settleHolder` b) `shouldReturn` True
|
||||
queued <- reg st "b"
|
||||
write st queued {nrPhase = PTaken} `shouldReturn` Written (nrVersion queued + 1)
|
||||
credit st "b" holdPrice
|
||||
coveredSpend st "c" "b" 10000 holdPrice `shouldReturn` Right ()
|
||||
withTransaction st (\db -> balanceChain db (RegistrationId "c"))
|
||||
`shouldReturn` [ BalanceLink {lkRegistration = RegistrationId "c", lkPayment = RPBalance, lkPaid = 0, lkFromBalance = 10000, lkLateCredits = []},
|
||||
BalanceLink {lkRegistration = RegistrationId "b", lkPayment = RPInvoice btc (InvoiceId "inv-b"), lkPaid = holdPrice - 20000, lkFromBalance = 20000, lkLateCredits = []},
|
||||
BalanceLink {lkRegistration = RegistrationId "a", lkPayment = RPInvoice btc (InvoiceId "inv-a"), lkPaid = 20000, lkFromBalance = 0, lkLateCredits = []}
|
||||
]
|
||||
|
||||
testChainOfClosed :: IO ()
|
||||
testChainOfClosed = withServiceStore $ \st -> do
|
||||
source st "s" 15000
|
||||
holder st "d" "s" 15000 (at 60) `shouldReturn` Right ()
|
||||
waiters <- newWaiters
|
||||
cancelOpenInvoice st waiters Nothing (InvoiceId "inv-d") (at 2) `shouldReturn` True
|
||||
figures st "s" t0 `shouldReturn` Just (15000, 15000, 0, Nothing)
|
||||
let late = lateCredit "d" 1 (Just 40000) 2400
|
||||
creditLate st late `shouldReturn` True
|
||||
withTransaction st (\db -> balanceChain db (RegistrationId "d"))
|
||||
`shouldReturn` [BalanceLink {lkRegistration = RegistrationId "d", lkPayment = RPInvoice btc (InvoiceId "inv-d"), lkPaid = 0, lkFromBalance = 0, lkLateCredits = [late]}]
|
||||
|
||||
testBalanceOrderHash :: IO ()
|
||||
testBalanceOrderHash = withServiceStore $ \st -> do
|
||||
let o = fieldsN "h"
|
||||
terms = holdTerms "s" 15000
|
||||
base = orderHash ownerA o terms
|
||||
src = RegistrationId "s"
|
||||
variants =
|
||||
[ ("labelHash" :: Text, orderHash ownerA o {ofLabelHash = labelHash "hh"} terms),
|
||||
("length", orderHash ownerA o {ofLength = 2} terms),
|
||||
("duration", orderHash ownerA o {ofDuration = 69292801} terms),
|
||||
("owner", orderHash ownerB o terms),
|
||||
("commitment", orderHash ownerA o {ofCommitment = C.sha256Hash "another"} terms),
|
||||
("sealed", orderHash ownerA o {ofSealed = ofSealed o <> "x"} terms),
|
||||
("paying order", orderHash ownerA o (OTBalance (RegistrationId "t") holdPrice 15000 (Just btc))),
|
||||
("price", orderHash ownerA o (OTBalance src (holdPrice + 1) 15000 (Just btc))),
|
||||
("balance", orderHash ownerA o (OTBalance src holdPrice 15001 (Just btc))),
|
||||
("method", orderHash ownerA o (OTBalance src holdPrice 15000 (Just $ SPMCard CPStripe))),
|
||||
("no method", orderHash ownerA o (OTBalance src holdPrice 15000 Nothing)),
|
||||
("an invoice order", orderHash ownerA o (OTInvoice btc holdPrice))
|
||||
]
|
||||
orderHash ownerA (fieldsN "h") (holdTerms "s" 15000) `shouldBe` base
|
||||
forM_ variants $ \(field, h) -> (field, h == base) `shouldBe` (field, False)
|
||||
source st "s" 15000
|
||||
holder st "h" "s" 15000 (at 60) `shouldReturn` Right ()
|
||||
fmap nrId <$> withTransaction st (`getRegistrationByOrderHash` base) `shouldReturn` Just (RegistrationId "h")
|
||||
|
||||
testFindBalances :: IO ()
|
||||
testFindBalances = withServiceStore $ \st -> do
|
||||
let rid :: Int -> Text
|
||||
rid i = "r" <> T.pack (show i)
|
||||
forM_ [1 .. 52 :: Int] $ \i -> withTransaction st $ \db -> do
|
||||
invoiceRow db ("inv-" <> rid i) False 20000 (at 60)
|
||||
createPaidRegistration db (newReg (rid i) ownerA (fieldsN $ rid i)) (OTInvoice btc 20000) (paidOrder ("inv-" <> rid i)) (at i) `shouldReturn` Right ()
|
||||
credit st "r1" 5000
|
||||
credit st "r2" 3000
|
||||
refund st "r2" 3000 `shouldReturn` True
|
||||
map nrId <$> withTransaction st (`findRegistrations` ownerA) `shouldReturn` map (RegistrationId . rid) ([52, 51 .. 3] <> [1])
|
||||
|
||||
-- closes
|
||||
|
||||
testClosesRelease :: IO ()
|
||||
testClosesRelease = withServiceStore $ \st -> do
|
||||
waiters <- newWaiters
|
||||
let closes = ["cancel", "sweep", "settled", "next"]
|
||||
forM_ closes $ \c -> do
|
||||
source st ("s-" <> c) 10000
|
||||
holder st ("h-" <> c) ("s-" <> c) 10000 (at 60) `shouldReturn` Right ()
|
||||
cancelOpenInvoice st waiters Nothing (InvoiceId "inv-h-cancel") (at 2) `shouldReturn` True
|
||||
expireOverdue st waiters Nothing (at 61) (at 61) [InvoiceId "inv-h-sweep"] `shouldReturn` [InvoiceId "inv-h-sweep"]
|
||||
settleOrder st waiters Nothing (InvoiceId "inv-h-settled") (SigClosed (rcv 0 Nothing)) (at 2) `shouldReturn` Right ISExpired
|
||||
next <- reg st "h-next"
|
||||
withTransaction st (\db -> closeOverdue db next (at 61)) `shouldReturn` Just (nrVersion next + 1)
|
||||
forM_ closes $ \c -> do
|
||||
(c,) <$> figures st ("s-" <> c) t0 `shouldReturn` (c, Just (10000, 10000, 0, Nothing))
|
||||
(c,) . nrLive <$> reg st ("h-" <> c) `shouldReturn` (c, False)
|
||||
(c,) <$> invoiceStatus st ("inv-h-" <> c) `shouldReturn` (c, "expired")
|
||||
|
||||
testOverdueHolderClosed :: IO ()
|
||||
testOverdueHolderClosed = withOrders $ \Ctx {cStore = st, cOrders} -> do
|
||||
now <- oeNow cOrders
|
||||
source st "s" 10000
|
||||
holder st "h" "s" 10000 (at 60) `shouldReturn` Right ()
|
||||
Just balance <- withTransaction st $ \db -> getBalance db (RegistrationId "s") now
|
||||
(blHold balance, heldBy balance) `shouldBe` (Just $ hold "h" (at 60) True, Nothing)
|
||||
Just h <- withTransaction st $ \db -> overdueHolder db (RegistrationId "s") now
|
||||
nrId h `shouldBe` RegistrationId "h"
|
||||
withTransaction st (\db -> overdueHolder db (RegistrationId "s") t0) `shouldReturn` Nothing
|
||||
closedIds <$> Orders.placeOrder cOrders (orderRequest "c") [h] (\_ _ -> pure $ Left ORBadRequest) `shouldReturn` Left ORBadRequest
|
||||
invoiceStatus st "inv-h" `shouldReturn` "open"
|
||||
nrLive <$> reg st "h" `shouldReturn` True
|
||||
figures st "s" now `shouldReturn` Just (10000, 10000, 0, Just $ hold "h" (at 60) True)
|
||||
closedIds <$> Orders.placeOrder cOrders (orderRequest "c") [h] (spendInsert "c" "s" 6000 10000) `shouldReturn` Right [(RegistrationId "h", nrVersion h + 1)]
|
||||
invoiceStatus st "inv-h" `shouldReturn` "expired"
|
||||
nrLive <$> reg st "h" `shouldReturn` False
|
||||
figures st "s" now `shouldReturn` Just (4000, 10000, 0, Nothing)
|
||||
nrPhase <$> reg st "c" `shouldReturn` PQueued
|
||||
|
||||
testNamedHolders :: IO ()
|
||||
testNamedHolders = withOrders $ \Ctx {cStore = st, cOrders, cWeb} -> do
|
||||
now <- oeNow cOrders
|
||||
source st "s1" 10000
|
||||
holder st "h1" "s1" 10000 (at 60) `shouldReturn` Right ()
|
||||
h1 <- reg st "h1"
|
||||
cancelOpenInvoice st (weWaiters cWeb) Nothing (InvoiceId "inv-h1") now `shouldReturn` True
|
||||
closedIds <$> Orders.placeOrder cOrders (orderRequest "c1") [h1] (spendInsert "c1" "s1" 6000 10000) `shouldReturn` Right []
|
||||
figures st "s1" now `shouldReturn` Just (4000, 10000, 0, Nothing)
|
||||
source st "s2" 10000
|
||||
holder st "h2" "s2" 10000 (at 60) `shouldReturn` Right ()
|
||||
h2 <- reg st "h2"
|
||||
payment st "inv-h2" (cardPaid 100 0) (at 61)
|
||||
Just funded <- withTransaction st $ \db -> getBalance db (RegistrationId "s2") now
|
||||
blHold funded `shouldBe` Just (hold "h2" (at 60) False)
|
||||
closedIds <$> Orders.placeOrder cOrders (orderRequest "c2") [h2] (spendInsert "c2" "s2" 6000 10000) `shouldReturn` Left (ORBalanceChanged $ Just funded)
|
||||
absent st "c2"
|
||||
nrLive <$> reg st "h2" `shouldReturn` True
|
||||
invoiceStatus st "inv-h2" `shouldReturn` "open"
|
||||
|
||||
testNamedHolderNotDue :: IO ()
|
||||
testNamedHolderNotDue = withOrders $ \Ctx {cStore = st, cOrders} -> do
|
||||
now <- oeNow cOrders
|
||||
let expiry = addUTCTime 600 now
|
||||
source st "s" 10000
|
||||
holder st "h" "s" 10000 expiry `shouldReturn` Right ()
|
||||
h <- reg st "h"
|
||||
Just held <- withTransaction st $ \db -> getBalance db (RegistrationId "s") now
|
||||
blHold held `shouldBe` Just (hold "h" expiry False)
|
||||
closedIds <$> Orders.placeOrder cOrders (orderRequest "c") [h] (spendInsert "c" "s" 6000 10000) `shouldReturn` Left (ORBalanceChanged $ Just held)
|
||||
absent st "c"
|
||||
invoiceStatus st "inv-h" `shouldReturn` "open"
|
||||
nrLive <$> reg st "h" `shouldReturn` True
|
||||
figures st "s" now `shouldReturn` Just (10000, 10000, 0, Just $ hold "h" expiry False)
|
||||
|
||||
testCleanupKeepsHolder :: IO ()
|
||||
testCleanupKeepsHolder = withOrders $ \Ctx {cStore = st, cWeb} -> do
|
||||
source st "s" 10000
|
||||
holder st "h" "s" 10000 t0 `shouldReturn` Right ()
|
||||
let due = addUTCTime (unpaidRetention + 1) t0
|
||||
poller <- newPollerEnv st (weWaiters cWeb) Nothing (weHints cWeb) (weProviders cWeb) (poll $ weConfig cWeb)
|
||||
logs <- capturingStderr $ cleanupUnpaid poller due
|
||||
logs `shouldSatisfy` T.isInfixOf "unpaid registration h is kept because it still holds the balance of registration s"
|
||||
nrLive <$> reg st "h" `shouldReturn` True
|
||||
figures st "s" due `shouldReturn` Just (10000, 10000, 0, Just $ hold "h" t0 True)
|
||||
|
||||
-- refunds
|
||||
|
||||
testRecordRefund :: IO ()
|
||||
testRecordRefund = withServiceStore $ \st -> do
|
||||
turns <- newTurns
|
||||
waiters <- newWaiters
|
||||
source st "s" 10000
|
||||
placeOrderRow st "plain" False 20000 (at 60)
|
||||
holder st "h" "s" 10000 (at 60) `shouldReturn` Right ()
|
||||
let refundOf :: Text -> Int -> UTCTime -> IO (Either RefundRefusal RefundRecord)
|
||||
refundOf ref cents now = recordRefund turns st ref cents now
|
||||
refundOf "nothing" 10000 t0 `shouldReturn` Left RefundNoOrder
|
||||
refundOf "plain" 20000 t0 `shouldReturn` Left RefundNoBalance
|
||||
refundOf "s" 10000 t0 `shouldReturn` Left (RefundHeld $ hold "h" (at 60) False)
|
||||
refundOf "inv-s" 10000 (at 61) `shouldReturn` Left (RefundHeld $ hold "h" (at 60) True)
|
||||
figures st "s" t0 `shouldReturn` Just (10000, 10000, 0, Just $ hold "h" (at 60) False)
|
||||
cancelOpenInvoice st waiters Nothing (InvoiceId "inv-h") (at 2) `shouldReturn` True
|
||||
refundOf "s" 9000 t0 `shouldReturn` Left (RefundAmount 10000)
|
||||
chain <- withTransaction st $ \db -> balanceChain db (RegistrationId "s")
|
||||
chain `shouldBe` [BalanceLink {lkRegistration = RegistrationId "s", lkPayment = RPInvoice btc (InvoiceId "inv-s"), lkPaid = 10000, lkFromBalance = 0, lkLateCredits = []}]
|
||||
refundOf "inv-s" 10000 t0 `shouldReturn` Right RefundRecord {rdRegistration = RegistrationId "s", rdCents = 10000, rdChain = chain}
|
||||
figures st "s" t0 `shouldReturn` Just (0, 10000, 10000, Nothing)
|
||||
refundOf "s" 10000 t0 `shouldReturn` Left (RefundAmount 0)
|
||||
turnsCount turns `shouldReturn` 0
|
||||
|
||||
-- late credits
|
||||
|
||||
testCreditLatePayment :: IO ()
|
||||
testCreditLatePayment = withServiceStore $ \st -> do
|
||||
closedWith st "x" False (cryptoPaid 400000 400000) t0
|
||||
let state :: IO (Maybe (Int64, Int, Int))
|
||||
state = withTransaction st $ \db -> fmap (\LateCreditState {lsCreditedUnits, lsCreditedCents, lsCredits} -> (lsCreditedUnits, lsCreditedCents, lsCredits)) <$> lateCreditState db (InvoiceId "inv-x")
|
||||
creditLate st (lateCredit "x" 1 (Just 300000) 3000) `shouldReturn` True
|
||||
creditLate st (lateCredit "x" 1 (Just 100000) 1000) `shouldReturn` False
|
||||
figures st "x" t0 `shouldReturn` Just (3000, 3000, 0, Nothing)
|
||||
state `shouldReturn` Just (300000, 3000, 1)
|
||||
creditLate st (lateCredit "x" 2 (Just 100000) 1000) `shouldReturn` True
|
||||
figures st "x" t0 `shouldReturn` Just (4000, 4000, 0, Nothing)
|
||||
state `shouldReturn` Just (400000, 4000, 2)
|
||||
|
||||
testLateScans :: IO ()
|
||||
testLateScans = withServiceStore $ \st -> do
|
||||
let cases =
|
||||
[ ("uncredited", False, cryptoPaid 300000 300000, Just LPUncredited),
|
||||
("unconfirmed", False, cryptoPaid 300000 0, Just LPUnconfirmed),
|
||||
("monero", False, Paid {pCents = 0, pConfirmedCents = 0, pCrypto = Just "0", pUnits = Just 0, pConfirmedUnits = Just 0, pInFull = True}, Just LPConfirming),
|
||||
("part", False, cryptoPaid 300000 100000, Just LPUnconfirmed),
|
||||
("card", True, cardPaid 5000 5000, Just LPUncredited),
|
||||
("cardpending", True, cardPaid 5000 0, Just LPUnconfirmed),
|
||||
("settledlate", False, cryptoPaid 200000 200000, Just LPUncredited),
|
||||
("uncounted", False, (cryptoPaid 0 0) {pCents = 5000, pUnits = Nothing}, Just LPUncounted),
|
||||
("credited", True, cardPaid 5000 5000, Nothing)
|
||||
]
|
||||
forM_ (zip [1 ..] cases) $ \(i, (r, card, paid, _)) -> closedWith st r card paid (at i)
|
||||
creditLate st (lateCredit "part" 1 (Just 100000) 1500) `shouldReturn` True
|
||||
creditLate st (lateCredit "credited" 1 Nothing 5000) `shouldReturn` True
|
||||
withTransaction st $ \db -> DB.execute db "UPDATE sx_badge_service_invoices SET status = ? WHERE invoice_id = ?" ("paid" :: Text, "inv-settledlate" :: Text)
|
||||
placeOrderRow st "live" False 20000 (at 60)
|
||||
payment st "inv-live" (cryptoPaid 300000 300000) (at 20)
|
||||
let pendingOf :: Text -> IO (Text, Maybe LatePending)
|
||||
pendingOf r = withTransaction st $ \db -> (r,) <$> latePending db (InvoiceId $ "inv-" <> r)
|
||||
forM_ cases $ \(r, _, _, expected) -> pendingOf r `shouldReturn` (r, expected)
|
||||
pendingOf "live" `shouldReturn` ("live", Nothing)
|
||||
withTransaction st uncreditedLateInvoices `shouldReturn` map (InvoiceId . ("inv-" <>)) ["uncredited", "card", "settledlate"]
|
||||
withTransaction st (\db -> lateCreditState db (InvoiceId "inv-part"))
|
||||
`shouldReturn` Just
|
||||
LateCreditState
|
||||
{ lsInvoice = InvoiceId "inv-part",
|
||||
lsRegistration = RegistrationId "part",
|
||||
lsPhase = PUnpaid,
|
||||
lsLive = False,
|
||||
lsCard = False,
|
||||
lsReceivedCents = 20000,
|
||||
lsConfirmedCents = 0,
|
||||
lsReceivedUnits = Just 300000,
|
||||
lsConfirmedUnits = Just 100000,
|
||||
lsInvoiceRate = Just "62500",
|
||||
lsCreditedUnits = 100000,
|
||||
lsCreditedCents = 1500,
|
||||
lsCredits = 1,
|
||||
lsPaymentCreatedAt = at 4
|
||||
}
|
||||
withTransaction st (\db -> lateCreditState db (InvoiceId "inv-plain")) `shouldReturn` Nothing
|
||||
|
||||
testExactUnits :: IO ()
|
||||
testExactUnits = withServiceStore $ \st -> do
|
||||
closedWith st "x" False (cryptoPaid 30000000 30000000) t0
|
||||
creditLate st (lateCredit "x" 1 (Just 10000000) 6000) `shouldReturn` True
|
||||
creditLate st (lateCredit "x" 2 (Just 20000000) 12000) `shouldReturn` True
|
||||
withTransaction st (\db -> latePending db (InvoiceId "inv-x")) `shouldReturn` Nothing
|
||||
withTransaction st uncreditedLateInvoices `shouldReturn` []
|
||||
fmap lsCreditedUnits <$> withTransaction st (\db -> lateCreditState db (InvoiceId "inv-x")) `shouldReturn` Just 30000000
|
||||
|
||||
testConfirmedBeforeCents :: IO ()
|
||||
testConfirmedBeforeCents = withServiceStore $ \st -> do
|
||||
closedWith st "x" False Paid {pCents = 0, pConfirmedCents = 0, pCrypto = Just "0", pUnits = Just 1000000000000, pConfirmedUnits = Just 1000000000000, pInFull = True} t0
|
||||
withTransaction st (\db -> latePending db (InvoiceId "inv-x")) `shouldReturn` Just LPUncredited
|
||||
withTransaction st uncreditedLateInvoices `shouldReturn` [InvoiceId "inv-x"]
|
||||
|
||||
testNoConfirmedFigure :: IO ()
|
||||
testNoConfirmedFigure = withServiceStore $ \st -> do
|
||||
closedWith st "x" False (cryptoPaid 300000 0) {pConfirmedUnits = Nothing} t0
|
||||
withTransaction st (\db -> latePending db (InvoiceId "inv-x")) `shouldReturn` Just LPUnconfirmed
|
||||
|
||||
testLateAlerts :: IO ()
|
||||
testLateAlerts = withServiceStore $ \st -> do
|
||||
closedWith st "old" False (cryptoPaid 300000 0) t0
|
||||
closedWith st "card" True (cardPaid 5000 0) (at 1)
|
||||
closedWith st "done" True (cardPaid 5000 5000) (at 2)
|
||||
creditLate st (lateCredit "done" 1 Nothing 5000) `shouldReturn` True
|
||||
closedWith st "new" False (cryptoPaid 300000 0) (at 120)
|
||||
let due :: IO [LateAlert]
|
||||
due = withTransaction st $ \db -> lateAlertDue db (at 60)
|
||||
marks :: LateAlert -> (InvoiceId, LatePending, Maybe Int64, Maybe UTCTime, Maybe Int64)
|
||||
marks LateAlert {laState = LateCreditState {lsInvoice}, laPending, laReceived, laAlertedAt, laAlertedReceived} = (lsInvoice, laPending, laReceived, laAlertedAt, laAlertedReceived)
|
||||
claim :: LateAlert -> UTCTime -> IO Bool
|
||||
claim alert t = withTransaction st $ \db -> claimLateAlert db alert t
|
||||
[old, card] <- due
|
||||
map marks [old, card]
|
||||
`shouldBe` [(InvoiceId "inv-old", LPUnconfirmed, Just 300000, Nothing, Nothing), (InvoiceId "inv-card", LPUnconfirmed, Just 5000, Nothing, Nothing)]
|
||||
claim old (at 61) `shouldReturn` True
|
||||
claim old (at 61) `shouldReturn` False
|
||||
claim card (at 61) `shouldReturn` True
|
||||
due `shouldReturn` []
|
||||
withTransaction st $ \db -> DB.execute db "UPDATE sx_badge_service_payments SET received_units = ? WHERE invoice_id = ?" (400000 :: Int64, "inv-old" :: Text)
|
||||
[more] <- due
|
||||
marks more `shouldBe` (InvoiceId "inv-old", LPUnconfirmed, Just 400000, Just (at 61), Just 300000)
|
||||
claim more (at 62) `shouldReturn` True
|
||||
claim more (at 63) `shouldReturn` False
|
||||
due `shouldReturn` []
|
||||
map marks <$> withTransaction st (\db -> lateAlertDue db (at 180)) `shouldReturn` [(InvoiceId "inv-new", LPUnconfirmed, Just 300000, Nothing, Nothing)]
|
||||
|
||||
testUncountedAlertRepeats :: IO ()
|
||||
testUncountedAlertRepeats = withServiceStore $ \st -> do
|
||||
closedWith st "x" False (cryptoPaid 0 0) {pCents = 5000, pUnits = Nothing} t0
|
||||
let due :: IO [LateAlert]
|
||||
due = withTransaction st $ \db -> lateAlertDue db (at 60)
|
||||
[alert] <- due
|
||||
(laPending alert, laReceived alert) `shouldBe` (LPUncounted, Just 5000)
|
||||
withTransaction st (\db -> claimLateAlert db alert (at 61)) `shouldReturn` True
|
||||
due `shouldReturn` []
|
||||
withTransaction st $ \db -> DB.execute db "UPDATE sx_badge_service_payments SET amount = ? WHERE invoice_id = ?" (6000 :: Int, "inv-x" :: Text)
|
||||
map (\LateAlert {laPending, laReceived, laAlertedReceived} -> (laPending, laReceived, laAlertedReceived)) <$> due `shouldReturn` [(LPUncounted, Just 6000, Just 5000)]
|
||||
|
||||
-- turns
|
||||
|
||||
testBalanceTurn :: IO ()
|
||||
testBalanceTurn = withServiceStore $ \st -> do
|
||||
turns <- newTurns
|
||||
source st "s" 10000
|
||||
inside <- newEmptyMVar
|
||||
proceed <- newEmptyMVar
|
||||
let src = RegistrationId "s"
|
||||
firstHolder <- async $ withBalanceTurn turns src $ do
|
||||
putMVar inside ()
|
||||
takeMVar proceed
|
||||
holder st "h1" "s" 10000 (at 60)
|
||||
takeMVar inside
|
||||
secondHolder <- async $ withBalanceTurn turns src $ holder st "h2" "s" 10000 (at 60)
|
||||
timeout 5000000 (atomically $ balanceTurnCount turns src >>= check . (== 2)) `shouldReturn` Just ()
|
||||
putMVar proceed ()
|
||||
wait firstHolder `shouldReturn` Right ()
|
||||
wait secondHolder `shouldReturn` Left RRBalanceChanged
|
||||
timeout 5000000 (withLabelTurn turns (labelHash "alicebakery") $ withBalanceTurn turns src $ pure ()) `shouldReturn` Just ()
|
||||
turnsCount turns `shouldReturn` 0
|
||||
|
||||
#if defined(dbPostgres)
|
||||
-- | Both transactions begun before either writes, so the second writer waits for the first's row lock and re-reads its condition.
|
||||
together :: DBStore -> DBStore -> (DB.Connection -> IO a) -> (DB.Connection -> IO b) -> IO (a, b)
|
||||
together st1 st2 w1 w2 = do
|
||||
begun <- newTVarIO (0 :: Int)
|
||||
let run :: DBStore -> (DB.Connection -> IO c) -> IO c
|
||||
run st w = withTransaction st $ \db -> do
|
||||
atomically $ modifyTVar' begun (+ 1)
|
||||
atomically $ readTVar begun >>= check . (== 2)
|
||||
w db
|
||||
concurrently (run st1 w1) (run st2 w2)
|
||||
|
||||
testConcurrentWrites :: IO ()
|
||||
testConcurrentWrites = withServiceStores $ \open -> do
|
||||
st <- open
|
||||
other <- open
|
||||
forM_ [1 .. 25 :: Int] $ \n -> do
|
||||
let tag = T.pack (show n)
|
||||
spendSrc = "spend" <> tag
|
||||
heldSrc = "held" <> tag
|
||||
lateSrc = "late" <> tag
|
||||
source st spendSrc 10000
|
||||
(spent, refundedSpend) <-
|
||||
together st other (\db -> createBalanceRegistration db (newReg ("c" <> tag) ownerA (fieldsN $ "c" <> tag)) (RegistrationId spendSrc) 6000 10000 t0) (\db -> refundBalance db (RegistrationId spendSrc) 10000 t0)
|
||||
(n, isRight spent, refundedSpend) `shouldSatisfy` (\(_, a, b) -> a /= b)
|
||||
fmap (\(amount, _, _, _) -> amount) <$> figures st spendSrc t0 `shouldReturn` Just (if refundedSpend then 0 else 4000)
|
||||
invariantHolds st spendSrc
|
||||
source st heldSrc 10000
|
||||
holder st ("h" <> tag) heldSrc 10000 (at 60) `shouldReturn` Right ()
|
||||
h <- reg st ("h" <> tag)
|
||||
(n,) <$> together st other (`settleHolder` h) (\db -> refundBalance db (RegistrationId heldSrc) 10000 t0) `shouldReturn` (n, (True, False))
|
||||
figures st heldSrc t0 `shouldReturn` Just (0, 10000, 0, Nothing)
|
||||
invariantHolds st heldSrc
|
||||
source st lateSrc 10000
|
||||
(credited, refundedLate) <- together st other (\db -> creditLatePayment db (lateCredit lateSrc 1 (Just 50000) 3000) t0) (\db -> refundBalance db (RegistrationId lateSrc) 10000 t0)
|
||||
credited `shouldBe` True
|
||||
(n,) <$> figures st lateSrc t0 `shouldReturn` (n, Just $ if refundedLate then (3000, 13000, 10000, Nothing) else (13000, 13000, 0, Nothing))
|
||||
invariantHolds st lateSrc
|
||||
#endif
|
||||
@@ -35,7 +35,7 @@ where
|
||||
import Bots.StoreService.FakeBTCPay (FakeBTCPay (..), setInvoiceState, withFakeBTCPay)
|
||||
import Bots.StoreService.FakeEthNode
|
||||
import Bots.StoreService.NamesContractsTests (alice, answerRegistered, answerRegistrar, nameExpiresCall, noncesCall, registrarOf, rentPriceAnswer, rentPriceCall, reservedNamesCall, unhex)
|
||||
import Bots.StoreService.NamesStoreTests (order, ownerA, ownerB, placeCode, placePaid, t0)
|
||||
import Bots.StoreService.NamesStoreTests (btc, insertInvoiceRow, newReg, order, ownerA, ownerB, paidOrder, placeCode, placePaid, t0)
|
||||
import Bots.StoreService.WebTests
|
||||
import Control.Concurrent (threadDelay)
|
||||
import Control.Concurrent.Async (async, concurrently, wait)
|
||||
@@ -88,7 +88,7 @@ import StoreService.Names.Phases (RegistrationId (..), awaitChange, newPhases)
|
||||
import StoreService.Names.Signatures (OrderFields (..), orderMessage, personalDigest)
|
||||
import StoreService.Names.Store
|
||||
import StoreService.Names.Turns (newTurns, withCodeTurn)
|
||||
import StoreService.Names.View (ViewReads (..), readViewChain, viewJSON)
|
||||
import StoreService.Names.View (ViewReads (..), readViewChain, registrationView, viewJSON)
|
||||
import StoreService.Orders (cancelOpenInvoice, expireOverdue, settleOrder)
|
||||
import StoreService.Poller (cleanupInterval, cleanupReadsPerPass, cleanupUnpaid, drainHints, newPollerEnv, newReadHints, queueReadHint, runOnePass, unpaidRetention)
|
||||
import StoreService.Providers.BTCPay (btcpayProvider)
|
||||
@@ -170,6 +170,7 @@ namesOrdersTests = describe "StoreService names: orders" $ do
|
||||
it "has the contract's fields" testViewFields
|
||||
it "reads the intent nonce in committed and nothing in taken" testViewReads
|
||||
it "leaves a failed intentNonce read null and named in unread, reading nothing in taken" testViewUnread
|
||||
it "reads the order's balance, held until its holder's invoice expires and free past it, and the cents its order took from another's" testViewBalance
|
||||
|
||||
-- fixtures
|
||||
|
||||
@@ -937,7 +938,7 @@ testCodeNotReservable = withOrders $ \ctx@Ctx {cStore} -> do
|
||||
req <- fromJust . Orders.parseOrderRequest <$> signedBody (cRandom ctx) aliceOrder
|
||||
let new = NewRegistration {nrgId = RegistrationId "late", nrgOwner = ownerA, nrgOrder = fieldsOf aliceOrder, nrgYears = 2}
|
||||
forM_ [(reservedLate, CRReserved), (revokedLate, CRInvalid)] $ \(stale, refusal) -> do
|
||||
placedOrder <- Orders.placeOrder (cOrders ctx) req (Orders.codeOrderInsert new stale)
|
||||
placedOrder <- Orders.placeOrder (cOrders ctx) req [] (Orders.codeOrderInsert new stale)
|
||||
either Just (const Nothing) placedOrder `shouldBe` Just (Orders.ORCode refusal)
|
||||
length <$> registrationsOf ctx ownerA `shouldReturn` 0
|
||||
|
||||
@@ -1344,13 +1345,13 @@ testViewFields :: IO ()
|
||||
testViewFields = withServiceStore $ \st -> withFakeEthNode $ \node -> do
|
||||
Right () <- placePaid st "r1" ownerA (order "alicebakery" 1) (addUTCTime 3600 t0)
|
||||
r <- withTransaction st $ \db -> fromJust <$> getRegistration db (RegistrationId "r1")
|
||||
let v = objectOf $ viewJSON (testNamesConfig node) r ViewReads {vrIntentNonce = Nothing, vrUnread = []}
|
||||
let v = objectOf $ viewJSON (testNamesConfig node) r Nothing ViewReads {vrIntentNonce = Nothing, vrUnread = []}
|
||||
sort (KM.keys v)
|
||||
`shouldBe` sort
|
||||
[ "id", "version", "moves", "linksRefused", "phase", "labelHash", "label", "owner", "commitment", "sealed", "resolver", "sponsor", "chainId", "tld",
|
||||
"years", "duration", "price", "commitTx", "registerTx", "revealAfter", "revealBefore", "intentNonce", "unread", "expiresAt", "error"
|
||||
"years", "duration", "price", "fromBalance", "balance", "commitTx", "registerTx", "revealAfter", "revealBefore", "intentNonce", "unread", "expiresAt", "error"
|
||||
]
|
||||
map (`field` v) ["id", "phase", "owner", "tld", "chainId", "linksRefused", "label", "unread", "sponsor"]
|
||||
map (`field` v) ["id", "phase", "owner", "tld", "chainId", "linksRefused", "label", "unread", "sponsor", "fromBalance", "balance"]
|
||||
`shouldBe` map
|
||||
Just
|
||||
[ "r1",
|
||||
@@ -1361,7 +1362,9 @@ testViewFields = withServiceStore $ \st -> withFakeEthNode $ \node -> do
|
||||
J.Bool False,
|
||||
J.Null,
|
||||
J.Array mempty,
|
||||
J.String $ safeDecodeUtf8 $ strEncode fakeSponsor
|
||||
J.String $ safeDecodeUtf8 $ strEncode fakeSponsor,
|
||||
J.Number 0,
|
||||
J.Null
|
||||
]
|
||||
|
||||
testViewReads :: IO ()
|
||||
@@ -1388,5 +1391,35 @@ testViewUnread = withServiceStore $ \st -> withFakeEthNode $ \node -> do
|
||||
r <- withTransaction st $ \db -> fromJust <$> getRegistration db (RegistrationId "paid")
|
||||
readViewChain ct r {nrPhase = PCommitted} `shouldReturn` ViewReads Nothing ["intentNonce"]
|
||||
readViewChain ct r {nrPhase = PTaken} `shouldReturn` ViewReads Nothing []
|
||||
let v = objectOf $ viewJSON (testNamesConfig node) r {nrPhase = PCommitted} (ViewReads Nothing ["intentNonce"])
|
||||
let v = objectOf $ viewJSON (testNamesConfig node) r {nrPhase = PCommitted} Nothing (ViewReads Nothing ["intentNonce"])
|
||||
(field "intentNonce" v, field "unread" v) `shouldBe` (Just J.Null, Just (J.toJSON ["intentNonce" :: Text]))
|
||||
|
||||
testViewBalance :: IO ()
|
||||
testViewBalance = withServiceStore $ \st -> withFakeEthNode $ \node -> do
|
||||
g <- C.newRandom
|
||||
ct <- newContracts TLDSimplex =<< newChainClient g (fakeChainConfig node)
|
||||
let holderExpiry = addUTCTime 3600 t0
|
||||
viewOf :: Text -> UTCTime -> IO J.Object
|
||||
viewOf rid now = do
|
||||
r <- withTransaction st $ \db -> fromJust <$> getRegistration db (RegistrationId rid)
|
||||
objectOf <$> registrationView st (testNamesConfig node) ct now r
|
||||
balanceOf :: J.Object -> (Maybe J.Value, Maybe J.Value)
|
||||
balanceOf v = (field "balance" v, field "fromBalance" v)
|
||||
balance :: Int -> Bool -> Maybe UTCTime -> Int -> J.Value
|
||||
balance amount held heldUntil refunded = J.object ["amount" .= amount, "held" .= held, "heldUntil" .= heldUntil, "refunded" .= refunded]
|
||||
Right () <- placePaid st "source" ownerA (order "alicebakery" 1) holderExpiry
|
||||
withTransaction st $ \db -> creditBalance db (RegistrationId "source") 15000 t0
|
||||
balanceOf <$> viewOf "source" t0 `shouldReturn` (Just $ balance 15000 False Nothing 0, Just $ J.Number 0)
|
||||
Right () <- withTransaction st $ \db -> do
|
||||
insertInvoiceRow db "inv-holder" holderExpiry "open"
|
||||
let terms = OTBalance (RegistrationId "source") 20000 15000 (Just btc)
|
||||
createPaidRegistration db (newReg "holder" ownerA (order "bobsbakery" 2)) terms (paidOrder "inv-holder") t0
|
||||
balanceOf <$> viewOf "source" t0 `shouldReturn` (Just $ balance 15000 True (Just holderExpiry) 0, Just $ J.Number 0)
|
||||
balanceOf <$> viewOf "source" (addUTCTime 1 holderExpiry) `shouldReturn` (Just $ balance 15000 False Nothing 0, Just $ J.Number 0)
|
||||
balanceOf <$> viewOf "holder" t0 `shouldReturn` (Just J.Null, Just $ J.Number 15000)
|
||||
version <- nrVersion <$> withTransaction st (\db -> fromJust <$> getRegistration db (RegistrationId "source"))
|
||||
withTransaction st (\db -> releaseHeldBalance db (RegistrationId "holder") t0) `shouldReturn` True
|
||||
withTransaction st (\db -> refundBalance db (RegistrationId "source") 15000 t0) `shouldReturn` True
|
||||
v <- viewOf "source" t0
|
||||
balanceOf v `shouldBe` (Just $ balance 0 False Nothing 15000, Just $ J.Number 0)
|
||||
field "version" v `shouldBe` Just (J.toJSON version)
|
||||
@@ -3,11 +3,12 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module Bots.StoreService.NamesStoreTests (namesStoreTests, issue, order, ownerA, ownerB, placeCode, placePaid, reveal, t0, usable) where
|
||||
module Bots.StoreService.NamesStoreTests (namesStoreTests, at, btc, insertInvoiceRow, invoiceStatus, issue, newReg, order, ownerA, ownerB, paidOrder, placeCode, placePaid, price, reg, reveal, signature, t0, usable, write) where
|
||||
|
||||
import Bots.StoreService.WebTests (columnsOf, digestFixture, runMigrations, withServiceStore)
|
||||
import Control.Arrow ((&&&))
|
||||
import Control.Monad (forM_, void)
|
||||
import Data.Int (Int64)
|
||||
import Data.List (sort)
|
||||
import Data.Maybe (fromJust, mapMaybe)
|
||||
import Data.Text (Text)
|
||||
@@ -21,8 +22,8 @@ import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction)
|
||||
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationsToRun (..), toDownMigration)
|
||||
import Simplex.Messaging.Crypto.Secp256k1 (RecoverableSignature)
|
||||
import Simplex.Messaging.Eth.Address (Address)
|
||||
import Simplex.Messaging.SimplexName (labelHash)
|
||||
import Simplex.Messaging.Eth.Address (Address, unAddress)
|
||||
import Simplex.Messaging.SimplexName (LabelHash (..), labelHash)
|
||||
import StoreService.Names.Chain (TxHash (..))
|
||||
import StoreService.Names.Phases (RegistrationId (..))
|
||||
import StoreService.Names.Signatures (OrderFields (..), parseAnyCaseAddress, parseSignature)
|
||||
@@ -45,6 +46,7 @@ namesStoreTests = describe "StoreService names: store" $ do
|
||||
it "creates the names tables with the planned columns" testNamesColumns
|
||||
it "rolls back and re-applies only the names migrations, with rows in them, leaving the badge tables and paid invoices as they were" testNamesDownUp
|
||||
it "turns a code_issued order taken at price 0 and not live at its next version, dropping issued_code_id with its index and foreign key, which the down migration restores" testDropTakenCodes
|
||||
it "makes taken orders not live and gives a taken paid order a balance of its price, none to a code order or a price of 0, and rolls back the balance tables and columns" testNameBalancesMigration
|
||||
describe "order hash" $ do
|
||||
it "is equal for the same order fields, and differs when any one field differs" testOrderHashFields
|
||||
it "keeps field boundaries, so moving a byte between two fields changes it" testOrderHashBoundaries
|
||||
@@ -141,7 +143,7 @@ insertInvoiceRow db invId expiresAt status =
|
||||
placePaid :: DBStore -> Text -> Address -> OrderFields -> UTCTime -> IO (Either RegistrationRefused ())
|
||||
placePaid st rid owner o expiresAt = withTransaction st $ \db -> do
|
||||
insertInvoiceRow db ("inv-" <> rid) expiresAt "open"
|
||||
createPaidRegistration db (newReg rid owner o) (paidOrder $ "inv-" <> rid) t0
|
||||
createPaidRegistration db (newReg rid owner o) (OTInvoice btc price) (paidOrder $ "inv-" <> rid) t0
|
||||
|
||||
issue :: DBStore -> Word8 -> NewPrepaidCode -> IO PrepaidCode
|
||||
issue st n c = withTransaction st $ \db -> do
|
||||
@@ -161,7 +163,11 @@ write :: DBStore -> NameRegistration -> IO VersionWrite
|
||||
write st r = withTransaction st $ \db -> updateRegistration db r (at 1)
|
||||
|
||||
namesTables :: [Text]
|
||||
namesTables = map ("sx_badge_service_name_" <>) ["prepaid_codes", "registrations", "invoices", "record_edits", "transactions"]
|
||||
namesTables = map ("sx_badge_service_name_" <>) ["prepaid_codes", "registrations", "invoices", "record_edits", "transactions", "balances", "late_credits"]
|
||||
|
||||
-- | Added to the badge tables' payments by the names migrations.
|
||||
balancePaymentColumns :: [Text]
|
||||
balancePaymentColumns = ["confirmed_amount", "received_units", "confirmed_units", "invoice_rate"]
|
||||
|
||||
badgeTables :: [Text]
|
||||
badgeTables = map ("sx_badge_service_" <>) ["invoices", "payments", "badge_prices", "badge_offers", "badge_purchases", "badge_ledger", "badge_issuances", "badge_codes", "badge_code_invoices", "group"]
|
||||
@@ -175,9 +181,12 @@ testNamesColumns = withServiceStore $ \st -> do
|
||||
[ "registration_id", "label_hash", "length", "label", "secret", "contact_link", "channel_link", "intent_nonce", "intent_deadline", "intent_signature",
|
||||
"sealed", "owner", "commitment", "phase", "commit_tx", "commit_block", "register_tx", "register_block", "reveal_after", "reveal_before",
|
||||
"years", "duration", "expires_at", "price", "name_prepaid_code_id", "registered_at", "failed_at", "error", "created_at",
|
||||
"updated_at", "version", "live", "order_hash", "method", "moves", "links_refused_at"
|
||||
"updated_at", "version", "live", "order_hash", "method", "moves", "links_refused_at", "balance_source", "balance_used"
|
||||
]
|
||||
has "sx_badge_service_name_invoices" ["invoice_id", "registration_id", "provider_ref"]
|
||||
has "sx_badge_service_name_invoices" ["invoice_id", "registration_id", "provider_ref", "late_alerted_at", "late_alerted_units"]
|
||||
has "sx_badge_service_name_balances" ["registration_id", "amount", "credited", "refunded", "reserved_by", "refunded_at", "created_at", "updated_at"]
|
||||
has "sx_badge_service_name_late_credits" ["invoice_id", "credit_no", "registration_id", "units", "invoice_rate", "current_rate", "rate_used", "cents", "created_at"]
|
||||
has "sx_badge_service_payments" balancePaymentColumns
|
||||
has "sx_badge_service_name_record_edits" ["label_hash", "owner", "nonce", "record_key", "record_value", "deadline", "signature", "body_hash", "state", "revert", "mined_tx", "created_at"]
|
||||
has "sx_badge_service_name_transactions" ["registration_id", "name_record_edit_id", "kind", "raw", "tx_hash", "registrar_nonce", "sent_at", "mined_block", "status"]
|
||||
|
||||
@@ -185,7 +194,7 @@ testNamesDownUp :: IO ()
|
||||
testNamesDownUp = withServiceStore $ \st -> do
|
||||
let names = dropWhile ((/= "20261010_names") . name) storeServiceSchemaMigrations
|
||||
badgeColumns = mapM (fmap sort . columnsOf st) badgeTables
|
||||
map name names `shouldBe` ["20261010_names", "20261022_drop_taken_codes"]
|
||||
map name names `shouldBe` ["20261010_names", "20261022_drop_taken_codes", "20261022_name_balances"]
|
||||
badgeBefore <- badgeColumns
|
||||
code <- issue st 1 usable
|
||||
placeCode st "r1" ownerA (order "alicebakery" 1) code `shouldReturn` Right ()
|
||||
@@ -195,7 +204,7 @@ testNamesDownUp = withServiceStore $ \st -> do
|
||||
runMigrations st $ MTRDown (mapMaybe toDownMigration names)
|
||||
mapM (columnsOf st) namesTables `shouldReturn` map (const []) namesTables
|
||||
mapM (invoiceCount st) ["inv-r2", "inv-r3"] `shouldReturn` [0, 1]
|
||||
badgeColumns `shouldReturn` badgeBefore
|
||||
badgeColumns `shouldReturn` map (filter (`notElem` balancePaymentColumns)) badgeBefore
|
||||
runMigrations st $ MTRUp names
|
||||
mapM (fmap null . columnsOf st) namesTables `shouldReturn` map (const False) namesTables
|
||||
badgeColumns `shouldReturn` badgeBefore
|
||||
@@ -225,6 +234,53 @@ testDropTakenCodes = withServiceStore $ \st -> do
|
||||
worth <$> reg st "r1" `shouldReturn` (PTaken, 0, False, 2)
|
||||
withTransaction st (\db -> DB.execute db "UPDATE sx_badge_service_name_registrations SET issued_code_id = ? WHERE registration_id = 'r1'" (Only $ pcId code + 1)) `shouldThrow` foreignKeyViolation
|
||||
|
||||
testNameBalancesMigration :: IO ()
|
||||
testNameBalancesMigration = withServiceStore $ \st -> do
|
||||
let balances = [m | m@Migration {name = "20261022_name_balances"} <- storeServiceSchemaMigrations]
|
||||
applied :: IO (Bool, Bool, Bool)
|
||||
applied = (,,) <$> (not . null <$> columnsOf st "sx_badge_service_name_balances") <*> (elem "balance_source" <$> columnsOf st "sx_badge_service_name_registrations") <*> (elem "confirmed_units" <$> columnsOf st "sx_badge_service_payments")
|
||||
live :: Text -> IO Bool
|
||||
live rid = withTransaction st $ \db -> DB.query db "SELECT live FROM sx_badge_service_name_registrations WHERE registration_id = ?" (Only rid) >>= \case
|
||||
[Only (DB.BI l)] -> pure l
|
||||
rows -> fail $ "registration " <> show rid <> ": " <> show (length rows) <> " rows"
|
||||
balanceOf :: Text -> IO (Maybe (Int, Int, Int, Maybe BalanceHold))
|
||||
balanceOf rid = withTransaction st $ \db -> fmap (\Balance {blAmount, blCredited, blRefunded, blHold} -> (blAmount, blCredited, blRefunded, blHold)) <$> getBalance db (RegistrationId rid) t0
|
||||
length balances `shouldBe` 1
|
||||
runMigrations st $ MTRDown (mapMaybe toDownMigration balances)
|
||||
applied `shouldReturn` (False, False, False)
|
||||
code <- issue st 1 usable
|
||||
withTransaction st $ \db -> do
|
||||
insertInvoiceRow db "inv-paid" (at 60) "paid"
|
||||
insertOldRegistration db "paid" 1 "taken" price Nothing (Just "inv-paid")
|
||||
insertOldRegistration db "code" 2 "taken" 0 (Just $ pcId code) Nothing
|
||||
insertInvoiceRow db "inv-converted" (at 60) "paid"
|
||||
insertOldRegistration db "converted" 3 "taken" 0 Nothing (Just "inv-converted")
|
||||
insertInvoiceRow db "inv-queued" (at 60) "paid"
|
||||
insertOldRegistration db "queued" 4 "queued" price Nothing (Just "inv-queued")
|
||||
runMigrations st $ MTRUp balances
|
||||
applied `shouldReturn` (True, True, True)
|
||||
mapM live ["paid", "code", "converted", "queued"] `shouldReturn` [False, False, False, True]
|
||||
mapM balanceOf ["paid", "code", "converted", "queued"] `shouldReturn` [Just (price, price, 0, Nothing), Nothing, Nothing, Nothing]
|
||||
runMigrations st $ MTRDown (mapMaybe toDownMigration balances)
|
||||
applied `shouldReturn` (False, False, False)
|
||||
mapM live ["paid", "queued"] `shouldReturn` [False, True]
|
||||
runMigrations st $ MTRUp balances
|
||||
balanceOf "paid" `shouldReturn` Just (price, price, 0, Nothing)
|
||||
|
||||
-- | A registration row as the code before the balances wrote it, paid by the invoice or the code given.
|
||||
insertOldRegistration :: DB.Connection -> Text -> Word8 -> Text -> Int -> Maybe Int64 -> Maybe Text -> IO ()
|
||||
insertOldRegistration db rid n phase cents codeId invId = do
|
||||
let OrderFields {ofLabelHash = LabelHash lh, ofLength, ofDuration, ofCommitment, ofSealed} = order ("alicebakery" <> rid) n
|
||||
DB.execute
|
||||
db
|
||||
( "INSERT INTO sx_badge_service_name_registrations (registration_id, version, label_hash, length, duration, owner, commitment, sealed, phase, years,"
|
||||
<> " price, method, name_prepaid_code_id, live, order_hash, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?, ?,?,?,?,?,?,?)"
|
||||
)
|
||||
( (rid, 1 :: Int, DB.Binary lh, ofLength, fromIntegral ofDuration :: Int64, DB.Binary $ unAddress ownerA, DB.Binary ofCommitment, ofSealed, phase, twoYears)
|
||||
:. (cents, ("btc" :: Text) <$ invId, codeId, DB.BI True, DB.Binary $ digestFixture n, t0, t0)
|
||||
)
|
||||
mapM_ (\i -> DB.execute db "INSERT INTO sx_badge_service_name_invoices (invoice_id, registration_id, provider_ref, created_at) VALUES (?,?,?,?)" (i, rid, "ref-" <> i, t0)) invId
|
||||
|
||||
testOrderHashFields :: IO ()
|
||||
testOrderHashFields = do
|
||||
let o = order "alicebakery" 1
|
||||
@@ -270,6 +326,8 @@ testCreatePaid = withServiceStore $ \st -> do
|
||||
nrYears = twoYears,
|
||||
nrPrice = price,
|
||||
nrPayment = RPInvoice btc (InvoiceId "inv-r1"),
|
||||
nrBalanceSource = Nothing,
|
||||
nrBalanceUsed = 0,
|
||||
nrReveal = Nothing,
|
||||
nrCommitTx = Nothing,
|
||||
nrCommitBlock = Nothing,
|
||||
@@ -359,7 +417,7 @@ testCloseOverdue = withServiceStore $ \st -> do
|
||||
[LiveOrder {loRegistration, loOverdue = True}] <- getLiveOrders db (ofCommitment o') ownerA (ofLabelHash o') (at 61)
|
||||
void $ closeOverdue db loRegistration (at 61)
|
||||
insertInvoiceRow db "inv-r2" (at 120) "open"
|
||||
createPaidRegistration db (newReg "r2" ownerA o') (paidOrder "inv-r2") (at 61)
|
||||
createPaidRegistration db (newReg "r2" ownerA o') (OTInvoice btc price) (paidOrder "inv-r2") (at 61)
|
||||
placed `shouldBe` Right ()
|
||||
old <- reg st "r1"
|
||||
(nrLive old, nrPhase old, nrVersion old) `shouldBe` (False, PUnpaid, 2)
|
||||
@@ -552,7 +610,7 @@ testFind = withServiceStore $ \st -> do
|
||||
let rid = "r" <> T.pack (show i)
|
||||
o = order ("alicebakery" <> T.pack (show i)) (fromIntegral i)
|
||||
insertInvoiceRow db ("inv-" <> rid) (at 60) "open"
|
||||
createPaidRegistration db (newReg rid ownerA o) (paidOrder $ "inv-" <> rid) (at i) `shouldReturn` Right ()
|
||||
createPaidRegistration db (newReg rid ownerA o) (OTInvoice btc price) (paidOrder $ "inv-" <> rid) (at i) `shouldReturn` Right ()
|
||||
placePaid st "other" ownerB (order "bobsbakery" 99) (at 60) `shouldReturn` Right ()
|
||||
found <- withTransaction st $ \db -> findRegistrations db ownerA
|
||||
map nrId found `shouldBe` [RegistrationId $ "r" <> T.pack (show i) | i <- [52, 51 .. 3 :: Int]]
|
||||
@@ -611,7 +669,7 @@ testUnpaidCleanup = withServiceStore $ \st -> do
|
||||
void $ write st r {nrPhase = PQueued}
|
||||
candidates <- withTransaction st $ \db -> unpaidCleanupCandidates db (at 61)
|
||||
map (\(RegistrationId rid, InvoiceId invId) -> (rid, invId)) candidates `shouldBe` [("old", "inv-old")]
|
||||
withTransaction st (\db -> mapM (deleteUnpaidRegistration db . RegistrationId) ["old", "funded", "queued"]) `shouldReturn` [True, False, False]
|
||||
withTransaction st (\db -> mapM (deleteUnpaidRegistration db . RegistrationId) ["old", "funded", "queued"]) `shouldReturn` [Right (), Left CKPaid, Left CKPaid]
|
||||
withTransaction st (\db -> getRegistration db (RegistrationId "old")) `shouldReturn` Nothing
|
||||
invoiceCount st "inv-old" `shouldReturn` 0
|
||||
invoiceCount st "inv-funded" `shouldReturn` 1
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
module Bots.StoreService.WebTests
|
||||
( badgeWebTests,
|
||||
withServiceStore,
|
||||
withServiceStores,
|
||||
columnsOf,
|
||||
runMigrations,
|
||||
digestFixture,
|
||||
@@ -67,7 +68,7 @@ import qualified Data.ByteString.Char8 as BC
|
||||
import qualified Data.ByteString.Lazy.Char8 as LB
|
||||
import Data.Char (toLower)
|
||||
import Data.Either (isLeft)
|
||||
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
|
||||
import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef)
|
||||
import Data.Int (Int64)
|
||||
import Data.List (isInfixOf, sort, sortOn)
|
||||
import qualified Data.Map.Strict as Map
|
||||
@@ -122,32 +123,37 @@ import Simplex.Messaging.Agent.Store.DB (TrackQueries (..))
|
||||
import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations
|
||||
#endif
|
||||
|
||||
#if defined(dbPostgres)
|
||||
withServiceStore :: (DBStore -> IO a) -> IO a
|
||||
withServiceStore action = do
|
||||
withServiceStore action = withServiceStores (>>= action)
|
||||
|
||||
-- | Each store the action opens is a new one over the same test database, as a restarted service opens; all are closed at the end.
|
||||
withServiceStores :: (IO DBStore -> IO a) -> IO a
|
||||
withServiceStores action = withServiceDBOpts $ \opts -> do
|
||||
opened <- newIORef []
|
||||
let open = do
|
||||
Right st <- createDBStore opts storeServiceSchemaMigrations (MigrationConfig MCError Nothing)
|
||||
st <$ modifyIORef' opened (st :)
|
||||
action open `E.finally` (readIORef opened >>= mapM_ closeDBStore)
|
||||
|
||||
withServiceDBOpts :: (DBOpts -> IO a) -> IO a
|
||||
#if defined(dbPostgres)
|
||||
withServiceDBOpts action = do
|
||||
n <- atomicModifyIORef' serviceSchemaCounter (\i -> (i + 1, i))
|
||||
Right st <- createDBStore (serviceDBOpts n) storeServiceSchemaMigrations (MigrationConfig MCError Nothing)
|
||||
action st `E.finally` closeDBStore st
|
||||
where
|
||||
serviceDBOpts :: Int -> DBOpts
|
||||
serviceDBOpts n =
|
||||
DBOpts
|
||||
{ connstr = BC.pack testDBConnstr,
|
||||
schema = "sx_badge_service_web_test_" <> BC.pack (show n),
|
||||
poolSize = 4,
|
||||
createSchema = True
|
||||
}
|
||||
action
|
||||
DBOpts
|
||||
{ connstr = BC.pack testDBConnstr,
|
||||
schema = "sx_badge_service_web_test_" <> BC.pack (show n),
|
||||
poolSize = 4,
|
||||
createSchema = True
|
||||
}
|
||||
|
||||
serviceSchemaCounter :: IORef Int
|
||||
serviceSchemaCounter = unsafePerformIO $ newIORef 0
|
||||
{-# NOINLINE serviceSchemaCounter #-}
|
||||
#else
|
||||
withServiceStore :: (DBStore -> IO a) -> IO a
|
||||
withServiceStore action = do
|
||||
withServiceDBOpts action = do
|
||||
createDirectoryIfMissing True "tests/tmp"
|
||||
withTempDirectory "tests/tmp" "badge-web" $ \dir -> do
|
||||
Right st <- createDBStore (DBOpts (dir </> "badge_service_test.db") [] "" False True TQOff) storeServiceSchemaMigrations (MigrationConfig MCError Nothing)
|
||||
action st `E.finally` closeDBStore st
|
||||
withTempDirectory "tests/tmp" "badge-web" $ \dir -> action (DBOpts (dir </> "badge_service_test.db") [] "" False True TQOff)
|
||||
#endif
|
||||
|
||||
columnsOf :: DBStore -> Text -> IO [Text]
|
||||
|
||||
@@ -10,6 +10,7 @@ import Bots.StoreService.ConfigTests
|
||||
import Bots.StoreService.GroupIntegrationTests
|
||||
import Bots.StoreService.GroupTests
|
||||
import Bots.StoreService.NameRegistrationTests
|
||||
import Bots.StoreService.NamesBalancesTests
|
||||
import Bots.StoreService.NamesChainTests
|
||||
import Bots.StoreService.NamesCodesTests
|
||||
import Bots.StoreService.NamesContractsTests
|
||||
@@ -104,6 +105,7 @@ main = do
|
||||
badgeStripeTests
|
||||
badgeNameRegistrationTests
|
||||
namesConfigTests
|
||||
namesBalancesTests
|
||||
namesChainTests
|
||||
namesCodesTests
|
||||
namesContractsTests
|
||||
|
||||
Reference in new issue
Block a user