mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 15:07:32 +00:00
1317 lines
81 KiB
Haskell
1317 lines
81 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,
|
|
RequestLane (..),
|
|
requestLane,
|
|
PurchaseChecks (..),
|
|
ClaimChecks,
|
|
ClaimKey (..),
|
|
ClaimPurpose (..),
|
|
newPurchaseChecks,
|
|
purchaseCheckWait,
|
|
maxPurchaseChecks,
|
|
PlayReads,
|
|
playReadWait,
|
|
maxPlayPurchaseReads,
|
|
badgeErrorRetryAfter,
|
|
grantableCharge,
|
|
NotGranted (..),
|
|
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, prepaidCodeP, 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 (appleSubscriptionChecker, readAppleRoot, verifyAppleTransaction)
|
|
import StoreService.StoreReceipts.Google (PlayVerifier (..), playStoreVerifier)
|
|
import StoreService.StoreReceipts.Mock (newMockStoreVerifier)
|
|
import StoreService.Waiters (Waiters, newWaiters)
|
|
import StoreService.Web.Http (Bucket, takeFrom)
|
|
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, isNothing, maybeToList)
|
|
import qualified Data.Text as T
|
|
import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, 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.StorePlan (storePlan)
|
|
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 (AppleTransactionFacts (..), ServicePayment (..), appleTransactionFacts)
|
|
import Simplex.Chat.PaymentService.Types (InvoiceId (..), PaymentProvider (..), 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 >>= \k@BadgeIssuerKey {keyIdx} -> logInfo ("signing badges with issuer key " <> tshow keyIdx) $> k
|
|
|
|
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
|
|
|
|
-- | Only one of the mock and the real verifiers applies: the config refuses
|
|
-- accept_unverified_store_receipts beside [apple] or [google]. The other two dev settings,
|
|
-- credit_test_purchases and check_store_during_coverage, are meant to sit beside a real verifier.
|
|
configureStoreVerifier :: Maybe ServiceConfig -> ServiceState -> IO ServiceState
|
|
configureStoreVerifier serviceCfg env@ServiceState {serviceCC, storeVerifier = v} = case serviceCfg of
|
|
Just ServiceConfig {devAcceptUnverifiedStoreReceipts = True, devCheckStoreDuringCoverage} -> do
|
|
logWarn "[dev] accept_unverified_store_receipts is on: any store receipt is accepted without verification"
|
|
-- the badge clock is read at each check, as the controller that holds it starts after this
|
|
mock <- newMockStoreVerifier $ atomically (readTMVar serviceCC) >>= badgeNow
|
|
pure env {storeVerifier = mock {storeChecks = if devCheckStoreDuringCoverage then ChecksAtEveryRequest else ChecksAfterCoverage}}
|
|
Just ServiceConfig {appleStore, playStore, devCheckStoreDuringCoverage, devCreditTestPurchases} -> do
|
|
when devCreditTestPurchases $ logWarn "[dev] credit_test_purchases is on: a purchase the store calls a test is credited as a real one"
|
|
apple <- forM appleStore $ \AppleStoreConfig {aBundleId, aRootCertificates, aServerApi} -> do
|
|
roots <- mapM ((>>= orExit) . readAppleRoot) aRootCertificates
|
|
appleSubscriptions <- forM aServerApi $ (>>= orExit) . appleSubscriptionChecker roots aBundleId
|
|
pure (verifyAppleTransaction roots aBundleId, appleSubscriptions)
|
|
google <- forM playStore $ (>>= orExit) . playStoreVerifier
|
|
forM_ appleStore $ \AppleStoreConfig {aBundleId, aServerApi} ->
|
|
logInfo $ "verifying App Store receipts for " <> aBundleId <> maybe ", but not its subscriptions: [apple] sets no API key" (const "") aServerApi
|
|
forM_ playStore $ \PlayStoreConfig {gPackageName} -> logInfo $ "verifying Play purchases for " <> gPackageName
|
|
pure
|
|
env
|
|
{ storeVerifier =
|
|
v
|
|
{ verifyApple = maybe (verifyApple v) (Just . fst) apple,
|
|
verifyGoogle = maybe (verifyGoogle v) (Just . verifyPlayPurchase) google,
|
|
acknowledgeGoogle = maybe (acknowledgeGoogle v) (Just . acknowledgePlayPurchase) google,
|
|
acknowledgeGoogleSubscription = maybe (acknowledgeGoogleSubscription v) (Just . acknowledgePlaySubscription) google,
|
|
checkSubscription = case (snd =<< apple, checkPlaySubscription <$> google) of
|
|
(Nothing, Nothing) -> checkSubscription v
|
|
(checkApple_, checkGoogle_) -> Just $ \ref _ -> case ref of
|
|
SubscriptionApple originalId -> maybe notConfigured ($ originalId) checkApple_
|
|
SubscriptionGoogle token -> maybe notConfigured ($ token) checkGoogle_,
|
|
storeChecks = if devCheckStoreDuringCoverage && devCreditTestPurchases then ChecksAtEveryRequest else ChecksAfterCoverage,
|
|
creditTestPurchases = devCreditTestPurchases
|
|
}
|
|
}
|
|
Nothing -> pure env
|
|
where
|
|
notConfigured = pure $ Left SRNotConfigured
|
|
|
|
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
|
|
namesOn = isJust (serviceCfg >>= names)
|
|
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 turns namesOn (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 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, nCodesOnly, 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 <> codesOnlyNote
|
|
newNamesEnv cc userId nc chatStore turns waiters providers
|
|
where
|
|
codesOnlyNote = if nCodesOnly then ", codes only: registration needs a prepaid code" else ""
|
|
|
|
-- | 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)
|
|
|
|
-- | 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
|
|
-- an order its invoice paid is credited only the extra of an overpayment
|
|
kind = if cents > 0 then " overpaid" else " late"
|
|
in [dollars cents <> by | cents > 0] <> [dollars late <> kind <> 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 store call can take verifyTimeout, and a Play hand-over two, so a request that may make one waits on a lane of its own, and renewal checks on one no purchase can fill
|
|
purchaseQ <- newTQueueIO
|
|
renewalQ <- newTQueueIO
|
|
checks <- newPurchaseChecks
|
|
let answer (u, reqId, sigKey, reqData) = handleServiceRequest key (storeVerifier env) checks cc trackerQ_ u reqId sigKey reqData
|
|
route req@(_, _, sigKey, reqData) =
|
|
requestLane (storeChecks $ storeVerifier env) cc sigKey reqData >>= \case
|
|
MainLane -> answer req
|
|
PurchaseLane -> atomically $ writeTQueue purchaseQ req
|
|
RenewalLane -> atomically $ writeTQueue renewalQ req
|
|
lane q act = forever $ do
|
|
req@(_, reqId, _, _) <- atomically $ readTQueue q
|
|
survive ("request " <> safeDecodeUtf8 (strEncode reqId)) $ act req
|
|
raceAny_ [lane (serviceRequestQ env) route, lane purchaseQ answer, lane renewalQ answer]
|
|
|
|
data RequestLane = MainLane | PurchaseLane | RenewalLane
|
|
deriving (Eq, Show)
|
|
|
|
-- | Store calls wait off the main lane, so appends to one ledger come from more than one lane: each takes its
|
|
-- purchase's row lock before re-reading the tip, so the lanes may run concurrently at any --pool-size.
|
|
requestLane :: StoreChecks -> ChatController -> Maybe C.PublicKeyEd25519 -> J.Object -> IO RequestLane
|
|
requestLane checks cc sigKey reqData = case J.fromJSON (J.Object reqData) of
|
|
J.Success StoreServiceRequest {request = STCPurchaseBadge {payment = SPGoogle {}, upgrade = Nothing}} -> pure PurchaseLane
|
|
J.Success StoreServiceRequest {request = STCPurchaseBadge {payment = SPApple {jws}, upgrade = Nothing}}
|
|
| maybe False autoRenewable (appleTransactionFacts jws) -> pure PurchaseLane
|
|
J.Success StoreServiceRequest {request = STCTransferBadge {payment = SPApple {jws}}}
|
|
| maybe False autoRenewable (appleTransactionFacts jws) -> pure PurchaseLane
|
|
J.Success StoreServiceRequest {request = STCTransferBadge {payment = SPGoogle {}}} -> pure PurchaseLane
|
|
J.Success StoreServiceRequest {purchaseKey = Just k, request = STCIssueBadge {}}
|
|
| sigKey == Just k -> do
|
|
now <- badgeNow cc
|
|
withDB' "requestLane" cc (\db -> getIssuePurchase db k) <&> \case
|
|
-- a due month goes to the serial renewal lane even within the hour, so it queues behind a check still in flight
|
|
Right (Just (_, Just tip, Just _))
|
|
| checks == ChecksAtEveryRequest || balanceStartTs tip <= addUTCTime routeLookahead now -> RenewalLane
|
|
Right _ -> MainLane
|
|
-- a read that failed may hide a due check, and the main lane is what must not wait on a store
|
|
Left _ -> RenewalLane
|
|
J.Success StoreServiceRequest {purchaseKey = Just k, request = STCUpgradeBadgeSubscription {}}
|
|
| sigKey == Just k -> do
|
|
now <- badgeNow cc
|
|
withDB' "requestLane" cc (\db -> getIssuePurchase db k) <&> \case
|
|
Right (Just (_, Just _, Just subscription@PurchaseSubscription {subscriptionRef = SubscriptionApple _}))
|
|
| isNothing $ checkWait (addUTCTime routeLookahead now) subscription -> RenewalLane
|
|
Right _ -> MainLane
|
|
Left _ -> RenewalLane
|
|
_ -> pure MainLane
|
|
|
|
-- | The router judges a minute ahead, so a handler reading the clock moments later finds due only what the router routed off the main lane.
|
|
routeLookahead :: NominalDiffTime
|
|
routeLookahead = 60
|
|
|
|
-- | 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 -> PurchaseChecks -> ChatController -> Maybe (TQueue GroupEvent) -> User -> AgentInvId -> Maybe C.PublicKeyEd25519 -> J.Object -> IO ()
|
|
handleServiceRequest key verifier checks cc trackerQ_ User {userId} reqId sigKey reqData = do
|
|
let reqIdT = safeDecodeUtf8 (strEncode reqId)
|
|
logInfo $ "store service request " <> reqIdT
|
|
resp <-
|
|
storeServiceResponse key verifier checks cc trackerQ_ sigKey reqData `catchOwn'` \_ ->
|
|
logError ("store service request " <> reqIdT <> " failed") $> errorResponse STEInternal
|
|
logInfo $ "badge answer: " <> responseName resp
|
|
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 -> PurchaseChecks -> ChatController -> Maybe (TQueue GroupEvent) -> Maybe C.PublicKeyEd25519 -> J.Object -> IO StoreServiceResponse
|
|
storeServiceResponse key verifier checks 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 -> logInfo ("badge request: " <> commandName request) >> 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 verifier checks RUPurchase 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 verifier k Nothing balance
|
|
Nothing -> pure $ errorResponse STEBadRequest
|
|
-- only an App Store subscription changes tier on the same transaction; Play gives a change a new token
|
|
STCUpgradeBadgeSubscription {payment = payment@SPApple {}, balance}
|
|
| Just k <- purchaseKey,
|
|
Just receipt_ <- toStoreReceipt verifier payment ->
|
|
either storeRefusalResponse (upgradeSubscriptionCmd key cc verifier k balance) receipt_
|
|
STCUpgradeBadgeSubscription {} -> pure $ errorResponse STEBadRequest
|
|
STCTransferBadge {masterKey, payment}
|
|
| Just k <- purchaseKey,
|
|
Just receipt_ <- toStoreReceipt verifier payment ->
|
|
either storeRefusalResponse (purchaseWithReceipt key cc verifier checks (RUTransfer $ presentedTransaction payment) k masterKey) receipt_
|
|
STCTransferBadge {} -> 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 -> Maybe SubscriptionState -> [StatementEntry] -> StoreServiceResponse
|
|
credentialResponse credential previousEntryId subscription entries =
|
|
STPBadgeCredential {credential, receipt = Nothing, statement = BadgeStatement {entries, previousEntryId}, subscription}
|
|
|
|
-- | 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 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 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
|
|
|
|
-- | What a store receipt is presented for. A transfer carries the Apple transaction the device presented,
|
|
-- as only the subscription's latest one moves it.
|
|
data ReceiptUse = RUPurchase | RUTransfer (Maybe T.Text)
|
|
|
|
presentedTransaction :: ServicePayment -> Maybe T.Text
|
|
presentedTransaction = \case
|
|
SPApple {jws} -> (\AppleTransactionFacts {transactionId} -> transactionId) <$> appleTransactionFacts jws
|
|
_ -> Nothing
|
|
|
|
-- | 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 -> StoreVerifier -> PurchaseChecks -> ReceiptUse -> C.PublicKeyEd25519 -> BadgeMasterKey -> StoreReceipt -> IO StoreServiceResponse
|
|
purchaseWithReceipt key cc verifier checks use purchaseKey masterKey StoreReceipt {txRef = StoreTransactionRef {provider, transactionRef = providerRef}, receiptProductId, subscriptionRef, verifyReceipt, acknowledgeReceipt, acknowledgeSubscription} =
|
|
withDB' "getStorePayment" cc (\db -> (,) <$> getStorePaymentCredit db provider providerRef <*> keyHoldsPurchase db) >>= \case
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right (credit, keyHeld)
|
|
-- 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
|
|
-- a payment moves only to a key with no purchase, so one transferOut alone names the purchase holding it
|
|
| keyHeld -> pure $ errorResponse STEBadRequest
|
|
-- a Play subscription is verified by asking about the subscription itself
|
|
| Just StoreSubscription <- storeProduct provider =<< receiptProductId -> purchaseSubscription receiptProductId 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}
|
|
| not (creditTestPurchases verifier) -> 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@VerifiedStoreTransaction {productId} -> case credit of
|
|
Uncredited -> newPurchase tx
|
|
_
|
|
| RUTransfer _ <- use,
|
|
Just StoreSubscription <- storeProduct provider productId ->
|
|
purchaseSubscription (Just productId) credit
|
|
_ -> answerCredit credit
|
|
where
|
|
keyHoldsPurchase db = case use of
|
|
RUPurchase -> pure False
|
|
RUTransfer _ -> purchaseKeyExists db purchaseKey
|
|
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, testPurchase} = case storeProduct provider productId of
|
|
Nothing -> pure $ errorResponse STEProductUnavailable
|
|
-- an Apple subscription, whose JWS was verified offline
|
|
Just StoreSubscription -> purchaseSubscription (Just productId) Uncredited
|
|
Just StoreOneTime {badgeType, months} -> do
|
|
when testPurchase $ logWarn "[dev] crediting a store test purchase as paid: credit_test_purchases is on"
|
|
acknowledged (($ tx) <$> acknowledgeReceipt) $ do
|
|
now <- badgeNow cc
|
|
signFirstMonth key cc masterKey badgeType months (SCPayment Nothing) now >>= \case
|
|
Left resp -> pure resp
|
|
Right firstMonth ->
|
|
claimPurchase now (\paymentId -> NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey, badgeType, subscriptionState = Nothing, providerToken = Nothing}) $
|
|
\db purchaseId paymentId -> firstMonthResponse db purchaseId (Just paymentId) Nothing firstMonth
|
|
-- another key's credit is told only once the store vouched for the subscription, as for a one-time purchase;
|
|
-- the store is asked at most once an interval per subscription and per purpose, whoever presents it
|
|
purchaseSubscription product_ credit = do
|
|
now <- badgeNow cc
|
|
atomically (subscriptionAsk now) >>= \case
|
|
ClaimHeld remaining -> pure STPError {code = STEPaymentPending, message = Nothing, retryAfter = Just (ceiling remaining)}
|
|
NoPlayRead wait -> do
|
|
logWarn "Play subscription read refused: the purchase path's reads for this minute are used"
|
|
pure STPError {code = STERateLimited, message = Nothing, retryAfter = Just (ceiling wait)}
|
|
AskStore ->
|
|
checkStoreSubscription verifier subscriptionRef product_ >>= \case
|
|
-- no store was asked, so the claim is not held off
|
|
Left SRNotConfigured -> atomically (modifyTVar' claimChecks $ M.delete claimKey) >> storeRefusalResponse SRNotConfigured
|
|
Left refusal -> atLeastCheckInterval <$> storeRefusalResponse refusal
|
|
Right checked -> atLeastCheckInterval <$> case (credit, use) of
|
|
(Uncredited, _) -> newSubscription checked
|
|
(Credited from, RUTransfer presented) -> moveSubscription presented from checked
|
|
_ -> answerCredit credit
|
|
subscriptionAsk now =
|
|
purchaseCheckWait claimChecks now claimKey >>= \case
|
|
Just remaining -> pure $ ClaimHeld remaining
|
|
Nothing ->
|
|
maybe (pure Nothing) (`playReadWait` now) playReads_ >>= \case
|
|
Nothing -> pure AskStore
|
|
-- a claim refused a read is not held, as Play was not asked
|
|
Just wait -> modifyTVar' claimChecks (M.delete claimKey) $> NoPlayRead wait
|
|
claimKey = ClaimKey {claimRef = providerRef, claimPurpose}
|
|
claimPurpose = case use of
|
|
RUPurchase -> ClaimPurchase
|
|
RUTransfer _ -> ClaimTransfer
|
|
(claimChecks, playReads_) = case subscriptionRef of
|
|
SubscriptionApple _ -> (appleChecks checks, Nothing)
|
|
SubscriptionGoogle _ -> (googleChecks checks, Just $ playReads checks)
|
|
newSubscription checked@SubscriptionCheck {subscriptionState} = do
|
|
now <- badgeNow cc
|
|
case grantableCharge provider now checked >>= fundsMonthNow purchaseKey now Nothing of
|
|
Left notGranted -> firstChargeNotGranted provider notGranted
|
|
Right (charge@StoreCharge {periodStart, chargePaid = paid}, (badgeType, months)) ->
|
|
acknowledged acknowledgeSubscription $ do
|
|
chargeId <- randomId cc
|
|
entryIds <- (,,,) <$> randomId cc <*> randomId cc <*> randomId cc <*> randomId cc
|
|
let seed = emptyEntry periodStart badgeType
|
|
signMonthAfter key cc masterKey now seed (chargeEntries periodStart now badgeType months purchaseKey chargeId entryIds seed) >>= \case
|
|
Left resp -> pure resp
|
|
Right firstMonth ->
|
|
claimPurchase now (\paymentId -> NewStorePurchase {paymentId, provider, providerRef, paid, purchaseKey, masterKey, badgeType, subscriptionState = Just subscriptionState, providerToken}) $
|
|
\db purchaseId paymentId -> do
|
|
insertSubscriptionCharge db paymentId chargeId charge now
|
|
firstMonthResponse db purchaseId Nothing (Just subscriptionState) firstMonth
|
|
moveSubscription presented CreditedPurchase {badgePurchaseId = fromPurchaseId} checked@SubscriptionCheck {subscriptionState, latestCharge}
|
|
-- an older transaction stops moving the subscription once the store charges again, so a leaked receipt lapses
|
|
| SubscriptionApple _ <- subscriptionRef,
|
|
presented /= ((\StoreCharge {chargeRef} -> chargeRef) <$> latestCharge) =
|
|
pure $ errorResponse STEPaymentPending
|
|
| otherwise = do
|
|
now <- badgeNow cc
|
|
withDB' "getMovedPayment" cc (\db -> getPurchaseSubscription db fromPurchaseId >>= mapM (movedPayment db)) >>= \case
|
|
Right (Just (paymentId, movedFromExpiry_, Just fromTip))
|
|
-- A store receipt proves control of the store account but is no one-time secret, so this is the only bound on moves:
|
|
-- waiting out the credential of the key the payment last moved from keeps each paid month with at most two keys.
|
|
| maybe False (now <) movedFromExpiry_ -> pure $ errorResponse STETransferLimited
|
|
| otherwise -> case grantableCharge provider now checked >>= fundsMonthNow purchaseKey now Nothing of
|
|
Left notGranted -> firstChargeNotGranted provider notGranted
|
|
Right grant -> movePayment now paymentId fromTip grant
|
|
_ -> pure $ errorResponse STEInternal
|
|
where
|
|
movedPayment db PurchaseSubscription {paymentId} = (paymentId,,) <$> getMovedFromExpiry db paymentId <*> getLedgerTip db fromPurchaseId
|
|
movePayment now paymentId fromTip (charge@StoreCharge {chargeRef, periodStart}, (badgeType, months)) =
|
|
withDB' "getSubscriptionCharge" cc (\db -> getSubscriptionCharge db paymentId chargeRef) >>= \case
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right recorded -> do
|
|
chargeId <- maybe (randomId cc) (pure . fst) recorded
|
|
entryIds <- (,,,) <$> randomId cc <*> randomId cc <*> randomId cc <*> randomId cc
|
|
(lapseId, transferId) <- (,) <$> randomId cc <*> randomId cc
|
|
let seed = emptyEntry periodStart badgeType
|
|
lapsed = lapseEntry now lapseId fromTip
|
|
fromRows = maybeToList lapsed <> [debitEntry now transferId (SDTransferOut purchaseKey) (fromMaybe fromTip lapsed)]
|
|
signMonthAfter key cc masterKey now seed (chargeEntries periodStart now badgeType months purchaseKey chargeId entryIds seed) >>= \case
|
|
Left resp -> pure resp
|
|
Right firstMonth -> do
|
|
r <- withDB "transferStorePurchase" cc $ \db -> liftIO $
|
|
rebindStorePayment db PaymentRebind {paymentId, fromPurchaseId, fromTipId = entryId fromTip, fromRows, purchaseKey, masterKey, badgeType} now >>= \case
|
|
Nothing -> pure Nothing
|
|
Just purchaseId -> do
|
|
insertSubscriptionCharge db paymentId chargeId charge now
|
|
setSubscriptionState db paymentId subscriptionState now
|
|
Just <$> firstMonthResponse db purchaseId Nothing (Just subscriptionState) firstMonth
|
|
case r of
|
|
Right (Just resp) -> logInfo "store subscription moved to another key" $> resp
|
|
Right Nothing -> logError "store service: a subscription moved, or its ledger changed, while its transfer was signed" $> errorResponse STEInternal
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
providerToken = case subscriptionRef of
|
|
SubscriptionGoogle token -> Just token
|
|
SubscriptionApple _ -> Nothing
|
|
acknowledged acknowledge_ crediting =
|
|
fromMaybe (pure $ Right ()) acknowledge_ >>= \case
|
|
Left refusal -> storeRefusalResponse refusal
|
|
Right () -> crediting
|
|
claimPurchase now purchaseFor writeCredit = do
|
|
paymentId <- randomId cc
|
|
r <- withDB "writeStorePurchase" cc $ \db ->
|
|
liftIO (createStorePurchase db (purchaseFor paymentId) 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 store payment exists but funds no purchase" $> errorResponse STEInternal
|
|
Just purchaseId -> liftIO $ writeCredit db purchaseId paymentId
|
|
pure $ fromRight (errorResponse STEInternal) r
|
|
|
|
-- | Why a store charge grants nothing.
|
|
data NotGranted
|
|
= NGEnded
|
|
| NGNoCharge
|
|
| NGMalformedPeriod
|
|
| NGRevoked
|
|
| NGOffer
|
|
| NGUnknownPlan
|
|
| NGNotStarted
|
|
| NGPeriodOver
|
|
deriving (Show)
|
|
|
|
-- | The grant rule of a first purchase and a renewal alike, but for whether a ledger row already references
|
|
-- the charge, which is the caller's to check. It grants the tier of the plan the store charged, which may not be the ledger's.
|
|
grantableCharge :: PaymentProvider -> UTCTime -> SubscriptionCheck -> Either NotGranted (StoreCharge, (BadgeType, Int))
|
|
grantableCharge provider now SubscriptionCheck {subscriptionState, latestCharge}
|
|
| subscriptionState == SSEnded = Left NGEnded
|
|
| otherwise = case latestCharge of
|
|
Nothing -> Left NGNoCharge
|
|
-- the plan first: Play derives a period from the plan's length, so a plan it does not know has none
|
|
Just charge@StoreCharge {planId, periodStart, periodEnd, revoked, offer} -> case storePlan provider planId of
|
|
Nothing -> Left NGUnknownPlan
|
|
Just plan
|
|
| periodEnd <= periodStart -> Left NGMalformedPeriod
|
|
| revoked -> Left NGRevoked
|
|
| offer -> Left NGOffer
|
|
| now < periodStart -> Left NGNotStarted
|
|
| now >= periodEnd -> Left NGPeriodOver
|
|
| otherwise -> Right (charge, plan)
|
|
|
|
-- | Play dates a period from its end less the plan's months, clipped at month ends, so the months the ledger grants
|
|
-- from that start can end before Play's period does: a charge still in its period may then fund no month now.
|
|
fundsMonthNow :: C.PublicKeyEd25519 -> UTCTime -> Maybe StatementEntry -> (StoreCharge, (BadgeType, Int)) -> Either NotGranted (StoreCharge, (BadgeType, Int))
|
|
fundsMonthNow purchaseKey now tip_ grant@(StoreCharge {periodStart}, (badgeType, months))
|
|
| isJust $ issueEntry now "" $ last $ tip : chargeEntries periodStart now badgeType months purchaseKey "" ("", "", "", "") tip = Right grant
|
|
| otherwise = Left NGPeriodOver
|
|
where
|
|
tip = fromMaybe (emptyEntry periodStart badgeType) tip_
|
|
|
|
-- | The store ended the subscription, or revoked the latest charge it reports, one this payment was granted: a refund or a reversal.
|
|
coverageEnded :: DB.Connection -> T.Text -> SubscriptionCheck -> IO Bool
|
|
coverageEnded db paymentId SubscriptionCheck {subscriptionState, latestCharge}
|
|
| subscriptionState == SSEnded = pure True
|
|
| Just StoreCharge {chargeRef, revoked = True} <- latestCharge = maybe False snd <$> getSubscriptionCharge db paymentId chargeRef
|
|
| otherwise = pure False
|
|
|
|
-- | No payment row exists yet to stamp, so a first purchase's checks are bounded by the purchase checks.
|
|
firstChargeNotGranted :: PaymentProvider -> NotGranted -> IO StoreServiceResponse
|
|
firstChargeNotGranted provider = \case
|
|
NGMalformedPeriod -> storeRefusalResponse $ SRVerifierFailed "a charge whose period does not end after its start"
|
|
-- Play issues a new token when an ended subscription is bought again, so this one can never be credited
|
|
NGEnded | PPGoogle <- provider -> storeRefusalResponse $ SRInvalid "the subscription ended"
|
|
NGNotStarted -> storeRefusalResponse SRPending
|
|
-- a later charge, or an Apple resubscription under the same original id, may yet be credited from this receipt
|
|
reason -> logInfo ("store subscription not credited yet: " <> tshow reason) $> STPError {code = STEPaymentPending, message = Nothing, retryAfter = Just 86400}
|
|
|
|
-- | What each request was answered, to pair with its command in a log. No payload: a credential,
|
|
-- an invoice and a statement are the client's, and an error's message can quote what it refused.
|
|
responseName :: StoreServiceResponse -> T.Text
|
|
responseName = \case
|
|
STPBadgeCatalog {} -> "catalog"
|
|
STPBadgeInvoice {} -> "invoice"
|
|
STPBadgeCredential {credential = Just _, subscription} ->
|
|
"badge issued" <> maybe "" (\s -> ", subscription " <> tshow s) subscription
|
|
STPBadgeCredential {credential = Nothing} -> "no month to issue"
|
|
STPError {code} -> "error " <> tshow code
|
|
|
|
-- | The command each request ran, so a log can be followed without the request ids. No payload:
|
|
-- a payment holds a store receipt, and an upgrade a receipt code.
|
|
commandName :: StoreServiceCommand -> T.Text
|
|
commandName = \case
|
|
STCGetBadgeCatalog -> "getBadgeCatalog"
|
|
STCGetBadgeInvoice {} -> "getBadgeInvoice"
|
|
STCRedeemBadgeCode {} -> "redeemBadgeCode"
|
|
STCPurchaseBadge {payment} -> "purchaseBadge " <> paymentName payment
|
|
STCUpgradeBadgeSubscription {payment} -> "upgradeBadgeSubscription " <> paymentName payment
|
|
STCTransferBadge {payment} -> "transferBadge " <> paymentName payment
|
|
STCIssueBadge {} -> "issueBadge"
|
|
STCPauseBadge -> "pauseBadge"
|
|
where
|
|
paymentName = \case
|
|
SPApple {} -> "(App Store)"
|
|
SPGoogle {} -> "(Play)"
|
|
SPInvoice {} -> "(invoice)"
|
|
SPReceipt {} -> "(receipt)"
|
|
|
|
-- | 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
|
|
-- warn, not info: every one of these ends a purchase for good, and one of them (a store that no longer
|
|
-- knows a subscription it once sold) can do that to a purchase the user really paid for
|
|
SRInvalid reason -> logWarn ("store receipt refused for good: " <> 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, subscription}
|
|
| k /= purchaseKey -> pure $ Left $ errorResponse STEReceiptUsed
|
|
| otherwise ->
|
|
maybe (Left $ errorResponse STEInternal) (Left . credentialResponse (Just credential) Nothing subscription)
|
|
<$> getLedgerEntries db badgePurchaseId 0
|
|
|
|
signFirstMonth :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> BadgeType -> Int -> StatementCreditType -> UTCTime -> IO (Either StoreServiceResponse ([StatementEntry], (StatementEntry, StatementEntry, BadgeCredential)))
|
|
signFirstMonth key cc masterKey badgeType months credit now = do
|
|
grantUuid <- 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 seed = emptyEntry now badgeType
|
|
signMonthAfter key cc masterKey now seed [grantEntry now grantUuid badgeType months credit seed]
|
|
|
|
-- | The rows that fund a month, after the entry they follow, with that month issued at now and signed.
|
|
signMonthAfter :: BadgeIssuerKey -> ChatController -> BadgeMasterKey -> UTCTime -> StatementEntry -> [StatementEntry] -> IO (Either StoreServiceResponse ([StatementEntry], (StatementEntry, StatementEntry, BadgeCredential)))
|
|
signMonthAfter key cc masterKey now previous rows = do
|
|
issueUuid <- randomId cc
|
|
let funded = last (previous : rows)
|
|
case issueEntry now issueUuid funded of
|
|
Nothing -> logError "store service: the rows written fund no month to issue" $> 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 (rows, issuanceAfter funded signed)
|
|
|
|
firstMonthResponse :: DB.Connection -> Int64 -> Maybe T.Text -> Maybe SubscriptionState -> ([StatementEntry], (StatementEntry, StatementEntry, BadgeCredential)) -> IO StoreServiceResponse
|
|
firstMonthResponse db purchaseId creditPaymentId_ subscription (rows, issuance@(_, _, credential)) = do
|
|
appendLedgerPlan db purchaseId creditPaymentId_ rows $ Just issuance
|
|
entries_ <- getLedgerEntries db purchaseId 0
|
|
pure $ maybe (errorResponse STEInternal) (credentialResponse (Just credential) Nothing subscription) entries_
|
|
|
|
-- | The subscription a device's transaction names, its product and that product's tier: what an upgrade waits for, never what it grants.
|
|
data SubscriptionUpgrade = SubscriptionUpgrade
|
|
{ upgradeRef :: T.Text,
|
|
upgradeProductId :: T.Text,
|
|
upgradeTier :: BadgeType
|
|
}
|
|
|
|
-- | Refused before the store is asked: a transaction that does not verify, or that is of no subscription product.
|
|
upgradeSubscriptionCmd :: BadgeIssuerKey -> ChatController -> StoreVerifier -> C.PublicKeyEd25519 -> BadgeBalance -> StoreReceipt -> IO StoreServiceResponse
|
|
upgradeSubscriptionCmd key cc verifier purchaseKey balance StoreReceipt {txRef = StoreTransactionRef {provider, transactionRef = claimedRef}, verifyReceipt} =
|
|
verifyReceipt >>= \case
|
|
Left refusal -> storeRefusalResponse refusal
|
|
Right VerifiedStoreTransaction {transactionRef}
|
|
| transactionRef /= claimedRef -> storeRefusalResponse $ SRVerifierFailed "verified a transaction other than the one claimed"
|
|
Right VerifiedStoreTransaction {productId} -> case storePlan provider productId of
|
|
Nothing -> pure $ errorResponse STEBadRequest
|
|
Just (tier, _) -> issueBadgeCmd key cc verifier purchaseKey (Just SubscriptionUpgrade {upgradeRef = claimedRef, upgradeProductId = productId, upgradeTier = tier}) balance
|
|
|
|
-- | The purchase is reached through the verified signer key and no other way.
|
|
issueBadgeCmd :: BadgeIssuerKey -> ChatController -> StoreVerifier -> C.PublicKeyEd25519 -> Maybe SubscriptionUpgrade -> BadgeBalance -> IO StoreServiceResponse
|
|
issueBadgeCmd key cc verifier purchaseKey upgrade_ BadgeBalance {lastEntry} = do
|
|
now <- badgeNow cc
|
|
purchase_ <- withDB' "getBadgePurchase" cc (\db -> getIssuePurchase db purchaseKey)
|
|
case purchase_ of
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right Nothing -> pure $ errorResponse STEUnknownPurchaseKey
|
|
Right (Just (purchase, Just tip, Just subscription))
|
|
| Just upgrade <- upgrade_ -> upgradeSubscription upgrade purchase tip subscription now
|
|
-- once a subscription's paid coverage has ended, only a new store charge funds another month
|
|
| paidThrough tip <= now -> renewSubscription Nothing purchase tip subscription now
|
|
| ChecksAtEveryRequest <- storeChecks verifier -> checkStoreDuringCoverage purchase tip subscription now
|
|
| balanceStartTs tip <= now -> checkStoreBeforeMonth purchase tip subscription now
|
|
Right (Just _) | isJust upgrade_ -> pure $ errorResponse STEBadRequest
|
|
Right (Just (ServicePurchase {badgePurchaseId}, Just StatementEntry {entryType = SEDebit SDTransferOut {}}, Nothing)) ->
|
|
answered "answerMovedOut" $ \db -> withoutCredential <$> issueResponse db badgePurchaseId now (Just SSEnded)
|
|
Right (Just (purchase, tip, subscription_)) -> issueDue purchase tip ((\PurchaseSubscription {storedState} -> storedState) <$> subscription_) now
|
|
where
|
|
issueDue ServicePurchase {badgePurchaseId, masterKey, badgeType} tip state_ now = 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 state_
|
|
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) state_
|
|
-- Write only if the tip has not moved, since another request may have advanced it.
|
|
writeIssued purchaseId tip rows t issuance_ state_ = do
|
|
r <- withDB "issueBadge" cc $ \db -> liftIO $ do
|
|
tip' <- lockedLedgerTip db purchaseId t
|
|
when (fmap entryId tip' == fmap entryId tip) $ appendLedgerPlan db purchaseId Nothing rows issuance_
|
|
issueResponse db purchaseId t state_
|
|
pure $ fromRight (errorResponse STEInternal) r
|
|
-- Answered pending until the tier the device saw is granted, so the old tier's credential is never taken for the new one.
|
|
upgradeSubscription upgrade@SubscriptionUpgrade {upgradeRef, upgradeTier} purchase@ServicePurchase {badgePurchaseId} tip@StatementEntry {balanceBadgeType} subscription@PurchaseSubscription {subscriptionRef, storedState} now
|
|
| not ownSubscription = pure $ errorResponse STEBadRequest
|
|
| balanceBadgeType == upgradeTier = answered "answerUpgraded" $ \db -> issueResponse db badgePurchaseId now (Just storedState)
|
|
| Just wait <- checkWait now subscription =
|
|
pure STPError {code = STEPaymentPending, message = Nothing, retryAfter = Just $ ceiling wait}
|
|
| otherwise = renewSubscription (Just upgrade) purchase tip subscription now
|
|
where
|
|
ownSubscription = case subscriptionRef of
|
|
SubscriptionApple ref -> ref == upgradeRef
|
|
SubscriptionGoogle _ -> False
|
|
-- Nothing but the store's answer is written until a charge is granted: a lapse at now would be dated
|
|
-- after a grant a later check may make at an earlier period start, and the client would read it as a pass that issued.
|
|
renewSubscription upgrading_ ServicePurchase {badgePurchaseId, masterKey} tip@StatementEntry {balanceBadgeType} subscription@PurchaseSubscription {paymentId, provider, subscriptionRef, storedState} now
|
|
| isNothing upgrading_ && not (subscriptionCheckDue (storeChecks verifier) now tip subscription) = answered "answerStoredSubscription" $ \db -> issueResponse db badgePurchaseId now (Just storedState)
|
|
| otherwise =
|
|
withDB' "stampSubscriptionCheck" cc (\db -> stampSubscriptionCheck db paymentId now) >>= \case
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right () ->
|
|
-- the stores ignore the raise's product; the development mock charges its plan from now on
|
|
checkStoreSubscription verifier subscriptionRef (upgradeProductId <$> upgrading_) >>= \case
|
|
Left refusal -> atLeastCheckInterval <$> storeRefusalResponse refusal
|
|
Right checked@SubscriptionCheck {subscriptionState, latestCharge} -> case grantableCharge provider now checked >>= fundsMonthNow purchaseKey now (Just tip) of
|
|
Left NGMalformedPeriod -> storeRefusalResponse $ SRVerifierFailed "a charge whose period does not end after its start"
|
|
Left reason -> do
|
|
forM_ latestCharge $ \_ -> logInfo $ "store subscription charge not granted: " <> tshow reason
|
|
recordCheck latestCharge subscriptionState
|
|
Right (charge@StoreCharge {chargeRef, periodStart}, (badgeType, months)) ->
|
|
withDB' "getSubscriptionCharge" cc (\db -> getSubscriptionCharge db paymentId chargeRef) >>= \case
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
-- the store still reports the charge the ledger last granted
|
|
Right (Just (_, True)) -> recordCheck (Just charge) subscriptionState
|
|
Right recorded -> do
|
|
chargeId <- maybe (randomId cc) (pure . fst) recorded
|
|
entryIds <- (,,,) <$> randomId cc <*> randomId cc <*> randomId cc <*> randomId cc
|
|
signMonthAfter key cc masterKey now tip (chargeEntries periodStart now badgeType months purchaseKey chargeId entryIds tip) >>= \case
|
|
Left resp -> pure resp
|
|
Right (rows, issuance) -> do
|
|
r <- withDB "renewSubscription" cc $ \db -> liftIO $ do
|
|
tip' <- lockedLedgerTip db badgePurchaseId now
|
|
if fmap entryId tip' /= Just (entryId tip)
|
|
then pure Nothing
|
|
else do
|
|
insertSubscriptionCharge db paymentId chargeId charge now
|
|
appendLedgerPlan db badgePurchaseId Nothing rows $ Just issuance
|
|
setPurchaseBadgeType db badgePurchaseId badgeType now
|
|
setSubscriptionState db paymentId subscriptionState now
|
|
Just <$> checkedResponse db badgeType subscriptionState
|
|
case r of
|
|
Right (Just resp) -> logBanked months $> resp
|
|
Right Nothing -> logError "store service: a subscription's ledger moved while its renewal was signed" $> errorResponse STEInternal
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
where
|
|
-- the store's answer is kept, and a charge it reports is recorded once, granted or not
|
|
recordCheck charge_ state = do
|
|
chargeId <- randomId cc
|
|
answered "recordSubscriptionCheck" $ \db -> do
|
|
forM_ charge_ $ \charge -> insertSubscriptionCharge db paymentId chargeId charge now
|
|
setSubscriptionState db paymentId state now
|
|
checkedResponse db balanceBadgeType state
|
|
checkedResponse db ledgerTier state = case upgradeTier <$> upgrading_ of
|
|
Just tier | tier /= ledgerTier -> pure $ atLeastCheckInterval $ errorResponse STEPaymentPending
|
|
_ -> issueResponse db badgePurchaseId now (Just state)
|
|
-- A charge a dev deployment's store reports while coverage runs is banked with no issuance, and its month issued once coverage reaches it.
|
|
checkStoreDuringCoverage purchase@ServicePurchase {badgePurchaseId} tip PurchaseSubscription {paymentId, provider, subscriptionRef} now =
|
|
withDB' "stampSubscriptionCheck" cc (\db -> stampSubscriptionCheck db paymentId now) >>= \case
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right () ->
|
|
checkStoreSubscription verifier subscriptionRef Nothing >>= \case
|
|
Left refusal -> atLeastCheckInterval <$> storeRefusalResponse refusal
|
|
Right checked@SubscriptionCheck {subscriptionState, latestCharge} -> do
|
|
newChargeId <- randomId cc
|
|
entryIds <- (,,,) <$> randomId cc <*> randomId cc <*> randomId cc <*> randomId cc
|
|
r <- withDB' "checkStoreDuringCoverage" cc $ \db -> do
|
|
tip' <- lockedLedgerTip db badgePurchaseId now
|
|
ended <- coverageEnded db paymentId checked
|
|
banked <-
|
|
if not ended && fmap entryId tip' == Just (entryId tip)
|
|
then do
|
|
bankedMonths <- case grantableCharge provider now checked of
|
|
Right (charge@StoreCharge {chargeRef, periodStart}, (badgeType, months)) ->
|
|
getSubscriptionCharge db paymentId chargeRef >>= \case
|
|
Just (_, True) -> pure Nothing
|
|
recorded -> do
|
|
let chargeId = maybe newChargeId fst recorded
|
|
insertSubscriptionCharge db paymentId chargeId charge now
|
|
appendLedgerPlan db badgePurchaseId Nothing (chargeEntries periodStart now badgeType months purchaseKey chargeId entryIds tip) Nothing
|
|
setPurchaseBadgeType db badgePurchaseId badgeType now
|
|
pure $ Just months
|
|
Left _ -> Nothing <$ forM_ latestCharge (\charge -> insertSubscriptionCharge db paymentId newChargeId charge now)
|
|
setSubscriptionState db paymentId subscriptionState now
|
|
pure bankedMonths
|
|
else pure Nothing
|
|
(ended, banked,) <$> getLedgerTip db badgePurchaseId
|
|
case r of
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right (True, _, _) -> refundRemaining purchase tip paymentId now
|
|
Right (False, banked, tip_) -> do
|
|
forM_ banked logBanked
|
|
issueDue purchase tip_ (Just subscriptionState) now
|
|
-- While coverage runs the store is asked before each new month, so a refund ends the months not yet issued; a store
|
|
-- that fails or does not answer ends nothing, and the month is issued as before.
|
|
checkStoreBeforeMonth purchase tip subscription@PurchaseSubscription {paymentId, subscriptionRef, storedState} now
|
|
| held, storedState == SSEnded = refundRemaining purchase tip paymentId now
|
|
| held = issueDue purchase (Just tip) (Just storedState) now
|
|
| otherwise =
|
|
withDB' "stampSubscriptionCheck" cc (\db -> stampSubscriptionCheck db paymentId now) >>= \case
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right () ->
|
|
checkStoreSubscription verifier subscriptionRef Nothing >>= \case
|
|
Left refusal -> storeRefusalResponse refusal >> issueDue purchase (Just tip) (Just storedState) now
|
|
Right checked@SubscriptionCheck {subscriptionState, latestCharge} -> do
|
|
chargeId <- randomId cc
|
|
r <- withDB' "recordSubscriptionCheck" cc $ \db -> do
|
|
ended <- coverageEnded db paymentId checked
|
|
-- a new charge is only recorded: it is granted once coverage ends, as a renewal
|
|
unless ended $ forM_ latestCharge $ \charge -> insertSubscriptionCharge db paymentId chargeId charge now
|
|
setSubscriptionState db paymentId (if ended then SSEnded else subscriptionState) now
|
|
pure ended
|
|
case r of
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
Right True -> refundRemaining purchase tip paymentId now
|
|
Right False -> issueDue purchase (Just tip) (Just subscriptionState) now
|
|
where
|
|
held = isJust $ checkWait now subscription
|
|
refundRemaining ServicePurchase {badgePurchaseId} tip paymentId now = do
|
|
(lapseId, refundId) <- (,) <$> randomId cc <*> randomId cc
|
|
let lapsed = lapseEntry now lapseId tip
|
|
refund@StatementEntry {changeMonths = refunded} = debitEntry now refundId SDRefund (fromMaybe tip lapsed)
|
|
r <- withDB' "refundSubscription" cc $ \db -> do
|
|
tip' <- lockedLedgerTip db badgePurchaseId now
|
|
if fmap entryId tip' /= Just (entryId tip)
|
|
then pure Nothing
|
|
else do
|
|
-- with no months left there is nothing to refund, as when an earlier request already refunded them
|
|
unless (refunded == 0) $ appendLedgerPlan db badgePurchaseId Nothing (maybeToList lapsed <> [refund]) Nothing
|
|
setSubscriptionState db paymentId SSEnded now
|
|
Just . withoutCredential <$> issueResponse db badgePurchaseId now (Just SSEnded)
|
|
case r of
|
|
Right (Just resp) -> resp <$ unless (refunded == 0) (logInfo $ "store subscription ended during coverage: " <> tshow (negate refunded) <> " months refunded")
|
|
Right Nothing -> logError "store service: a subscription's ledger moved while its refund was written" $> errorResponse STEInternal
|
|
Left _ -> pure $ errorResponse STEInternal
|
|
answered what action = fromRight (errorResponse STEInternal) <$> withDB' what cc action
|
|
withoutCredential = \case
|
|
STPBadgeCredential {receipt, statement, subscription} -> STPBadgeCredential {credential = Nothing, receipt, statement, subscription}
|
|
resp -> resp
|
|
logBanked months = logInfo $ "store subscription charge banked: " <> tshow months <> " months"
|
|
-- Only the asserted entry's identity is read, never the months it claims.
|
|
issueResponse db purchaseId t subscription = 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) subscription) entries_
|
|
|
|
getIssuePurchase :: DB.Connection -> C.PublicKeyEd25519 -> IO (Maybe (ServicePurchase, Maybe StatementEntry, Maybe PurchaseSubscription))
|
|
getIssuePurchase db purchaseKey = do
|
|
p_ <- getPurchaseByKey db purchaseKey
|
|
forM p_ $ \p@ServicePurchase {badgePurchaseId} -> (p,,) <$> getLedgerTip db badgePurchaseId <*> getPurchaseSubscription db badgePurchaseId
|
|
|
|
-- | Due with each month, within coverage too, so a refund ends the months not yet issued; at most once an interval.
|
|
subscriptionCheckDue :: StoreChecks -> UTCTime -> StatementEntry -> PurchaseSubscription -> Bool
|
|
subscriptionCheckDue ChecksAtEveryRequest _ _ _ = True
|
|
subscriptionCheckDue ChecksAfterCoverage now tip subscription =
|
|
balanceStartTs tip <= now && isNothing (checkWait now subscription)
|
|
|
|
-- | What remains of the interval since the subscription last asked its store, if anything.
|
|
checkWait :: UTCTime -> PurchaseSubscription -> Maybe NominalDiffTime
|
|
checkWait now PurchaseSubscription {checkedAt} =
|
|
mfilter (> 0) $ (\t -> subscriptionCheckInterval - diffUTCTime now t) <$> checkedAt
|
|
|
|
-- | Each subscription asks its store at most this often, failed attempts included, so a client
|
|
-- repeating its requests cannot spend the store quota every subscriber shares.
|
|
subscriptionCheckInterval :: NominalDiffTime
|
|
subscriptionCheckInterval = 3600
|
|
|
|
-- | The next check waits out the interval whatever the client is told, so its retry waits as long.
|
|
atLeastCheckInterval :: StoreServiceResponse -> StoreServiceResponse
|
|
atLeastCheckInterval = \case
|
|
STPError {code, message, retryAfter = Just t} -> STPError {code, message, retryAfter = Just $ max t $ round subscriptionCheckInterval}
|
|
resp -> resp
|
|
|
|
-- | The subscription claims the purchase path asked a store about, one map per store: the App Store's by original
|
|
-- transaction id, Play's by the token's hash, so Play tokens, which cost nothing to make up, cannot fill the App Store's;
|
|
-- and the minute's allowance of Play reads.
|
|
data PurchaseChecks = PurchaseChecks {appleChecks :: ClaimChecks, googleChecks :: ClaimChecks, playReads :: PlayReads}
|
|
|
|
type ClaimChecks = TVar (M.Map ClaimKey UTCTime)
|
|
|
|
-- | A claim is held per subscription and per what its receipt was presented for, so a restore on another
|
|
-- device is not held off by the purchase the buyer made minutes earlier, and each still asks at most once an interval.
|
|
data ClaimKey = ClaimKey {claimRef :: T.Text, claimPurpose :: ClaimPurpose}
|
|
deriving (Eq, Ord)
|
|
|
|
data ClaimPurpose = ClaimPurchase | ClaimTransfer
|
|
deriving (Eq, Ord)
|
|
|
|
-- | The Play subscription checks the purchase path made in the current minute, counted as the web listener counts requests.
|
|
type PlayReads = TVar (Maybe Bucket)
|
|
|
|
newPurchaseChecks :: IO PurchaseChecks
|
|
newPurchaseChecks = PurchaseChecks <$> newTVarIO M.empty <*> newTVarIO M.empty <*> newTVarIO Nothing
|
|
|
|
-- | How long a claim waits before its store is asked again; Nothing records a check at now. A claim that finds
|
|
-- the table full of checks inside the interval is asked without being recorded, so a full table refuses no one.
|
|
purchaseCheckWait :: ClaimChecks -> UTCTime -> ClaimKey -> STM (Maybe NominalDiffTime)
|
|
purchaseCheckWait checks now key = stateTVar checks $ \m ->
|
|
let live = M.filter (\t -> diffUTCTime now t < subscriptionCheckInterval) m
|
|
in case M.lookup key live of
|
|
Just t -> (Just $ subscriptionCheckInterval - diffUTCTime now t, live)
|
|
Nothing
|
|
| M.size live >= maxPurchaseChecks -> (Nothing, live)
|
|
| otherwise -> (Nothing, M.insert key now live)
|
|
|
|
-- | Per store, far above an hour of genuine purchases: every App Store key is one Apple signed for this app,
|
|
-- while Play keys cost nothing to make up, so a full Play map only stops recording Play claims.
|
|
maxPurchaseChecks :: Int
|
|
maxPurchaseChecks = 10000
|
|
|
|
data SubscriptionAsk = AskStore | ClaimHeld NominalDiffTime | NoPlayRead NominalDiffTime
|
|
|
|
-- | How long until the purchase path may read Play again; Nothing takes one of this minute's reads.
|
|
playReadWait :: PlayReads -> UTCTime -> STM (Maybe NominalDiffTime)
|
|
playReadWait taken now = stateTVar taken $ \b_ -> either (\wait -> (Just wait, b_)) (\b -> (Nothing, Just b)) (takeFrom maxPlayPurchaseReads now b_)
|
|
|
|
-- | A tenth of Play's default Subscriptions quota of 3,000 reads a minute, which renewal checks share: any token in the
|
|
-- accepted grammar costs a read, so this caps what made-up tokens can spend; far above a minute of genuine purchases.
|
|
maxPlayPurchaseReads :: Int
|
|
maxPlayPurchaseReads = 300
|