mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 17:58:47 +00:00
* badges: webapp (#7433) * badges: service migrations, store and catalog * badges: BTCPay provider and settlement poller * badges: web listener and /api endpoints * web: checkout single-page app * badges: tests and BTCPay fixtures * badges: README and ini reference * badges: fix hex16 build on GHC 8.10.7 * badges: Stripe card lane * badges: fix Stripe card checkout, add theming * badges: add a discount row to the order summary * badges: site navbar, embedding, theme, Forget move * badges: use SB code prefix in web checkout * badges: rename sxb app namespace to sb * badges: embed checkout nav via site; keep original app navbar * badges: post iframe height, apply site background when embedded * badges: embed dark surfaces, steadier iframe height * badges: hide app footer when embedded * badges: size embedded body to content, not viewport * badges: declare color-scheme to stop reload flash * badges: fade shell in on load, no reload blank * badges: prerender app shell into index.html * badges: pre-paint theme, hide shell on deep reload * badges: logo returns to landing client-side * badges: embedded wizard back, buy-a-code, resume * badges: signal app-managed screens, resume across reload * badges: rebuild wizard history on deep load so Back walks it * badges: carry welcome-page height as the iframe floor * badges: keep selection on Buy a code; rename to Your codes * badges: read web shell as UTF-8, not locale * badges: resume the exact paid order after Stripe card redirect * badges: move docker deploy under scripts * badges: add serve_webapp toggle and webapp export * badges: wire split webapp deploy in docker config * badges: quiet agent logs by default * badges: resume card redirect in the embedded frame * badges: migrate Stripe adapter to PaymentIntents * badges: correct Stripe restricted key scopes in ini example * badges: card via Payment Element and PaymentIntents * badges: fix stale Checkout Session wording in Stripe adapter * badges: fix stale CheckoutActions reference in card comment * badges: order shell stylesheet before bootstrap script * badges: remove development card stand-in * badges: theme the Stripe card form with the site palette * badges: exclude web from the Haskell build stage * badges: unify invoice cancel and mark canceled * badges: default log level to info * badges: unify closed-invoice buy-again button * badges: mute agent connection logs at info level * badges: show purchase time in local timezone in Your codes * badges: log service events on own channel, quiet agent * badges: fold service migrations into one baseline * badges: run compose on postgres over host network * badges: use high-res hero art * badges: add web CI to catch stale builds * badges: rebuild web shell from committed source * badges: normalize invoice-code link and columns * badges: drop unused columns, rename index * badges: note deferred receipt_hash in migrations * badges: apply code-review fixes * badges: reduce comments across service and web --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * badges: improve web page (#7546) * badges: improve web page * improve layout * improve layout * fix * small changes --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * badges: read one issuer key from the ini * badges: move and group the service tests * badges: service fixes (#7567) * badges: match the redeem error wording in tests * badges: drop unused imports in the bot tests * badges: cancel Stripe orders when they expire * badges: correct the Stripe config and docs * badges: refuse to revoke a redeemed code * badges: make the fake Stripe cancel like Stripe * badges: limit replayed webhook deliveries --------- Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com> Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: shum <github.shum@liber.li> Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
528 lines
20 KiB
Haskell
528 lines
20 KiB
Haskell
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
|
|
module Bots.BadgeService.FakeBTCPay
|
|
( FakeBTCPay (..),
|
|
FakeRequest (..),
|
|
withFakeBTCPay,
|
|
fakeApiKey,
|
|
fakeExpiryMinutes,
|
|
setInvoiceState,
|
|
failNextCalls,
|
|
failAfterCalls,
|
|
useListFixture,
|
|
usePaymentMethodsFixture,
|
|
ignoreListPaging,
|
|
answerOversize,
|
|
fakeRequests,
|
|
apiRequests,
|
|
fakeInvoiceIds,
|
|
fixtureResponse,
|
|
webhookEvent,
|
|
webhookSigHeader,
|
|
webhookHexSig,
|
|
)
|
|
where
|
|
|
|
import BadgeService.Config (BTCPayConfig (..), SpeedPolicy (..))
|
|
import Control.Applicative ((<|>))
|
|
import Control.Concurrent.STM
|
|
import Control.Monad (join)
|
|
import Crypto.Hash (Digest, SHA256)
|
|
import Crypto.MAC.HMAC (HMAC, hmac, hmacGetDigest)
|
|
import qualified Data.Aeson as J
|
|
import Data.Aeson.Key (Key)
|
|
import qualified Data.Aeson.Key as K
|
|
import qualified Data.Aeson.KeyMap as KM
|
|
import Data.Aeson.Types (Pair)
|
|
import Data.ByteArray.Encoding (Base (Base16), convertToBase)
|
|
import Data.ByteString (ByteString)
|
|
import qualified Data.ByteString.Char8 as B8
|
|
import qualified Data.ByteString.Lazy as LB
|
|
import Data.List (isPrefixOf, nub)
|
|
import qualified Data.Map.Strict as M
|
|
import Data.Maybe (fromMaybe)
|
|
import Data.Scientific (Scientific)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import qualified Data.Text.Encoding as TE
|
|
-- http-client is imported qualified because its Request has requestHeaders, requestBody and
|
|
-- queryString just as WAI's does.
|
|
import Network.HTTP.Client (Manager)
|
|
import qualified Network.HTTP.Client as HTTP
|
|
import Network.HTTP.Types
|
|
( Query,
|
|
Status,
|
|
badRequest400,
|
|
hAuthorization,
|
|
hContentType,
|
|
methodGet,
|
|
methodPost,
|
|
mkStatus,
|
|
notFound404,
|
|
ok200,
|
|
statusCode,
|
|
statusMessage,
|
|
unauthorized401,
|
|
)
|
|
import Network.HTTP.Types.Header (Header)
|
|
import Network.Wai (Application, Response, pathInfo, queryString, requestHeaders, requestMethod, responseLBS, strictRequestBody)
|
|
import qualified Network.Wai.Handler.Warp as Warp
|
|
import Simplex.Messaging.Util (tshow)
|
|
import System.FilePath ((</>))
|
|
import Text.Read (readMaybe)
|
|
|
|
fixtureDir :: FilePath
|
|
fixtureDir = "apps" </> "simplex-badge-service" </> "test-fixtures" </> "btcpay"
|
|
|
|
fakeApiKey :: Text
|
|
fakeApiKey = "7f3c1d9a2e5b48c6905ad1e2f3b4c5d6e7f80912"
|
|
|
|
fakeStoreId :: Text
|
|
fakeStoreId = "BqZKtkeSN9JgLLdCJRJfXQjLwCLDNCVLdxLTTdMzTHnT"
|
|
|
|
fakeWebhookSecret :: Text
|
|
fakeWebhookSecret = "3d8f5c6a2b1e4f7089abcdef01234567"
|
|
|
|
fakeExpiryMinutes :: Int
|
|
fakeExpiryMinutes = 60
|
|
|
|
data FakeRequest = FakeRequest
|
|
{ frMethod :: ByteString,
|
|
frPath :: [Text],
|
|
frQuery :: Query,
|
|
frHeaders :: [Header],
|
|
frBody :: LB.ByteString
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data InvoiceState = InvoiceState
|
|
{ isMethodId :: Text,
|
|
isStatus :: Text,
|
|
isAdditionalStatus :: Text,
|
|
isPaid :: Maybe Text,
|
|
isDue :: Maybe Text
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data FailPlan = FailPlan {fpSkip :: Int, fpCalls :: Int, fpStatus :: Int}
|
|
deriving (Eq, Show)
|
|
|
|
data FakeState = FakeState
|
|
{ fsInvoices :: M.Map Text InvoiceState,
|
|
fsNextId :: Int,
|
|
fsFail :: FailPlan,
|
|
fsList :: Text,
|
|
fsMethods :: Maybe Text,
|
|
fsIgnorePaging :: Bool,
|
|
fsOversize :: Bool,
|
|
fsRequests :: [FakeRequest]
|
|
}
|
|
|
|
initialState :: FakeState
|
|
initialState =
|
|
FakeState
|
|
{ fsInvoices = M.empty,
|
|
fsNextId = 1,
|
|
fsFail = FailPlan {fpSkip = 0, fpCalls = 0, fpStatus = 0},
|
|
fsList = "invoice-list",
|
|
fsMethods = Nothing,
|
|
fsIgnorePaging = False,
|
|
fsOversize = False,
|
|
fsRequests = []
|
|
}
|
|
|
|
data FakeBTCPay = FakeBTCPay
|
|
{ fbConfig :: BTCPayConfig,
|
|
fbBaseUrl :: String,
|
|
fbManager :: Manager,
|
|
fbState :: TVar FakeState
|
|
}
|
|
|
|
-- | Warp chooses the port, because a fixed one could collide with a server left over from an
|
|
-- earlier run and the test would pass against that instead.
|
|
withFakeBTCPay :: (FakeBTCPay -> IO a) -> IO a
|
|
withFakeBTCPay action = do
|
|
fbState <- newTVarIO initialState
|
|
fbManager <- HTTP.newManager HTTP.defaultManagerSettings
|
|
Warp.testWithApplication (pure (fakeApp fbState)) $ \prt -> do
|
|
let fbBaseUrl = "http://127.0.0.1:" <> show prt
|
|
action FakeBTCPay {fbConfig = fakeConfig (T.pack fbBaseUrl), fbBaseUrl, fbManager, fbState}
|
|
|
|
fakeConfig :: Text -> BTCPayConfig
|
|
fakeConfig host =
|
|
BTCPayConfig
|
|
{ bHost = host,
|
|
bApiKey = fakeApiKey,
|
|
bStoreId = fakeStoreId,
|
|
bWebhookSecret = fakeWebhookSecret,
|
|
bExpiryMinutes = fakeExpiryMinutes,
|
|
bSpeedPolicy = MediumSpeed,
|
|
bPaymentTolerance = 0.5
|
|
}
|
|
|
|
-- | A BTCPay webhook delivery shaped as its documentation shows. Both suites sign this exact
|
|
-- envelope, so what a valid signature covers cannot mean two different things.
|
|
webhookEvent :: Text -> Text -> LB.ByteString
|
|
webhookEvent eventType invoiceRef =
|
|
LB.fromStrict . TE.encodeUtf8 . T.unlines $
|
|
[ "{",
|
|
" \"deliveryId\": \"vBYbSCVSMFXFqSuCZ2LEHt\",",
|
|
" \"webhookId\": \"5GtRTUCYPbVAWJmYcgZ8wK\",",
|
|
" \"originalDeliveryId\": \"vBYbSCVSMFXFqSuCZ2LEHt\",",
|
|
" \"isRedelivery\": false,",
|
|
" \"type\": \"" <> eventType <> "\",",
|
|
" \"timestamp\": 1700000000,",
|
|
" \"storeId\": \"" <> fakeStoreId <> "\",",
|
|
" \"invoiceId\": \"" <> invoiceRef <> "\"",
|
|
"}"
|
|
]
|
|
|
|
-- | The header BTCPay sends is an HMAC-SHA256 over the bytes exactly as written above.
|
|
webhookSigHeader :: Text -> LB.ByteString -> [Header]
|
|
webhookSigHeader secret body = [("BTCPay-Sig", "sha256=" <> webhookHexSig secret body)]
|
|
|
|
webhookHexSig :: Text -> LB.ByteString -> ByteString
|
|
webhookHexSig secret body = convertToBase Base16 digest
|
|
where
|
|
digest :: Digest SHA256
|
|
digest = hmacGetDigest (hmac (TE.encodeUtf8 secret) (LB.toStrict body) :: HMAC SHA256)
|
|
|
|
fixtureResponse :: Text -> IO J.Value
|
|
fixtureResponse name = do
|
|
raw <- LB.readFile path
|
|
case J.eitherDecode raw of
|
|
Left e -> fail (path <> ": " <> e)
|
|
Right (J.Object o) -> case (KM.lookup "_fixture" o, KM.lookup "response" o) of
|
|
(Just (J.String _), Just v) -> pure v
|
|
_ -> fail (path <> ": a fixture is {\"_fixture\": <provenance>, \"response\": <body>}")
|
|
Right _ -> fail (path <> ": a fixture is a JSON object")
|
|
where
|
|
path = fixtureDir </> T.unpack name <> ".json"
|
|
|
|
invoiceFixture :: Text -> Text
|
|
invoiceFixture = \case
|
|
"Processing" -> "invoice-processing"
|
|
"Settled" -> "invoice-settled"
|
|
"Expired" -> "invoice-expired"
|
|
"Invalid" -> "invoice-invalid"
|
|
_ -> "invoice-new"
|
|
|
|
methodsFixture :: Text -> Text
|
|
methodsFixture methodId
|
|
| methodId == xmrMethod = "payment-methods-xmr"
|
|
| otherwise = "payment-methods-btc"
|
|
|
|
-- The adapter's own ids are restated rather than imported, so a fake sharing the constant cannot
|
|
-- agree with a wrong change to it.
|
|
btcMethod, xmrMethod :: Text
|
|
btcMethod = "BTC-CHAIN"
|
|
xmrMethod = "XMR-CHAIN"
|
|
|
|
patchInvoice :: Text -> InvoiceState -> J.Value -> J.Value
|
|
patchInvoice invId InvoiceState {isStatus, isAdditionalStatus} =
|
|
setField "id" (J.String invId)
|
|
. setField "status" (J.String isStatus)
|
|
. setField "additionalStatus" (J.String isAdditionalStatus)
|
|
|
|
patchMethods :: InvoiceState -> J.Value -> J.Value
|
|
patchMethods InvoiceState {isPaid, isDue} = \case
|
|
J.Array vs -> J.Array (fmap one vs)
|
|
other -> other
|
|
where
|
|
one = \case
|
|
J.Object o ->
|
|
let paid = fromMaybe (fixturePaid o) isPaid
|
|
-- With nothing received the whole amount is owed. The fixtures were written for a
|
|
-- settled invoice, so without this a freshly created one would serve their `due` of
|
|
-- zero and the fake would report nothing left to send on an unpaid invoice.
|
|
due = if isZero paid then isDue <|> textField "amount" o else isDue
|
|
withPaid = KM.insert "paymentMethodPaid" (J.String paid) o
|
|
withDue = maybe withPaid (\d -> KM.insert "due" (J.String d) withPaid) due
|
|
in J.Object (if isZero paid then KM.insert "payments" (J.Array mempty) withDue else withDue)
|
|
v -> v
|
|
fixturePaid o = case KM.lookup "paymentMethodPaid" o of
|
|
Just (J.String t) -> t
|
|
_ -> "0"
|
|
|
|
isZero :: Text -> Bool
|
|
isZero t = (J.decodeStrict (TE.encodeUtf8 t) :: Maybe Scientific) == Just 0
|
|
|
|
setField :: Key -> J.Value -> J.Value -> J.Value
|
|
setField k v = \case
|
|
J.Object o -> J.Object (KM.insert k v o)
|
|
other -> other
|
|
|
|
fakeApp :: TVar FakeState -> Application
|
|
fakeApp stv req respond = do
|
|
body <- strictRequestBody req
|
|
case pathInfo req of
|
|
["_state", invId] | isPost -> control body (setState invId)
|
|
["_fail"] | isPost -> control body setFail
|
|
["_fixtures"] | isPost -> control body setFixtures
|
|
["_paging"] | isPost -> control body setPaging
|
|
["_oversize"] | isPost -> control body setOversize
|
|
"api" : "v1" : "stores" : storeId : rest -> apiCall body storeId rest
|
|
_ -> refuse notFound404 "no such path on the fake btcpay"
|
|
where
|
|
verb = requestMethod req
|
|
isPost = verb == methodPost
|
|
isGet = verb == methodGet
|
|
refuse st message = respond (errorResponse st message)
|
|
ok = respond (jsonResponse ok200 (J.object ["ok" J..= True]))
|
|
|
|
control body act = case J.decode body of
|
|
Just (J.Object o) ->
|
|
act o >>= \case
|
|
Nothing -> ok
|
|
Just message -> refuse badRequest400 message
|
|
_ -> refuse badRequest400 "a control call takes a JSON object"
|
|
|
|
setState invId o = case unknownKeys stateKeys o of
|
|
Just message -> pure (Just message)
|
|
Nothing -> atomically $ do
|
|
st <- readTVar stv
|
|
case M.lookup invId (fsInvoices st) of
|
|
Nothing -> pure (Just ("no invoice " <> invId <> " was created here"))
|
|
Just inv -> do
|
|
let inv' =
|
|
inv
|
|
{ isStatus = fromMaybe (isStatus inv) (textField "status" o),
|
|
isAdditionalStatus = fromMaybe (isAdditionalStatus inv) (textField "additionalStatus" o),
|
|
isPaid = maybe (isPaid inv) Just (textField "paymentMethodPaid" o),
|
|
isDue = maybe (isDue inv) Just (textField "due" o)
|
|
}
|
|
writeTVar stv st {fsInvoices = M.insert invId inv' (fsInvoices st)}
|
|
pure Nothing
|
|
|
|
setFail o = case unknownKeys ["calls", "status", "skip"] o of
|
|
Just message -> pure (Just message)
|
|
Nothing -> case (intField "calls" o, intField "status" o) of
|
|
(Just calls, Just code) -> do
|
|
let plan = FailPlan {fpSkip = fromMaybe 0 (intField "skip" o), fpCalls = calls, fpStatus = code}
|
|
atomically (modifyTVar' stv (\st -> st {fsFail = plan}))
|
|
pure Nothing
|
|
_ -> pure (Just "_fail takes {\"calls\": <n>, \"status\": <http status>, \"skip\": <n>}")
|
|
|
|
setFixtures o = case unknownKeys ["list", "paymentMethods"] o of
|
|
Just message -> pure (Just message)
|
|
Nothing -> case (named "list" "invoice-list" fsListOf, named "paymentMethods" "payment-methods" fsMethodsOf) of
|
|
(Left message, _) -> pure (Just message)
|
|
(_, Left message) -> pure (Just message)
|
|
(Right setL, Right setM)
|
|
| null (KM.keys o) -> pure (Just "_fixtures takes a `list` or a `paymentMethods` fixture name")
|
|
| otherwise -> do
|
|
atomically (modifyTVar' stv (setM . setL))
|
|
pure Nothing
|
|
where
|
|
named k prefix set = case textField k o of
|
|
Nothing -> Right id
|
|
Just name
|
|
| prefix `isPrefixOf` T.unpack name -> Right (set name)
|
|
| otherwise -> Left ("not a " <> K.toText k <> " fixture: " <> name)
|
|
fsListOf name st = st {fsList = name}
|
|
fsMethodsOf name st = st {fsMethods = Just name}
|
|
|
|
setOversize o = case unknownKeys ["on"] o of
|
|
Just message -> pure (Just message)
|
|
Nothing -> case KM.lookup "on" o of
|
|
Just (J.Bool on) -> do
|
|
atomically (modifyTVar' stv (\st -> st {fsOversize = on}))
|
|
pure Nothing
|
|
_ -> pure (Just "_oversize takes {\"on\": <bool>}")
|
|
|
|
setPaging o = case unknownKeys ["ignore"] o of
|
|
Just message -> pure (Just message)
|
|
Nothing -> case KM.lookup "ignore" o of
|
|
Just (J.Bool ignore) -> do
|
|
atomically (modifyTVar' stv (\st -> st {fsIgnorePaging = ignore}))
|
|
pure Nothing
|
|
_ -> pure (Just "_paging takes {\"ignore\": <bool>}")
|
|
|
|
apiCall body storeId rest = do
|
|
atomically $
|
|
modifyTVar' stv $ \st ->
|
|
st {fsRequests = FakeRequest verb (pathInfo req) (queryString req) (requestHeaders req) body : fsRequests st}
|
|
if storeId /= fakeStoreId
|
|
then refuse notFound404 ("no store " <> storeId <> " on the fake btcpay")
|
|
else case lookup hAuthorization (requestHeaders req) of
|
|
Just given | given == expectedAuth -> injectingFailure (greenfield body rest)
|
|
_ -> refuse unauthorized401 "Authorization must be `token <api-key>`"
|
|
|
|
expectedAuth = "token " <> TE.encodeUtf8 fakeApiKey
|
|
|
|
injectingFailure act = do
|
|
failing <- atomically $ do
|
|
st <- readTVar stv
|
|
case fsFail st of
|
|
plan@FailPlan {fpSkip} | fpSkip > 0 -> do
|
|
writeTVar stv st {fsFail = plan {fpSkip = fpSkip - 1}}
|
|
pure Nothing
|
|
plan@FailPlan {fpCalls, fpStatus} | fpCalls > 0 -> do
|
|
writeTVar stv st {fsFail = plan {fpCalls = fpCalls - 1}}
|
|
pure (Just fpStatus)
|
|
_ -> pure Nothing
|
|
case failing of
|
|
Just code -> refuse (mkStatus code "Injected Failure") "the fake was told to fail this call"
|
|
Nothing -> act
|
|
|
|
greenfield body = \case
|
|
["payment-methods"] | isGet -> serve "store-payment-methods" id
|
|
["invoices"] | isPost -> createInvoice body
|
|
["invoices"] | isGet -> listInvoices
|
|
["invoices", invId] | isGet ->
|
|
withInvoice invId $ \inv -> serve (invoiceFixture (isStatus inv)) (patchInvoice invId inv)
|
|
["invoices", invId, "payment-methods"] | isGet ->
|
|
withInvoice invId $ \inv -> do
|
|
override <- fsMethods <$> readTVarIO stv
|
|
serve (fromMaybe (methodsFixture (isMethodId inv)) override) (patchMethods inv)
|
|
_ -> refuse notFound404 "no such greenfield path on the fake btcpay"
|
|
|
|
listInvoices = do
|
|
st <- readTVarIO stv
|
|
fixture <- fixtureResponse (fsList st)
|
|
case arrayEntries fixture of
|
|
Nothing -> respond (jsonResponse ok200 fixture)
|
|
Just fixed -> do
|
|
let invs = M.toAscList (fsInvoices st)
|
|
cache <- readFixtures (concatMap (fixtureNames (fsMethods st)) invs)
|
|
let entries = fixed <> map (listedInvoice cache (fsMethods st)) invs
|
|
page = if fsIgnorePaging st then entries else paged (queryString req) entries
|
|
respond (jsonResponse ok200 (J.toJSON page))
|
|
|
|
readFixtures names = M.fromList <$> mapM (\n -> (,) n <$> fixtureResponse n) (nub names)
|
|
|
|
fixtureNames override (_, inv) = [invoiceFixture (isStatus inv), methodsName override inv]
|
|
|
|
methodsName override inv = fromMaybe (methodsFixture (isMethodId inv)) override
|
|
|
|
listedInvoice cache override (invId, inv) =
|
|
setField "paymentMethods" (patchMethods inv (cached (methodsName override inv))) $
|
|
patchInvoice invId inv (cached (invoiceFixture (isStatus inv)))
|
|
where
|
|
cached name = fromMaybe J.Null (M.lookup name cache)
|
|
|
|
serve name patch = do
|
|
oversize <- fsOversize <$> readTVarIO stv
|
|
if oversize
|
|
then respond (jsonResponse ok200 (J.String (T.replicate (12 * 1024 * 1024) "x")))
|
|
else do
|
|
v <- fixtureResponse name
|
|
respond (jsonResponse ok200 (patch v))
|
|
|
|
withInvoice invId act = do
|
|
invoices <- fsInvoices <$> readTVarIO stv
|
|
case M.lookup invId invoices of
|
|
Just inv -> act inv
|
|
Nothing -> refuse notFound404 ("no invoice " <> invId <> " on the fake btcpay")
|
|
|
|
createInvoice body = case J.decode body >>= chosenMethod of
|
|
Nothing -> refuse badRequest400 "checkout.paymentMethods must name exactly one of BTC-CHAIN, XMR-CHAIN"
|
|
Just methodId -> do
|
|
(invId, inv) <- atomically $ do
|
|
st <- readTVar stv
|
|
let invId = "FakeInvoiceRef" <> T.justifyRight 4 '0' (tshow (fsNextId st))
|
|
inv =
|
|
InvoiceState
|
|
{ isMethodId = methodId,
|
|
isStatus = "New",
|
|
isAdditionalStatus = "None",
|
|
isPaid = Just "0.00000000", isDue = Nothing
|
|
}
|
|
writeTVar stv st {fsNextId = fsNextId st + 1, fsInvoices = M.insert invId inv (fsInvoices st)}
|
|
pure (invId, inv)
|
|
serve (invoiceFixture (isStatus inv)) (patchInvoice invId inv)
|
|
|
|
arrayEntries :: J.Value -> Maybe [J.Value]
|
|
arrayEntries = \case
|
|
J.Array vs -> Just (foldr (:) [] vs)
|
|
_ -> Nothing
|
|
|
|
paged :: Query -> [J.Value] -> [J.Value]
|
|
paged q entries = maybe id take (intQuery "take" q) (drop (fromMaybe 0 (intQuery "skip" q)) entries)
|
|
|
|
intQuery :: ByteString -> Query -> Maybe Int
|
|
intQuery k q = join (lookup k q) >>= readMaybe . B8.unpack
|
|
|
|
stateKeys :: [Key]
|
|
stateKeys = ["status", "additionalStatus", "paymentMethodPaid", "due"]
|
|
|
|
unknownKeys :: [Key] -> J.Object -> Maybe Text
|
|
unknownKeys known o = case filter (`notElem` known) (KM.keys o) of
|
|
k : _ -> Just ("this control call does not set " <> K.toText k)
|
|
[] -> Nothing
|
|
|
|
chosenMethod :: J.Value -> Maybe Text
|
|
chosenMethod v = case objectField "checkout" v >>= objectField "paymentMethods" of
|
|
Just (J.Array ms) -> case foldr (:) [] ms of
|
|
[J.String m] | m `elem` [btcMethod, xmrMethod] -> Just m
|
|
_ -> Nothing
|
|
_ -> Nothing
|
|
|
|
objectField :: Key -> J.Value -> Maybe J.Value
|
|
objectField k = \case
|
|
J.Object o -> KM.lookup k o
|
|
_ -> Nothing
|
|
|
|
textField :: Key -> J.Object -> Maybe Text
|
|
textField k o = case KM.lookup k o of
|
|
Just (J.String t) -> Just t
|
|
_ -> Nothing
|
|
|
|
intField :: Key -> J.Object -> Maybe Int
|
|
intField k o = case KM.lookup k o of
|
|
Just n -> case J.fromJSON n of
|
|
J.Success i -> Just i
|
|
J.Error _ -> Nothing
|
|
Nothing -> Nothing
|
|
|
|
jsonResponse :: Status -> J.Value -> Response
|
|
jsonResponse st v = responseLBS st [(hContentType, "application/json")] (J.encode v)
|
|
|
|
errorResponse :: Status -> Text -> Response
|
|
errorResponse st message = jsonResponse st (J.object ["code" J..= TE.decodeUtf8 (statusMessage st), "message" J..= message])
|
|
|
|
controlPost :: FakeBTCPay -> String -> J.Value -> IO ()
|
|
controlPost FakeBTCPay {fbBaseUrl, fbManager} path v = do
|
|
req <- HTTP.parseRequest (fbBaseUrl <> path)
|
|
r <- HTTP.httpLbs req {HTTP.method = methodPost, HTTP.requestBody = HTTP.RequestBodyLBS (J.encode v)} fbManager
|
|
case statusCode (HTTP.responseStatus r) of
|
|
200 -> pure ()
|
|
code -> fail ("fake btcpay " <> path <> " answered " <> show code <> ": " <> show (HTTP.responseBody r))
|
|
|
|
setInvoiceState :: FakeBTCPay -> Text -> [Pair] -> IO ()
|
|
setInvoiceState fake invId fields = controlPost fake ("/_state/" <> T.unpack invId) (J.object fields)
|
|
|
|
failNextCalls :: FakeBTCPay -> Int -> Int -> IO ()
|
|
failNextCalls = failAfterCalls 0
|
|
|
|
failAfterCalls :: Int -> FakeBTCPay -> Int -> Int -> IO ()
|
|
failAfterCalls skip fake calls code =
|
|
controlPost fake "/_fail" (J.object ["calls" J..= calls, "status" J..= code, "skip" J..= skip])
|
|
|
|
answerOversize :: FakeBTCPay -> Bool -> IO ()
|
|
answerOversize fake on = controlPost fake "/_oversize" (J.object ["on" J..= on])
|
|
|
|
useListFixture :: FakeBTCPay -> Text -> IO ()
|
|
useListFixture fake name = controlPost fake "/_fixtures" (J.object ["list" J..= name])
|
|
|
|
usePaymentMethodsFixture :: FakeBTCPay -> Text -> IO ()
|
|
usePaymentMethodsFixture fake name = controlPost fake "/_fixtures" (J.object ["paymentMethods" J..= name])
|
|
|
|
ignoreListPaging :: FakeBTCPay -> IO ()
|
|
ignoreListPaging fake = controlPost fake "/_paging" (J.object ["ignore" J..= True])
|
|
|
|
fakeRequests :: FakeBTCPay -> IO [FakeRequest]
|
|
fakeRequests FakeBTCPay {fbState} = reverse . fsRequests <$> readTVarIO fbState
|
|
|
|
fakeInvoiceIds :: FakeBTCPay -> IO [Text]
|
|
fakeInvoiceIds FakeBTCPay {fbState} = M.keys . fsInvoices <$> readTVarIO fbState
|
|
|
|
apiRequests :: FakeBTCPay -> ByteString -> [Text] -> IO [FakeRequest]
|
|
apiRequests fake verb segments = filter matching <$> fakeRequests fake
|
|
where
|
|
matching FakeRequest {frMethod, frPath} =
|
|
frMethod == verb && frPath == ["api", "v1", "stores", fakeStoreId] <> segments
|