mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 12:57:45 +00:00
771 lines
43 KiB
Haskell
771 lines
43 KiB
Haskell
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE TupleSections #-}
|
|
|
|
module StoreService.Service
|
|
( ServiceState (..),
|
|
newServiceState,
|
|
configureStoreVerifier,
|
|
welcomeGetOpts,
|
|
trustDevKeys,
|
|
checkDevChain,
|
|
requireDevKeys,
|
|
checkIssuerKey,
|
|
storeService,
|
|
storeServiceCLI,
|
|
storeServiceResponse,
|
|
badgeErrorRetryAfter,
|
|
shownServiceRequest,
|
|
survive,
|
|
IssueCodeOpts (..),
|
|
issueBadgeCode,
|
|
)
|
|
where
|
|
|
|
import StoreService.Catalog (StoreProduct (..), defaultCatalog, storeProduct)
|
|
import StoreService.Codes (issueFailedText, issueOneCode, singleUse)
|
|
import StoreService.Config (AppleStoreConfig (..), BadgeIssuerKey (..), ChainConfig (..), NamesConfig (..), PlayStoreConfig (..), ServiceConfig (..), localChainId, minNameYears, readServiceConfig)
|
|
import StoreService.Group (GroupEvent, ensureManagedGroup, fmtTime, groupEvent, hasTracker, refreshTracker, revokeWithTracker, runGroupLane)
|
|
import StoreService.Group.Command (badgeTypeP, codeP, maxMonths, maxNameYears, nameCodeP, textTokenP)
|
|
import StoreService.Names.Balances (RefundRecord (..), RefundRefusal (..), recordRefund)
|
|
import StoreService.Names.Chain (newChainClient, nodeChainId)
|
|
import StoreService.Names.Codes (issueNameCode, revokeNameCode)
|
|
import StoreService.Names.Env (NamesEnv (..), newNamesEnv)
|
|
import StoreService.Names.Orders (ordersEnv)
|
|
import StoreService.Names.Phases (RegistrationId (..))
|
|
import StoreService.Names.Records (runRecordEdits)
|
|
import StoreService.Names.Sender (runSender)
|
|
import StoreService.Names.Store (Balance (..), BalanceHold (..), BalanceLink (..), LateCredit (..), RegistrationPayment (..), balanceByReference, balanceChain, methodText)
|
|
import StoreService.Names.Turns (Turns, newTurns)
|
|
import StoreService.Names.Worker (newWorker, runCodeSweep, runWorkers)
|
|
import StoreService.Options
|
|
import StoreService.Poller (expiryGrace, newPollerEnv, newReadHints, runPoller)
|
|
import StoreService.Providers (Provider)
|
|
import StoreService.Providers.BTCPay (btcpayProvider)
|
|
import StoreService.Providers.Stripe (stripeProvider)
|
|
import StoreService.Store
|
|
import StoreService.Store.Invoices (seedCatalog, truncateToSecond)
|
|
import StoreService.Store.Migrate (runStoreServiceMigrations)
|
|
import StoreService.StoreReceipts
|
|
import StoreService.StoreReceipts.Apple (readAppleRoot, verifyAppleTransaction)
|
|
import StoreService.StoreReceipts.Google (playStoreVerifier)
|
|
import StoreService.StoreReceipts.Mock (mockStoreVerifier)
|
|
import StoreService.Waiters (Waiters, newWaiters)
|
|
import StoreService.Web.Server (checkSiteDirs, exportWebapp, newWebEnv, runWebListeners)
|
|
import Control.Applicative (optional)
|
|
import Control.Concurrent.STM
|
|
import StoreService.Log (logError, logInfo, logWarn)
|
|
import Control.Monad
|
|
import Control.Monad.IO.Class (liftIO)
|
|
import qualified Data.Aeson as J
|
|
import qualified Data.Aeson.KeyMap as KM
|
|
import qualified Data.Attoparsec.ByteString.Char8 as A
|
|
import Data.Bifunctor (first)
|
|
import Data.ByteString.Char8 (ByteString)
|
|
import Data.Char (isSpace)
|
|
import Data.Either (fromRight, isRight)
|
|
import Data.Functor (($>), (<&>))
|
|
import Data.Int (Int64)
|
|
import qualified Data.Map.Strict as M
|
|
import Data.Maybe (fromMaybe, isJust, maybeToList)
|
|
import qualified Data.Text as T
|
|
import Data.Time.Clock (NominalDiffTime, UTCTime, getCurrentTime)
|
|
import Data.Word (Word32)
|
|
import Simplex.Chat.Badges
|
|
import Simplex.Chat.Badges.Code
|
|
import Simplex.Chat.Badges.Ledger
|
|
import Simplex.Chat.StoreService
|
|
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..))
|
|
import Simplex.Chat.Bot (initializeBotAddress')
|
|
import Simplex.Chat.Bot.Store (withDB, withDB')
|
|
import Simplex.Chat.Controller
|
|
import Simplex.Chat.Core (sendChatCmd, simplexChatCore)
|
|
import Simplex.Chat.Options (printDbOpts)
|
|
import Simplex.Chat.PaymentService (ServicePayment (..))
|
|
import Simplex.Chat.PaymentService.Types (InvoiceId (..), StoreTransactionRef (..))
|
|
import Simplex.Chat.Terminal (terminalChatConfig)
|
|
import Simplex.Chat.Terminal.Main (simplexChatCLI')
|
|
import Simplex.Chat.Types (AgentInvId (..), User (..))
|
|
import Simplex.Messaging.Agent.Store.Common (DBStore)
|
|
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import Simplex.Messaging.Crypto.BBS (bbsPublicKey)
|
|
import Simplex.Messaging.Encoding.String (strEncode)
|
|
import Simplex.Messaging.Util (catchOwn, catchOwn', raceAny_, safeDecodeUtf8, tshow)
|
|
import Simplex.Messaging.Version (isCompatible)
|
|
import System.Directory (getAppUserDataDirectory)
|
|
import System.Exit (exitFailure)
|
|
|
|
data ServiceState = ServiceState
|
|
{ serviceCC :: TMVar ChatController,
|
|
serviceRequestQ :: TQueue (User, AgentInvId, Maybe C.PublicKeyEd25519, J.Object),
|
|
groupEventQ :: TQueue GroupEvent,
|
|
storeVerifier :: StoreVerifier
|
|
}
|
|
|
|
-- | With no store verifier, which store_service.ini configures, every store receipt not already credited
|
|
-- is answered provider_not_configured.
|
|
newServiceState :: IO ServiceState
|
|
newServiceState = do
|
|
serviceCC <- newEmptyTMVarIO
|
|
serviceRequestQ <- newTQueueIO
|
|
groupEventQ <- newTQueueIO
|
|
pure ServiceState {serviceCC, serviceRequestQ, groupEventQ, storeVerifier = noStoreVerifier}
|
|
|
|
welcomeGetOpts :: IO StoreServiceOpts
|
|
welcomeGetOpts = do
|
|
appDir <- getAppUserDataDirectory "simplex"
|
|
opts@StoreServiceOpts {coreOptions, testing, serviceName} <- getStoreServiceOpts appDir "simplex_badge_service"
|
|
unless testing $ do
|
|
putStrLn $ "SimpleX Store Service v" ++ versionNumber
|
|
printDbOpts coreOptions
|
|
putStrLn $ "Service name: " ++ T.unpack serviceName
|
|
pure opts
|
|
|
|
-- | The ini's dev_keys, trusted beside the clients' keys; the config accepts them only on the local chain.
|
|
trustDevKeys :: Maybe ServiceConfig -> ChatConfig -> IO (Either String ChatConfig)
|
|
trustDevKeys serviceCfg cfg@ChatConfig {badgePublicKeys} = do
|
|
r <- fmap trusted . sequence <$> mapM publicKey devKeys
|
|
when (isRight r && not (null devKeys)) $
|
|
logWarn $ "trusting issuer dev_keys at indexes " <> T.intercalate ", " (map (\BadgeIssuerKey {keyIdx} -> tshow keyIdx) devKeys) <> "; no client trusts them, never set them in production"
|
|
pure r
|
|
where
|
|
devKeys = maybe [] devIssuerKeys serviceCfg
|
|
trusted ks = cfg {badgePublicKeys = M.union badgePublicKeys (M.fromList ks)}
|
|
publicKey BadgeIssuerKey {keyIdx, secretKey}
|
|
| M.member keyIdx badgePublicKeys = pure $ Left $ "issuer.dev_keys index " <> show keyIdx <> " is the index of a key clients trust"
|
|
| otherwise = fmap (keyIdx,) . first (("issuer.dev_keys secret at index " <> show keyIdx <> " is not a valid key: ") <>) <$> bbsPublicKey secretKey
|
|
|
|
-- | With dev keys, the node itself must be the local chain, not only the ini's chain_id.
|
|
checkDevChain :: Maybe ServiceConfig -> IO (Either String ())
|
|
checkDevChain = \case
|
|
Just ServiceConfig {devIssuerKeys = _ : _, names = Just NamesConfig {nChain}} -> do
|
|
chain <- C.newRandom >>= (`newChainClient` nChain)
|
|
nodeChainId chain <&> \case
|
|
Right chainId
|
|
| chainId == localChainId -> Right ()
|
|
| otherwise -> Left $ refused <> "is " <> show chainId <> ", not " <> show localChainId
|
|
Left _ -> Left $ refused <> "could not be read"
|
|
_ -> pure $ Right ()
|
|
where
|
|
refused = "issuer.dev_keys is refused: the node's chain id "
|
|
|
|
checkIssuerKey :: StoreServiceOpts -> Maybe ServiceConfig -> ChatConfig -> IO (Either String BadgeIssuerKey)
|
|
checkIssuerKey StoreServiceOpts {issuerKey} serviceCfg cfg = case issuerKey of
|
|
Left e -> pure (Left e)
|
|
Right (Just k) -> checkOne cfg k
|
|
Right Nothing -> case serviceCfg >>= issuer of
|
|
Nothing -> pure $ Left "an issuer key is required - pass --issuer-key-idx and --issuer-secret, or add an [issuer] section to store_service.ini (see `simplex-chat badge keygen`)"
|
|
Just k -> checkOne cfg k
|
|
|
|
checkOne :: ChatConfig -> BadgeIssuerKey -> IO (Either String BadgeIssuerKey)
|
|
checkOne ChatConfig {badgePublicKeys} k@BadgeIssuerKey {keyIdx, secretKey} =
|
|
bbsPublicKey secretKey >>= \case
|
|
Left e -> pure $ Left $ "issuer secret at index " <> show keyIdx <> " is not a valid key: " <> e
|
|
Right pk -> pure $ case M.lookup keyIdx badgePublicKeys of
|
|
Just pk' | pk' == pk -> Right k
|
|
Just _ -> Left $ "issuer secret does not match the configured key at index " <> show keyIdx <> ", its public key is " <> T.unpack (safeDecodeUtf8 $ strEncode pk)
|
|
Nothing -> Left $ "no configured badge key at index " <> show keyIdx <> ", clients could not verify what this service signs"
|
|
|
|
requireIssuerKey :: StoreServiceOpts -> Maybe ServiceConfig -> ChatConfig -> IO BadgeIssuerKey
|
|
requireIssuerKey opts serviceCfg cfg =
|
|
checkIssuerKey opts serviceCfg cfg >>= orExit
|
|
|
|
requireDevKeys :: Maybe ServiceConfig -> ChatConfig -> IO ChatConfig
|
|
requireDevKeys serviceCfg cfg = do
|
|
checkDevChain serviceCfg >>= orExit
|
|
trustDevKeys serviceCfg cfg >>= orExit
|
|
|
|
orExit :: Either String a -> IO a
|
|
orExit = either (\e -> putStrLn ("Error: " <> e) >> exitFailure) pure
|
|
|
|
readConfigOrExit :: FilePath -> IO ServiceConfig
|
|
readConfigOrExit path =
|
|
readServiceConfig path >>= \case
|
|
Left e -> putStrLn (path <> ": " <> e) >> exitFailure
|
|
Right sc -> pure sc
|
|
|
|
-- | The config refuses the dev flag beside a real verifier, so only one of them applies.
|
|
configureStoreVerifier :: Maybe ServiceConfig -> ServiceState -> IO ServiceState
|
|
configureStoreVerifier serviceCfg env@ServiceState {storeVerifier = v} = case serviceCfg of
|
|
Just ServiceConfig {devAcceptUnverifiedStoreReceipts = True} -> do
|
|
logWarn "[dev] accept_unverified_store_receipts is on: any store receipt is accepted without verification"
|
|
pure env {storeVerifier = mockStoreVerifier}
|
|
Just ServiceConfig {appleStore, playStore} -> do
|
|
apple <- forM appleStore $ \AppleStoreConfig {aBundleId, aRootCertificates} ->
|
|
(\roots -> verifyAppleTransaction roots aBundleId) <$> mapM ((>>= orExit) . readAppleRoot) aRootCertificates
|
|
google <- forM playStore $ (>>= orExit) . playStoreVerifier
|
|
forM_ appleStore $ \AppleStoreConfig {aBundleId} -> logInfo $ "verifying App Store receipts for " <> aBundleId
|
|
forM_ playStore $ \PlayStoreConfig {gPackageName} -> logInfo $ "verifying Play purchases for " <> gPackageName
|
|
pure
|
|
env
|
|
{ storeVerifier =
|
|
v
|
|
{ verifyApple = maybe (verifyApple v) Just apple,
|
|
verifyGoogle = maybe (verifyGoogle v) (Just . fst) google,
|
|
acknowledgeGoogle = maybe (acknowledgeGoogle v) (Just . snd) google
|
|
}
|
|
}
|
|
Nothing -> pure env
|
|
|
|
storeService :: StoreServiceOpts -> ChatConfig -> ServiceState -> IO ()
|
|
storeService opts@StoreServiceOpts {serviceConfigFile} baseCfg env = do
|
|
serviceCfg <- traverse readConfigOrExit serviceConfigFile
|
|
forM_ serviceCfg $ \sc -> checkSiteDirs (listener sc) >>= orExit
|
|
cfg <- requireDevKeys serviceCfg baseCfg
|
|
key <- requireIssuerKey opts serviceCfg cfg
|
|
waiters <- newWaiters
|
|
-- The hooks' //revoke takes a prepaid code's turn, so the turns exist before the chat starts.
|
|
turns <- newTurns
|
|
requestEnv <- configureStoreVerifier serviceCfg env
|
|
let groupCfg = serviceCfg >>= \ServiceConfig {group} -> group
|
|
trackerQ_ = groupEventQ env <$ groupCfg
|
|
readEvents cc =
|
|
forever $
|
|
atomically (readTBQueue $ outputQ cc) >>= \case
|
|
(_, Right (CEvtServiceRequest u reqId sigKey reqData)) ->
|
|
atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData)
|
|
(_, Right ev)
|
|
| isJust groupCfg -> forM_ (groupEvent ev) $ atomically . writeTQueue (groupEventQ env)
|
|
_ -> pure ()
|
|
startLanes cc = do
|
|
lanes <- maybe (pure []) (serviceLanes waiters turns cc) serviceCfg
|
|
-- The group is resolved before the other lanes start, so a failed lookup exits at once rather than waiting for them to end.
|
|
groupLane_ <- forM groupCfg $ \gc -> runGroupLane cc (groupEventQ env) <$> ensureManagedGroup cc gc
|
|
raceAny_ $ processQueuedRequests key trackerQ_ requestEnv : maybeToList groupLane_ <> lanes
|
|
chatHooks =
|
|
defaultChatHooks
|
|
{ preStartHook = Just $ badgePreStartHook opts,
|
|
postStartHook = Just $ badgePostStartHook opts env,
|
|
preCmdHook = Just $ badgeCmdHook turns
|
|
}
|
|
-- The reader runs from the start and must not block, since outputQ carries every chat event and a full queue stalls the core.
|
|
simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \_ cc -> raceAny_ [readEvents cc, startLanes cc]
|
|
where
|
|
serviceLanes :: Waiters -> Turns -> ChatController -> ServiceConfig -> IO [IO ()]
|
|
serviceLanes ws turns cc@ChatController {chatStore} sc = do
|
|
-- Seed before the listener accepts anything, since every checkout is priced from these.
|
|
seedServiceCatalog chatStore
|
|
btc <- maybe (pure []) (fmap (: []) . btcpayProvider) (btcpay sc)
|
|
str <- maybe (pure []) (fmap (: []) . stripeProvider) (stripe sc)
|
|
let providers = btc <> str
|
|
hints <- newReadHints
|
|
namesEnv <- forM (names sc) (startNames cc turns ws providers)
|
|
webEnv <- newWebEnv chatStore sc ws hints providers namesEnv
|
|
exportWebapp (listener sc) (stripe sc)
|
|
pollerEnv <- newPollerEnv chatStore ws (nePhases <$> namesEnv) hints providers (poll sc)
|
|
nameLanes <- maybe (pure []) namesLanes namesEnv
|
|
pure $ [runWebListeners webEnv, runPoller pollerEnv] <> nameLanes
|
|
|
|
startNames :: ChatController -> Turns -> Waiters -> [Provider] -> NamesConfig -> IO NamesEnv
|
|
startNames cc@ChatController {chatStore} turns waiters providers nc@NamesConfig {nTLD, nChain = ChainConfig {cChainId}} =
|
|
readTVarIO (currentUser cc) >>= \case
|
|
Nothing -> putStrLn "No current user" >> exitFailure
|
|
Just User {userId} -> do
|
|
logInfo $ "names: on, TLD " <> safeDecodeUtf8 (strEncode nTLD) <> ", chain " <> tshow cChainId
|
|
newNamesEnv cc userId nc chatStore turns waiters providers
|
|
|
|
-- | The workers resume every registration in their phases, and the code sweep runs at the start and then daily.
|
|
namesLanes :: NamesEnv -> IO [IO ()]
|
|
namesLanes ne@NamesEnv {neChain, neSender, neRecords} = do
|
|
worker <- newWorker (ordersEnv ne) neChain neSender
|
|
pure [runSender neSender, runRecordEdits neRecords, runWorkers worker, runCodeSweep worker]
|
|
|
|
seedServiceCatalog :: DBStore -> IO ()
|
|
seedServiceCatalog st = do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
let (prices, offers) = defaultCatalog now
|
|
(seededPrices, seededOffers) <- seedCatalog st prices offers
|
|
logInfo $
|
|
"badge catalog: " <> tshow (length prices) <> " prices and " <> tshow (length offers) <> " offers compiled in, "
|
|
<> tshow seededPrices <> " prices and " <> tshow seededOffers <> " offers inserted"
|
|
|
|
storeServiceCLI :: StoreServiceOpts -> IO ()
|
|
storeServiceCLI opts@StoreServiceOpts {serviceConfigFile} = do
|
|
serviceCfg <- traverse readConfigOrExit serviceConfigFile
|
|
cfg <- requireDevKeys serviceCfg terminalChatConfig
|
|
key <- requireIssuerKey opts serviceCfg cfg
|
|
env <- newServiceState >>= configureStoreVerifier serviceCfg
|
|
-- This mode runs no names lanes, so only its own //revoke takes these turns.
|
|
turns <- newTurns
|
|
let eventHook _cc = \case
|
|
Right (CEvtServiceRequest u reqId sigKey reqData) -> do
|
|
atomically $ writeTQueue (serviceRequestQ env) (u, reqId, sigKey, reqData)
|
|
pure $ Right $ CEvtServiceRequest u reqId sigKey (shownServiceRequest reqData)
|
|
ev -> pure ev
|
|
chatHooks =
|
|
defaultChatHooks
|
|
{ preStartHook = Just $ badgePreStartHook opts,
|
|
postStartHook = Just $ badgePostStartHook opts env,
|
|
preCmdHook = Just $ badgeCmdHook turns,
|
|
eventHook = Just eventHook
|
|
}
|
|
raceAny_
|
|
[ simplexChatCLI' cfg {chatHooks} (mkChatOpts opts) Nothing,
|
|
processQueuedRequests key Nothing env
|
|
]
|
|
|
|
-- | The terminal prints each request, and requests carry codes and store receipts, which are bearer secrets.
|
|
shownServiceRequest :: J.Object -> J.Object
|
|
shownServiceRequest reqData = case KM.lookup "request" reqData of
|
|
Just (J.Object cmd) | Just t <- KM.lookup "type" cmd -> KM.singleton "request" $ J.object ["type" J..= t]
|
|
_ -> KM.empty
|
|
|
|
badgeCmdHook :: Turns -> ChatController -> ChatCommand -> IO (Either (Either ChatError ChatResponse) ChatCommand)
|
|
badgeCmdHook turns cc = \case
|
|
CustomChatCommand cmd -> Left <$> runBadgeCmd turns cc cmd
|
|
cmd -> pure $ Right cmd
|
|
|
|
runBadgeCmd :: Turns -> ChatController -> ByteString -> IO (Either ChatError ChatResponse)
|
|
runBadgeCmd turns cc cmd
|
|
| Right issueOpts <- A.parseOnly issueCmdP cmd =
|
|
issuedReply formatBadgeCode <$> issueBadgeCode cc issueOpts
|
|
| Right (len, years, paymentStatus) <- A.parseOnly issueNameCmdP cmd =
|
|
issuedReply (formatNameCode . NCPrepaid) <$> issueNameCode cc len years paymentStatus
|
|
| Right code <- A.parseOnly revokeCmdP cmd = textReply <$> revokeWithTracker cc code
|
|
| Right code <- A.parseOnly revokeNameCmdP cmd = textReply <$> revokeNameCode turns cc code
|
|
| Right (ref, cents_) <- A.parseOnly refundCmdP cmd = textReply <$> maybe (showRefund cc ref) (refundBalanceCmd turns cc ref) cents_
|
|
| otherwise =
|
|
pure $ chatCmdError $
|
|
"Usage: //issue supporter|legend|investor [months 1-" <> show maxMonths <> "] [paid|unpaid|free], //issue name <length "
|
|
<> show minNameLength <> "-" <> show maxNameLength <> "> [years " <> show minNameYears <> "-" <> show maxNameYears
|
|
<> "] [paid|unpaid|free], //revoke <code>, or //refund <reference> [<cents>]"
|
|
where
|
|
issuedReply :: (c -> T.Text) -> Either String c -> Either ChatError ChatResponse
|
|
issuedReply format = \case
|
|
Right code -> Right CRCustomChatResponse {user_ = Nothing, response = "Code: " <> format code}
|
|
Left _ -> chatCmdError (T.unpack issueFailedText)
|
|
textReply = \case
|
|
Right response -> Right CRCustomChatResponse {user_ = Nothing, response}
|
|
Left e -> chatCmdError (T.unpack e)
|
|
|
|
revokeCmdP :: A.Parser BadgeCode
|
|
revokeCmdP = "revoke " *> codeP <* (A.skipSpace *> A.endOfInput)
|
|
|
|
revokeNameCmdP :: A.Parser NamePrepaidCode
|
|
revokeNameCmdP = "revoke " *> prepaidCodeP <* (A.skipSpace *> A.endOfInput)
|
|
where
|
|
prepaidCodeP =
|
|
parseNameCode . safeDecodeUtf8 <$> A.takeWhile1 (not . isSpace) >>= \case
|
|
Just (NCPrepaid code) -> pure code
|
|
_ -> fail "not a prepaid name code"
|
|
|
|
-- | The reference, and the cents of the balance to refund; without them the balance is shown.
|
|
refundCmdP :: A.Parser (T.Text, Maybe Int)
|
|
refundCmdP = do
|
|
ref <- "refund " *> (safeDecodeUtf8 <$> A.takeWhile1 (not . isSpace))
|
|
cents_ <- optional (A.space *> (A.decimal :: A.Parser Integer))
|
|
A.skipSpace
|
|
A.endOfInput
|
|
(ref,) <$> mapM checkCents cents_
|
|
where
|
|
-- Integer, as in issueCmdP, so a figure past Int is refused rather than wrapped.
|
|
checkCents n
|
|
| n >= 1 && n <= toInteger (maxBound :: Int) = pure (fromInteger n)
|
|
| otherwise = fail "cents out of range"
|
|
|
|
-- | Left is a refusal or a failure; both sides are the text to show the operator.
|
|
showRefund :: ChatController -> T.Text -> IO (Either T.Text T.Text)
|
|
showRefund cc ref = do
|
|
now <- getCurrentTime
|
|
withDB' "showRefund" cc (\db -> balanceByReference db ref now >>= mapM (withChain db)) <&> \case
|
|
Right Nothing -> Left $ "No order has the reference " <> ref <> "."
|
|
Right (Just (RegistrationId rid, Nothing)) -> Left $ "Order " <> rid <> " has no balance."
|
|
Right (Just (RegistrationId rid, Just (balance@Balance {blAmount, blCredited, blRefunded}, chain))) ->
|
|
Right $
|
|
"Order " <> rid <> ": balance " <> dollars blAmount <> " (credited " <> dollars blCredited <> ", refunded " <> dollars blRefunded
|
|
<> "). Paid by: " <> chainText chain <> ". " <> nextStep balance
|
|
Left _ -> Left "The balance could not be read."
|
|
where
|
|
withChain db (rid, balance_) = (rid,) <$> mapM (\balance -> (balance,) <$> balanceChain db rid) balance_
|
|
nextStep Balance {blAmount, blHold} = case blHold of
|
|
Just hold -> "It cannot be refunded now: " <> heldText hold <> "."
|
|
Nothing
|
|
| blAmount > 0 -> "To record its refund: //refund " <> ref <> " " <> tshow blAmount
|
|
| otherwise -> "Nothing to refund."
|
|
|
|
-- | The refund is recorded before support pays it, so the balance cannot be spent meanwhile.
|
|
refundBalanceCmd :: Turns -> ChatController -> T.Text -> Int -> IO (Either T.Text T.Text)
|
|
refundBalanceCmd turns ChatController {chatStore} ref cents = do
|
|
now <- getCurrentTime
|
|
((Right <$> recordRefund turns chatStore ref cents now) `catchOwn` (pure . Left)) >>= \case
|
|
Right (Right RefundRecord {rdRegistration = RegistrationId rid, rdCents, rdChain}) -> do
|
|
-- The registration id, not the reference, since an invoice id is kept out of the logs.
|
|
logInfo $ "names: recorded a refund of " <> tshow rdCents <> " cents for order " <> rid
|
|
pure $ Right $ "Recorded a refund of " <> dollars rdCents <> " for order " <> rid <> ". Refund it now, by: " <> chainText rdChain <> "."
|
|
Right (Left refusal) -> pure $ Left $ "Nothing recorded: " <> refusalText refusal <> "."
|
|
Left e -> Left "The refund could not be recorded." <$ logError ("names: recording a refund failed: " <> tshow e)
|
|
where
|
|
refusalText = \case
|
|
RefundNoOrder -> "no order has the reference " <> ref
|
|
RefundNoBalance -> "the order of " <> ref <> " has no balance"
|
|
RefundAmount amount -> "the balance is " <> dollars amount <> " now, not " <> dollars cents
|
|
RefundHeld hold -> heldText hold
|
|
|
|
heldText :: BalanceHold -> T.Text
|
|
heldText BalanceHold {bhHolder = RegistrationId holder, bhUntil, bhOverdue, bhFunded}
|
|
| bhOverdue = heldBy <> "its invoice expired at " <> fmtTime bhUntil <> "; start the service so the poller closes that order, " <> tshow graceMinutes <> " minutes after the expiry at the earliest, then stop it and run //refund again"
|
|
| bhFunded = heldBy <> "whose invoice holds money; it holds until that payment settles or the invoice closes; run the command again then"
|
|
| otherwise = heldBy <> "its invoice open until " <> fmtTime bhUntil <> "; run the command again then"
|
|
where
|
|
heldBy = "held by order " <> holder <> ", "
|
|
graceMinutes = round (expiryGrace / minute) :: Int
|
|
minute = 60 :: NominalDiffTime
|
|
|
|
-- | From the balance's own order back to the first; a part paid from a balance names the next order, whose balance paid it.
|
|
chainText :: [BalanceLink] -> T.Text
|
|
chainText chain = T.intercalate "; " $ zipWith linkText chain (map (Just . lkRegistration) (drop 1 chain) <> [Nothing])
|
|
where
|
|
linkText BalanceLink {lkRegistration = RegistrationId rid, lkPayment, lkPaid, lkFromBalance, lkLateCredits} source_ =
|
|
"order " <> rid <> ": " <> case paid lkPayment lkPaid lkLateCredits <> fromBalance lkFromBalance source_ of
|
|
[] -> "no payment recorded"
|
|
parts -> T.intercalate ", " parts
|
|
paid payment cents lateCredits = case payment of
|
|
RPInvoice method (InvoiceId invId) ->
|
|
let by = " by " <> methodText method <> " on invoice " <> invId
|
|
late = sum $ map lcCents lateCredits
|
|
in [dollars cents <> by | cents > 0] <> [dollars late <> " late" <> by | late > 0]
|
|
RPCode _ -> ["a prepaid code"]
|
|
RPBalance -> []
|
|
fromBalance cents = \case
|
|
Just (RegistrationId source) | cents > 0 -> [dollars cents <> " from the balance of order " <> source]
|
|
_ -> []
|
|
|
|
dollars :: Int -> T.Text
|
|
dollars cents = "$" <> tshow whole <> "." <> T.justifyRight 2 '0' (tshow part)
|
|
where
|
|
(whole, part) = cents `quotRem` centsPerDollar
|
|
centsPerDollar = 100
|
|
|
|
issueNameCmdP :: A.Parser (Int, Int, BadgeCodePaymentStatus)
|
|
issueNameCmdP = do
|
|
(len, years) <- "issue " *> nameCodeP
|
|
paymentStatus <- fromMaybe CPSFree <$> optional (A.space *> textTokenP)
|
|
A.skipSpace
|
|
A.endOfInput
|
|
pure (len, years, paymentStatus)
|
|
|
|
issueCmdP :: A.Parser IssueCodeOpts
|
|
issueCmdP =
|
|
"issue " *> do
|
|
badgeType <- badgeTypeP
|
|
months_ <- optional (A.space *> (A.decimal :: A.Parser Integer))
|
|
-- Kept outside optional, which would otherwise backtrack past a bad count.
|
|
months <- maybe (pure 1) checkMonths months_
|
|
paymentStatus <- fromMaybe CPSFree <$> optional (A.space *> textTokenP)
|
|
A.skipSpace
|
|
A.endOfInput
|
|
pure IssueCodeOpts {badgeType, months, paymentStatus}
|
|
where
|
|
-- Integer, because attoparsec's decimal wraps silently at Int, so the guard would check a truncated count.
|
|
checkMonths n
|
|
| n >= 1 && n <= fromIntegral maxMonths = pure (fromInteger n)
|
|
| otherwise = fail $ "months must be between 1 and " <> show maxMonths
|
|
|
|
data IssueCodeOpts = IssueCodeOpts
|
|
{ badgeType :: BadgeType,
|
|
months :: Int,
|
|
paymentStatus :: BadgeCodePaymentStatus
|
|
}
|
|
|
|
issueBadgeCode :: ChatController -> IssueCodeOpts -> IO (Either String BadgeCode)
|
|
issueBadgeCode cc IssueCodeOpts {badgeType, months, paymentStatus} =
|
|
fmap fst <$> issueOneCode cc badgeType months paymentStatus singleUse
|
|
|
|
processQueuedRequests :: BadgeIssuerKey -> Maybe (TQueue GroupEvent) -> ServiceState -> IO ()
|
|
processQueuedRequests key trackerQ_ env = do
|
|
cc <- atomically $ readTMVar $ serviceCC env
|
|
-- a Play hand-over can wait two verifyTimeouts on Play, so it waits on a lane of its own
|
|
playQ <- newTQueueIO
|
|
let answer (u, reqId, sigKey, reqData) = handleServiceRequest key (storeVerifier env) cc trackerQ_ u reqId sigKey reqData
|
|
route req@(_, _, _, reqData)
|
|
| presentsPlayReceipt reqData = atomically $ writeTQueue playQ req
|
|
| otherwise = answer req
|
|
lane q act = forever $ do
|
|
req@(_, reqId, _, _) <- atomically $ readTQueue q
|
|
survive ("request " <> safeDecodeUtf8 (strEncode reqId)) $ act req
|
|
raceAny_ [lane (serviceRequestQ env) route, lane playQ answer]
|
|
|
|
-- | Only what creates a purchase leaves the main lane: an append to an existing ledger is guarded by nothing but that lane being serial.
|
|
presentsPlayReceipt :: J.Object -> Bool
|
|
presentsPlayReceipt reqData = case J.fromJSON (J.Object reqData) of
|
|
J.Success StoreServiceRequest {request = STCPurchaseBadge {payment = SPGoogle {}, upgrade = Nothing}} -> True
|
|
_ -> False
|
|
|
|
-- | Cancellation is rethrown, since that is how the race stops this thread. The
|
|
-- exception itself is not logged: it can quote the request, which carries codes and store receipts.
|
|
survive :: T.Text -> IO () -> IO ()
|
|
survive what action = action `catchOwn'` \_ -> logError $ "store service: " <> what <> " failed; the next one will be handled"
|
|
|
|
badgePreStartHook :: StoreServiceOpts -> ChatController -> IO ()
|
|
badgePreStartHook opts ChatController {config, chatStore} =
|
|
runStoreServiceMigrations opts config chatStore
|
|
|
|
badgePostStartHook :: StoreServiceOpts -> ServiceState -> ChatController -> IO ()
|
|
badgePostStartHook StoreServiceOpts {noAddress, testing} env cc = do
|
|
-- Core starts this False and gates service request delivery on it, so the hook must set it.
|
|
atomically $ writeTVar (processServiceRequests cc) True
|
|
readTVarIO (currentUser cc) >>= \case
|
|
Nothing -> putStrLn "No current user" >> exitFailure
|
|
Just _ -> do
|
|
-- The address carries service RPC only, so contact requests are never auto-accepted.
|
|
unless noAddress $ initializeBotAddress' (not testing) (Just True) False cc
|
|
void $ atomically $ tryPutTMVar (serviceCC env) cc
|
|
|
|
handleServiceRequest :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO ()
|
|
handleServiceRequest key verifier cc trackerQ_ User {userId} reqId sigKey reqData = do
|
|
let reqIdT = safeDecodeUtf8 (strEncode reqId)
|
|
logInfo $ "store service request " <> reqIdT
|
|
resp <-
|
|
storeServiceResponse key verifier cc trackerQ_ sigKey reqData `catchOwn'` \_ ->
|
|
logError ("store service request " <> reqIdT <> " failed") $> errorResponse STEInternal
|
|
sendChatCmd cc (APISendServiceResponse userId reqId (responseObject resp)) >>= \case
|
|
Right _ -> pure ()
|
|
Left e -> logError $ "store service response failed for " <> reqIdT <> ": " <> tshow e
|
|
|
|
responseObject :: StoreServiceResponse -> J.Object
|
|
responseObject r = case J.toJSON r of
|
|
J.Object o -> o
|
|
_ -> KM.fromList [("type", J.String "error"), ("code", J.toJSON STEInternal)]
|
|
|
|
errorResponse :: StoreServiceErrorCode -> StoreServiceResponse
|
|
errorResponse code = STPError {code, message = Nothing, retryAfter = badgeErrorRetryAfter code}
|
|
|
|
badgeErrorRetryAfter :: StoreServiceErrorCode -> Maybe Word32
|
|
badgeErrorRetryAfter = \case
|
|
STEPaymentPending -> Just 300
|
|
STEProviderUnavailable -> Just 300
|
|
STERateLimited -> Just 60
|
|
_ -> Nothing
|
|
|
|
|
|
-- | The agent verified the signature, so sigKey is a key the sender holds; a differing purchaseKey would let a client claim a purchase it cannot sign for.
|
|
storeServiceResponse :: BadgeIssuerKey -> StoreVerifier -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO StoreServiceResponse
|
|
storeServiceResponse key verifier cc trackerQ_ sigKey reqData = case J.fromJSON (J.Object reqData) of
|
|
J.Error _ -> pure $ errorResponse STEBadRequest
|
|
J.Success StoreServiceRequest {version, purchaseKey, request}
|
|
| not (version `isCompatible` supportedStoreServiceVRange) -> pure $ errorResponse STEUnsupportedVersion
|
|
| purchaseKey /= sigKey -> pure $ errorResponse STEBadRequest
|
|
| otherwise -> case request of
|
|
STCRedeemBadgeCode {masterKey, code} -> case purchaseKey of
|
|
Just k -> redeemCode key cc trackerQ_ k masterKey code
|
|
Nothing -> pure $ errorResponse STEBadRequest
|
|
STCPurchaseBadge {masterKey, payment, upgrade}
|
|
| Just receipt_ <- toStoreReceipt verifier payment -> case (purchaseKey, upgrade) of
|
|
(Just k, Nothing) -> either storeRefusalResponse (purchaseWithReceipt key cc k masterKey) receipt_
|
|
-- store upgrades are not built, and ignoring one would credit its months at the discounted price
|
|
(Just _, Just _) -> pure $ errorResponse STEBadRequest
|
|
(Nothing, _) -> pure $ errorResponse STEBadRequest
|
|
STCIssueBadge {balance} -> case purchaseKey of
|
|
Just k -> issueBadgeCmd key cc k balance
|
|
Nothing -> pure $ errorResponse STEBadRequest
|
|
-- Every command but redeemBadgeCode and purchaseBadge from a store needs a key the service already knows; those two create the purchase, so their key is unknown on a first purchase.
|
|
_ -> case purchaseKey of
|
|
Nothing -> pure $ errorResponse STEUnsupportedVersion
|
|
Just k ->
|
|
withDB' "purchaseKeyExists" cc (`purchaseKeyExists` k) >>= \case
|
|
Right True -> pure $ errorResponse STEUnsupportedVersion
|
|
Right False -> pure $ errorResponse STEUnknownPurchaseKey
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
|
|
badgeNow :: ChatController -> IO UTCTime
|
|
badgeNow ChatController {config = ChatConfig {badgeCurrentTime}} = badgeCurrentTime
|
|
|
|
randomId :: ChatController -> IO T.Text
|
|
randomId cc = safeDecodeUtf8 . strEncode <$> atomically (C.randomBytes 16 (random cc))
|
|
|
|
-- | Neither the badge type nor the expiry comes from the caller; both derive from the entry.
|
|
credentialForEntry :: BadgeIssuerKey -> BadgeMasterKey -> StatementEntry -> IO (Either String (StatementEntry, BadgeCredential))
|
|
credentialForEntry BadgeIssuerKey {keyIdx, secretKey} masterKey e@StatementEntry {balanceStartTs = periodEnd, balanceBadgeType} = do
|
|
let badgeInfo = BadgeInfo {badgeType = balanceBadgeType, badgeExpiry = endOfMondayAfter periodEnd, badgeExtra = ""}
|
|
fmap (e,) <$> issueBadge keyIdx secretKey (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo})
|
|
|
|
issuanceAfter :: StatementEntry -> (StatementEntry, BadgeCredential) -> (StatementEntry, StatementEntry, BadgeCredential)
|
|
issuanceAfter previous (issued, credential) = (previous, issued, credential)
|
|
|
|
credentialResponse :: Maybe BadgeCredential -> Maybe T.Text -> [StatementEntry] -> StoreServiceResponse
|
|
credentialResponse credential previousEntryId entries =
|
|
STPBadgeCredential {credential, receipt = Nothing, statement = BadgeStatement {entries, previousEntryId}}
|
|
|
|
-- | Nothing is written until the credential is signed, so a signing failure leaves the code unspent.
|
|
redeemCode :: BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> C.PublicKeyEd25519 -> BadgeMasterKey -> T.Text -> IO StoreServiceResponse
|
|
redeemCode key cc trackerQ_ purchaseKey masterKey codeText = case parseBadgeCode codeText of
|
|
Nothing -> pure $ errorResponse STECodeInvalid
|
|
Just code -> do
|
|
now <- badgeNow cc
|
|
withDB "getBadgeCode" cc (readCode now code) >>= \case
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right (Left resp) -> pure resp
|
|
Right (Right IssuedCode {badgeCodeId, badgeType, months, redeemLimit}) ->
|
|
signFirstMonth key cc masterKey badgeType months SCCode now >>= \case
|
|
Left resp -> pure resp
|
|
Right firstMonth -> do
|
|
-- If the code was revoked or used up while signing, the claim fails. Read the code again to tell the client why.
|
|
r <- withDB "writeCodeRedemption" cc $ \db ->
|
|
liftIO (createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType} now) >>= \case
|
|
Nothing ->
|
|
readCode now code db >>= \case
|
|
Left resp -> pure (resp, False)
|
|
Right _ -> logError "store service: redeeming a code failed, but the code has uses left and is not revoked" $> (errorResponse STEInternal, False)
|
|
Just purchaseId -> liftIO $ (,True) <$> firstMonthResponse db purchaseId Nothing firstMonth
|
|
let (resp, claimed) = fromRight (errorResponse STEInternal, False) r
|
|
when (claimed && hasTracker redeemLimit) $ refreshTracker cc trackerQ_ badgeCodeId code
|
|
pure resp
|
|
where
|
|
readCode now code db = liftIO $
|
|
getBadgeCode db (badgeCodeHash code) >>= \case
|
|
Nothing -> pure $ Left $ errorResponse STECodeInvalid
|
|
Just c@IssuedCode {badgeCodeId, revokedAt, paymentStatus, expiresAt, redeemLimit, redeemCount} ->
|
|
getCodePurchaseForKey db badgeCodeId purchaseKey >>= \case
|
|
-- A key that already redeemed gets its credential back without a use, even if the code has since
|
|
-- expired or been revoked: a client whose reply was lost retries, and would otherwise lose the badge.
|
|
KeyRedeemed KeyPurchase {badgePurchaseId, credential} ->
|
|
maybe (Left $ errorResponse STEInternal) (Left . credentialResponse (Just credential) Nothing)
|
|
<$> getLedgerEntries db badgePurchaseId 0
|
|
-- code_used would make the client drop its keys, so the holder could never get the badge back.
|
|
KeyRedeemedUnreadable ->
|
|
logError "store service: a redeemed code's credential is missing or unreadable" $> Left (errorResponse STEInternal)
|
|
KeyUnredeemed
|
|
-- Revoked is checked first, so it answers as if the code never existed.
|
|
| Just _ <- revokedAt -> pure $ Left $ errorResponse STECodeInvalid
|
|
-- Redeeming an unpaid code would issue a free badge, so unpaid is refused.
|
|
| CPSUnpaid <- paymentStatus -> pure $ Left $ errorResponse STEPaymentPending
|
|
| redeemCount >= redeemLimit -> pure $ Left $ errorResponse STECodeUsed
|
|
| maybe False (now >=) expiresAt -> pure $ Left $ errorResponse STECodeExpired
|
|
| otherwise -> pure $ Right c
|
|
|
|
-- | Every refusal is answered before anything is written, so it leaves the receipt unclaimed; and
|
|
-- nothing is written until the credential is signed.
|
|
purchaseWithReceipt :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeMasterKey -> StoreReceipt -> IO StoreServiceResponse
|
|
purchaseWithReceipt key cc purchaseKey masterKey StoreReceipt {txRef = StoreTransactionRef {provider, transactionRef = providerRef}, verifyReceipt, acknowledgeReceipt} =
|
|
withDB' "getStorePayment" cc (\db -> getStorePaymentCredit db provider providerRef) >>= \case
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right credit
|
|
-- only the key it credited can ask, and it is told only what it was given, so the store is not asked
|
|
| creditedBy credit -> answerCredit credit
|
|
| otherwise ->
|
|
verifyReceipt >>= \case
|
|
Left refusal -> storeRefusalResponse refusal
|
|
-- the claim was read from the evidence before it was verified, so it must name the transaction the store vouched for
|
|
Right VerifiedStoreTransaction {transactionRef}
|
|
| transactionRef /= providerRef -> storeRefusalResponse $ SRVerifierFailed "verified a transaction other than the one claimed"
|
|
Right VerifiedStoreTransaction {quantity}
|
|
| quantity /= 1 -> storeRefusalResponse $ SRVerifierFailed $ "verified a quantity of " <> tshow quantity
|
|
Right VerifiedStoreTransaction {testPurchase = True} -> storeRefusalResponse SRTestPurchase
|
|
-- another key's credit is told only once the store vouched for the receipt, or it would reveal which transactions were credited
|
|
Right tx -> case credit of
|
|
Uncredited -> newPurchase tx
|
|
_ -> answerCredit credit
|
|
where
|
|
creditedBy = \case
|
|
Credited CreditedPurchase {purchaseKey = k} -> k == purchaseKey
|
|
_ -> False
|
|
answerCredit credit =
|
|
withDB' "answerStoreCredit" cc (\db -> creditedResponse db purchaseKey credit) <&> \case
|
|
Right (Left resp) -> resp
|
|
_ -> errorResponse STEInternal
|
|
-- the product is read only for a receipt not yet credited, so retiring it leaves its replays answered
|
|
newPurchase tx@VerifiedStoreTransaction {productId, paid} = case storeProduct provider productId of
|
|
Nothing -> pure $ errorResponse STEProductUnavailable
|
|
Just StoreProduct {badgeType, months} ->
|
|
maybe (pure $ Right ()) ($ tx) acknowledgeReceipt >>= \case
|
|
Left refusal -> storeRefusalResponse refusal
|
|
Right () -> do
|
|
now <- badgeNow cc
|
|
signFirstMonth key cc masterKey badgeType months (SCPayment Nothing) now >>= \case
|
|
Left resp -> pure resp
|
|
Right firstMonth -> do
|
|
paymentId <- randomId cc
|
|
r <- withDB "writeStorePurchase" cc $ \db ->
|
|
liftIO (createStorePurchase db NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey, badgeType} now) >>= \case
|
|
-- Credited to another key, or to this one by a request that ran alongside it, while signing.
|
|
Nothing ->
|
|
liftIO (getStorePaymentCredit db provider providerRef >>= creditedResponse db purchaseKey) >>= \case
|
|
Left resp -> pure resp
|
|
Right () -> logError ("store service: a payment for " <> providerRef <> " exists but funds no purchase") $> errorResponse STEInternal
|
|
Just purchaseId -> liftIO $ firstMonthResponse db purchaseId (Just paymentId) firstMonth
|
|
pure $ fromRight (errorResponse STEInternal) r
|
|
|
|
-- | A forged receipt and a refunded one are both receipt_invalid, so the client learns nothing a
|
|
-- forger could iterate on. A test purchase is named, since the device that made it already knows.
|
|
storeRefusalResponse :: StoreRefusal -> IO StoreServiceResponse
|
|
storeRefusalResponse = \case
|
|
SRInvalid reason -> logInfo ("store receipt refused: " <> reason) $> errorResponse STEReceiptInvalid
|
|
SRTestPurchase -> logInfo "store receipt refused: test purchase" $> errorResponse STETestPurchase
|
|
SRPending -> pure $ errorResponse STEPaymentPending
|
|
SRUnreachable reason -> logWarn ("store unreachable: " <> reason) $> errorResponse STEProviderUnavailable
|
|
SRVerifierFailed reason -> logError ("store receipt not verified: " <> reason) $> errorResponse STEInternal
|
|
SRNotConfigured -> logWarn "store receipt refused: no verifier for this store is configured" $> errorResponse STEProviderNotConfigured
|
|
|
|
creditedResponse :: DB.Connection -> C.PublicKeyEd25519 -> FundingCredit -> IO (Either StoreServiceResponse ())
|
|
creditedResponse db purchaseKey = \case
|
|
Uncredited -> pure $ Right ()
|
|
CreditedUnreadable -> pure $ Left $ errorResponse STEInternal
|
|
Credited CreditedPurchase {purchaseKey = k, badgePurchaseId, credential}
|
|
| k /= purchaseKey -> pure $ Left $ errorResponse STEReceiptUsed
|
|
| otherwise ->
|
|
maybe (Left $ errorResponse STEInternal) (Left . credentialResponse (Just credential) Nothing)
|
|
<$> getLedgerEntries db badgePurchaseId 0
|
|
|
|
signFirstMonth :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> BadgeType -> Int -> StatementCreditType -> UTCTime -> IO (Either StoreServiceResponse (StatementEntry, (StatementEntry, BadgeCredential)))
|
|
signFirstMonth key cc masterKey badgeType months credit now = do
|
|
(grantUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc
|
|
-- TODO [badges] a top-up grants onto an existing ledger, and must lapse before it or the
|
|
-- months it adds are counted from a start already in the past
|
|
let granted = grantEntry now grantUuid months credit $ emptyEntry now badgeType
|
|
-- A grant of at least one month starting now always has a month to issue.
|
|
case issueEntry now issueUuid granted of
|
|
Nothing -> pure $ Left $ errorResponse STEInternal
|
|
Just issued ->
|
|
credentialForEntry key masterKey issued >>= \case
|
|
Left e -> logError ("store service signing failed: " <> T.pack e) $> Left (errorResponse STEInternal)
|
|
Right signed -> pure $ Right (granted, signed)
|
|
|
|
firstMonthResponse :: DB.Connection -> Int64 -> Maybe T.Text -> (StatementEntry, (StatementEntry, BadgeCredential)) -> IO StoreServiceResponse
|
|
firstMonthResponse db purchaseId creditPaymentId_ (granted, signed) = do
|
|
appendLedgerPlan db purchaseId creditPaymentId_ [granted] $ Just $ issuanceAfter granted signed
|
|
entries_ <- getLedgerEntries db purchaseId 0
|
|
pure $ maybe (errorResponse STEInternal) (credentialResponse (Just $ snd signed) Nothing) entries_
|
|
|
|
-- | The purchase is reached through the verified signer key and no other way.
|
|
issueBadgeCmd :: BadgeIssuerKey -> ChatController -> C.PublicKeyEd25519 -> BadgeBalance -> IO StoreServiceResponse
|
|
issueBadgeCmd key cc purchaseKey BadgeBalance {lastEntry} = do
|
|
now <- badgeNow cc
|
|
purchase_ <- withDB' "getBadgePurchase" cc $ \db -> do
|
|
p_ <- getPurchaseByKey db purchaseKey
|
|
forM p_ $ \p@ServicePurchase {badgePurchaseId} -> (p,) <$> getLedgerTip db badgePurchaseId
|
|
case purchase_ of
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right Nothing -> pure $ errorResponse STEUnknownPurchaseKey
|
|
Right (Just (ServicePurchase {badgePurchaseId, masterKey, badgeType}, tip)) -> do
|
|
(lapseUuid, issueUuid) <- (,) <$> randomId cc <*> randomId cc
|
|
let tipEntry = fromMaybe (emptyEntry now badgeType) tip
|
|
lapsed = lapseEntry now lapseUuid tipEntry
|
|
current = fromMaybe tipEntry lapsed
|
|
case issueEntry now issueUuid current of
|
|
Nothing -> writeIssued badgePurchaseId tip (maybeToList lapsed) now Nothing
|
|
Just e ->
|
|
credentialForEntry key masterKey e >>= \case
|
|
Left err -> logError ("store service signing failed: " <> T.pack err) $> errorResponse STEInternal
|
|
Right signed ->
|
|
writeIssued badgePurchaseId tip (maybeToList lapsed) now $ Just $ issuanceAfter current signed
|
|
where
|
|
-- Write only if the tip has not moved, since another request may have advanced it.
|
|
writeIssued purchaseId tip rows t issuance_ = do
|
|
r <- withDB "issueBadge" cc $ \db -> liftIO $ do
|
|
tip' <- getLedgerTip db purchaseId
|
|
when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId Nothing rows issuance_
|
|
issueResponse db purchaseId t
|
|
pure $ fromRight (errorResponse STEInternal) r
|
|
-- Only the asserted entry's identity is read, never the months it claims.
|
|
issueResponse db purchaseId t = do
|
|
let StatementEntry {entryId = assertedUuid} = lastEntry
|
|
assertedId <- getLedgerEntryId db purchaseId assertedUuid
|
|
-- TODO [badges] when the assertion does not resolve, heal the ledger and restate it as a
|
|
-- single opening credit, rather than resending the whole history
|
|
entries_ <- getLedgerEntries db purchaseId (fromMaybe 0 assertedId)
|
|
credential_ <- getCurrentIssuance db purchaseId t
|
|
pure $ maybe (errorResponse STEInternal) (credentialResponse credential_ (assertedUuid <$ assertedId)) entries_
|