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>
309 lines
14 KiB
Haskell
309 lines
14 KiB
Haskell
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
module Bots.BadgeService.CatalogTests where
|
|
|
|
import BadgeService.Catalog
|
|
import Control.Monad (when)
|
|
import Data.List (find)
|
|
import qualified Data.Map.Strict as Map
|
|
import qualified Data.Text as T
|
|
import qualified Data.Text.IO as T
|
|
import Data.Text.Read (decimal)
|
|
import Data.Time.Clock (UTCTime)
|
|
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
|
|
import Data.Word (Word32, Word8)
|
|
import Simplex.Chat.Badges (BadgeType (..))
|
|
import Simplex.Chat.Badges.Service (BadgeOffer (..), BadgePrice (..))
|
|
import Simplex.Chat.Badges.Types (BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..))
|
|
import Simplex.Chat.PaymentService.Types (CurrencyAmount (..))
|
|
import Test.Hspec
|
|
|
|
badgeCatalogTests :: Spec
|
|
badgeCatalogTests = describe "badge catalog" $ do
|
|
it "prices the six sellable pairs at their literal totals" testLiteralTotals
|
|
it "delivers the free months it does not charge for" testFreeMonths
|
|
it "truncates a percentage discount in the buyer's favour" testDiscountTruncates
|
|
it "refuses a pair it cannot price" testRefusals
|
|
it "refuses a gross that would wrap, even where the charge is sellable" testGrossIsBounded
|
|
it "agrees with the catalog compiled into the page" testNoDriftFromWeb
|
|
it "refuses an unknown priceId" testPriceOfferUnknownPrice
|
|
it "refuses a badge type this service does not sell" testPriceOfferUnsoldBadgeType
|
|
it "refuses a disabled price" testPriceOfferDisabledPrice
|
|
it "refuses an unknown offerId" testPriceOfferUnknownOffer
|
|
it "refuses a disabled offer" testPriceOfferDisabledOffer
|
|
it "refuses an offer that names a different price" testPriceOfferMismatchedPrice
|
|
it "refuses a pair offerTotal itself cannot price" testPriceOfferUnpriced
|
|
it "refuses on the badge type before it checks whether the price is disabled" testPriceOfferOrderUnsoldBeforeDisabled
|
|
it "prices a deprecated price rather than refusing it" testPriceOfferDeprecatedPricePriced
|
|
it "prices a deprecated offer rather than refusing it" testPriceOfferDeprecatedOfferPriced
|
|
it "carries the term's gross, not the per-month rate, for a free-months offer" testPriceOfferGrossFreeMonths
|
|
it "carries the term's gross, not the per-month rate, for a percentage-discount offer" testPriceOfferGrossDiscount
|
|
it "applies an offer with no priceId to any price" testPriceOfferAnyPrice
|
|
|
|
offer :: Word8 -> OfferDiscount -> BadgeOffer
|
|
offer months discount =
|
|
BadgeOffer {offerId = BadgeOfferId "o", priceId = Nothing, months, discount, status = BISActive, createdAt = epoch}
|
|
|
|
epoch :: UTCTime
|
|
epoch = posixSecondsToUTCTime 0
|
|
|
|
testLiteralTotals :: IO ()
|
|
testLiteralTotals = do
|
|
amountOf 700 Nothing `shouldBe` Right 700
|
|
amountOf 700 (Just (offer 3 (ODFreeMonths 1))) `shouldBe` Right 1400
|
|
amountOf 700 (Just (offer 12 (ODDiscount 50))) `shouldBe` Right 4200
|
|
amountOf 7000 Nothing `shouldBe` Right 7000
|
|
amountOf 7000 (Just (offer 3 (ODFreeMonths 1))) `shouldBe` Right 14000
|
|
amountOf 7000 (Just (offer 12 (ODDiscount 50))) `shouldBe` Right 42000
|
|
where
|
|
amountOf p o = (\(_, _, CurrencyAmount a) -> a) <$> offerTotal (CurrencyAmount p) o
|
|
|
|
testFreeMonths :: IO ()
|
|
testFreeMonths =
|
|
offerTotal (CurrencyAmount 7000) (Just (offer 3 (ODFreeMonths 1)))
|
|
`shouldBe` Right (3, CurrencyAmount 21000, CurrencyAmount 14000)
|
|
|
|
testDiscountTruncates :: IO ()
|
|
testDiscountTruncates =
|
|
offerTotal (CurrencyAmount 333) (Just (offer 3 (ODDiscount 33)))
|
|
`shouldBe` Right (3, CurrencyAmount 999, CurrencyAmount 669)
|
|
|
|
testRefusals :: IO ()
|
|
testRefusals = do
|
|
offerTotal (CurrencyAmount 700) (Just (offer 0 (ODDiscount 10))) `shouldBe` Left OIZeroMonths
|
|
offerTotal (CurrencyAmount 700) (Just (offer 3 (ODFreeMonths 3))) `shouldBe` Left OIFreeMonthsExceedTerm
|
|
offerTotal (CurrencyAmount 700) (Just (offer 3 (ODDiscount 100))) `shouldBe` Left OIDiscountTooLarge
|
|
offerTotal (CurrencyAmount 0) Nothing `shouldBe` Left OIAmountUnsellable
|
|
offerTotal (CurrencyAmount 20000000) (Just (offer 12 (ODDiscount 50))) `shouldBe` Left OIAmountUnsellable
|
|
|
|
testGrossIsBounded :: IO ()
|
|
testGrossIsBounded = do
|
|
let chargedOf p o = (\(_, _, CurrencyAmount a) -> a) <$> offerTotal (CurrencyAmount p) o
|
|
chargedOf 100000000 (Just (offer 1 (ODDiscount 0))) `shouldBe` Right 100000000
|
|
offerTotal (CurrencyAmount 100000000) (Just (offer 43 (ODFreeMonths 42))) `shouldBe` Left OIAmountUnsellable
|
|
offerTotal (CurrencyAmount 100000000) (Just (offer 2 (ODDiscount 50))) `shouldBe` Left OIAmountUnsellable
|
|
offerTotal (CurrencyAmount 100000000) Nothing
|
|
`shouldBe` Right (1, CurrencyAmount 100000000, CurrencyAmount 100000000)
|
|
|
|
mkPrice :: T.Text -> BadgeType -> Word32 -> BadgeItemStatus -> BadgePrice
|
|
mkPrice priceIdText priceBadgeType monthPriceAmount priceStatus =
|
|
BadgePrice
|
|
{ priceId = BadgePriceId priceIdText,
|
|
badgeType = priceBadgeType,
|
|
monthPrice = CurrencyAmount monthPriceAmount,
|
|
currency = "usd",
|
|
status = priceStatus,
|
|
createdAt = epoch
|
|
}
|
|
|
|
mkOffer :: T.Text -> Maybe T.Text -> Word8 -> OfferDiscount -> BadgeItemStatus -> BadgeOffer
|
|
mkOffer offerIdText offerPriceIdText offerMonths offerDiscount offerStatus =
|
|
BadgeOffer
|
|
{ offerId = BadgeOfferId offerIdText,
|
|
priceId = BadgePriceId <$> offerPriceIdText,
|
|
months = offerMonths,
|
|
discount = offerDiscount,
|
|
status = offerStatus,
|
|
createdAt = epoch
|
|
}
|
|
|
|
testPriceOfferUnknownPrice :: IO ()
|
|
testPriceOfferUnknownPrice =
|
|
priceOffer [] [] (BadgePriceId "missing") Nothing `shouldBe` Left CRUnknownPrice
|
|
|
|
testPriceOfferUnsoldBadgeType :: IO ()
|
|
testPriceOfferUnsoldBadgeType =
|
|
priceOffer [mkPrice "p1" BTInvestor 1000 BISActive] [] (BadgePriceId "p1") Nothing
|
|
`shouldBe` Left (CRUnsoldBadgeType BTInvestor)
|
|
|
|
testPriceOfferDisabledPrice :: IO ()
|
|
testPriceOfferDisabledPrice =
|
|
priceOffer [mkPrice "p1" BTSupporter 700 BISDisabled] [] (BadgePriceId "p1") Nothing
|
|
`shouldBe` Left CRDisabledPrice
|
|
|
|
testPriceOfferUnknownOffer :: IO ()
|
|
testPriceOfferUnknownOffer =
|
|
priceOffer [mkPrice "p1" BTSupporter 700 BISActive] [] (BadgePriceId "p1") (Just (BadgeOfferId "missing"))
|
|
`shouldBe` Left CRUnknownOffer
|
|
|
|
testPriceOfferDisabledOffer :: IO ()
|
|
testPriceOfferDisabledOffer =
|
|
priceOffer
|
|
[mkPrice "p1" BTSupporter 700 BISActive]
|
|
[mkOffer "o1" (Just "p1") 3 (ODFreeMonths 1) BISDisabled]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Left CRDisabledOffer
|
|
|
|
testPriceOfferMismatchedPrice :: IO ()
|
|
testPriceOfferMismatchedPrice =
|
|
priceOffer
|
|
[mkPrice "p1" BTSupporter 700 BISActive, mkPrice "p2" BTSupporter 700 BISActive]
|
|
[mkOffer "o1" (Just "p2") 3 (ODFreeMonths 1) BISActive]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Left CROfferNotForPrice
|
|
|
|
testPriceOfferUnpriced :: IO ()
|
|
testPriceOfferUnpriced =
|
|
priceOffer
|
|
[mkPrice "p1" BTSupporter 700 BISActive]
|
|
[mkOffer "o1" (Just "p1") 0 (ODDiscount 10) BISActive]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Left (CRUnpriced OIZeroMonths)
|
|
|
|
testPriceOfferOrderUnsoldBeforeDisabled :: IO ()
|
|
testPriceOfferOrderUnsoldBeforeDisabled =
|
|
priceOffer [mkPrice "p1" BTInvestor 1000 BISDisabled] [] (BadgePriceId "p1") Nothing
|
|
`shouldBe` Left (CRUnsoldBadgeType BTInvestor)
|
|
|
|
testPriceOfferDeprecatedPricePriced :: IO ()
|
|
testPriceOfferDeprecatedPricePriced =
|
|
priceOffer [mkPrice "p1" BTSupporter 700 BISDeprecated] [] (BadgePriceId "p1") Nothing
|
|
`shouldBe` Right PricedOffer {poBadgeType = BTSupporter, poMonths = 1, poPrice = CurrencyAmount 700, poAmount = CurrencyAmount 700, poCurrency = "usd"}
|
|
|
|
testPriceOfferDeprecatedOfferPriced :: IO ()
|
|
testPriceOfferDeprecatedOfferPriced =
|
|
priceOffer
|
|
[mkPrice "p1" BTSupporter 700 BISActive]
|
|
[mkOffer "o1" (Just "p1") 3 (ODFreeMonths 1) BISDeprecated]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Right PricedOffer {poBadgeType = BTSupporter, poMonths = 3, poPrice = CurrencyAmount 2100, poAmount = CurrencyAmount 1400, poCurrency = "usd"}
|
|
|
|
testPriceOfferGrossFreeMonths :: IO ()
|
|
testPriceOfferGrossFreeMonths =
|
|
priceOffer
|
|
[mkPrice "p1" BTLegend 7000 BISActive]
|
|
[mkOffer "o1" (Just "p1") 3 (ODFreeMonths 1) BISActive]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Right PricedOffer {poBadgeType = BTLegend, poMonths = 3, poPrice = CurrencyAmount 21000, poAmount = CurrencyAmount 14000, poCurrency = "usd"}
|
|
|
|
testPriceOfferGrossDiscount :: IO ()
|
|
testPriceOfferGrossDiscount =
|
|
priceOffer
|
|
[mkPrice "p1" BTLegend 7000 BISActive]
|
|
[mkOffer "o1" (Just "p1") 12 (ODDiscount 50) BISActive]
|
|
(BadgePriceId "p1")
|
|
(Just (BadgeOfferId "o1"))
|
|
`shouldBe` Right PricedOffer {poBadgeType = BTLegend, poMonths = 12, poPrice = CurrencyAmount 84000, poAmount = CurrencyAmount 42000, poCurrency = "usd"}
|
|
|
|
testPriceOfferAnyPrice :: IO ()
|
|
testPriceOfferAnyPrice = do
|
|
let prices = [mkPrice "p1" BTSupporter 700 BISActive, mkPrice "p2" BTSupporter 700 BISActive]
|
|
offers = [mkOffer "o1" Nothing 3 (ODFreeMonths 1) BISActive]
|
|
expected = Right PricedOffer {poBadgeType = BTSupporter, poMonths = 3, poPrice = CurrencyAmount 2100, poAmount = CurrencyAmount 1400, poCurrency = "usd"}
|
|
priceOffer prices offers (BadgePriceId "p1") (Just (BadgeOfferId "o1")) `shouldBe` expected
|
|
priceOffer prices offers (BadgePriceId "p2") (Just (BadgeOfferId "o1")) `shouldBe` expected
|
|
|
|
testNoDriftFromWeb :: IO ()
|
|
testNoDriftFromWeb = do
|
|
src <- T.readFile "apps/simplex-badge-service/web/src/catalog.ts"
|
|
case parseCatalogSource src of
|
|
Nothing -> expectationFailure "could not parse CATALOG out of web/src/catalog.ts -- its shape has changed"
|
|
Just (webPrices, webOffers) -> do
|
|
when (null webPrices || null webOffers) $
|
|
expectationFailure "parsed zero prices or offers out of web/src/catalog.ts -- its shape has changed"
|
|
mapM_ checkPrice webPrices
|
|
mapM_ (checkOffer webPrices) webOffers
|
|
where
|
|
literalTotals :: Map.Map T.Text (Word32, Word32, Word32)
|
|
literalTotals = Map.fromList [("supporter", (700, 1400, 4200)), ("legend", (7000, 14000, 42000))]
|
|
checkPrice :: WebPrice -> IO ()
|
|
checkPrice WebPrice {wpPriceId, wpBadgeType, wpMonthPrice} =
|
|
case Map.lookup wpBadgeType literalTotals of
|
|
Nothing -> expectationFailure ("no literal total for badge type " <> T.unpack wpBadgeType <> " (price " <> T.unpack wpPriceId <> ")")
|
|
Just (base, _, _) ->
|
|
offerTotal (CurrencyAmount wpMonthPrice) Nothing
|
|
`shouldBe` Right (1, CurrencyAmount wpMonthPrice, CurrencyAmount base)
|
|
checkOffer :: [WebPrice] -> WebOffer -> IO ()
|
|
checkOffer webPrices WebOffer {woPriceId, woMonths, woDiscount} =
|
|
case find (\WebPrice {wpPriceId} -> wpPriceId == woPriceId) webPrices of
|
|
Nothing -> expectationFailure ("offer names price " <> T.unpack woPriceId <> ", which is not in the catalog")
|
|
Just WebPrice {wpBadgeType, wpMonthPrice} -> case Map.lookup wpBadgeType literalTotals of
|
|
Nothing -> expectationFailure ("no literal total for badge type " <> T.unpack wpBadgeType)
|
|
Just (_, threeMonths, twelveMonths) -> do
|
|
expectedAmount <- case woMonths of
|
|
3 -> pure threeMonths
|
|
12 -> pure twelveMonths
|
|
n -> expectationFailure ("no literal total for a " <> show n <> "-month offer") >> pure 0
|
|
offerTotal (CurrencyAmount wpMonthPrice) (Just (offer woMonths woDiscount))
|
|
`shouldBe` Right (woMonths, CurrencyAmount (wpMonthPrice * fromIntegral woMonths), CurrencyAmount expectedAmount)
|
|
|
|
data WebPrice = WebPrice {wpPriceId :: T.Text, wpBadgeType :: T.Text, wpMonthPrice :: Word32, wpCurrency :: T.Text}
|
|
deriving (Eq, Ord, Show)
|
|
|
|
data WebOffer = WebOffer {woOfferId :: T.Text, woPriceId :: T.Text, woMonths :: Word8, woDiscount :: OfferDiscount}
|
|
deriving (Eq, Show)
|
|
|
|
parseCatalogSource :: T.Text -> Maybe ([WebPrice], [WebOffer])
|
|
parseCatalogSource src = do
|
|
pricesBlock <- betweenMarkers "prices: [" "]" src
|
|
offersBlock <- betweenMarkers "offers: [" "]" src
|
|
prices <- mapM parseWebPrice (topLevelObjects pricesBlock)
|
|
offers <- mapM parseWebOffer (topLevelObjects offersBlock)
|
|
pure (prices, offers)
|
|
|
|
parseWebPrice :: T.Text -> Maybe WebPrice
|
|
parseWebPrice obj =
|
|
WebPrice
|
|
<$> fieldStr "priceId" obj
|
|
<*> fieldStr "badgeType" obj
|
|
<*> (fieldNum "monthPrice" obj :: Maybe Word32)
|
|
<*> fieldStr "currency" obj
|
|
|
|
parseWebOffer :: T.Text -> Maybe WebOffer
|
|
parseWebOffer obj = do
|
|
wOfferId <- fieldStr "offerId" obj
|
|
wPriceId <- fieldStr "priceId" obj
|
|
wMonths <- (fieldNum "months" obj :: Maybe Word8)
|
|
discObj <- betweenMarkers "discount: {" "}" obj
|
|
discType <- fieldStr "type" discObj
|
|
wDiscount <- case discType of
|
|
"freeMonths" -> ODFreeMonths <$> (fieldNum "freeMonths" discObj :: Maybe Word8)
|
|
"discount" -> ODDiscount <$> (fieldNum "discount" discObj :: Maybe Word8)
|
|
_ -> Nothing
|
|
pure WebOffer {woOfferId = wOfferId, woPriceId = wPriceId, woMonths = wMonths, woDiscount = wDiscount}
|
|
|
|
topLevelObjects :: T.Text -> [T.Text]
|
|
topLevelObjects = go (0 :: Int) "" . T.unpack
|
|
where
|
|
go :: Int -> String -> String -> [T.Text]
|
|
go _ _ [] = []
|
|
go 0 _ (c : cs)
|
|
| c == '{' = go 1 [c] cs
|
|
| otherwise = go 0 "" cs
|
|
go depth buf (c : cs)
|
|
| c == '{' = go (depth + 1) (c : buf) cs
|
|
| c == '}' && depth == 1 = T.pack (reverse (c : buf)) : go 0 "" cs
|
|
| c == '}' = go (depth - 1) (c : buf) cs
|
|
| otherwise = go depth (c : buf) cs
|
|
|
|
betweenMarkers :: T.Text -> T.Text -> T.Text -> Maybe T.Text
|
|
betweenMarkers start end t = do
|
|
let (_, afterStart0) = T.breakOn start t
|
|
afterStart <- if T.null afterStart0 then Nothing else Just (T.drop (T.length start) afterStart0)
|
|
let (block, endPart) = T.breakOn end afterStart
|
|
if T.null endPart then Nothing else Just block
|
|
|
|
fieldStr :: T.Text -> T.Text -> Maybe T.Text
|
|
fieldStr key obj = do
|
|
let marker = key <> ": \""
|
|
(_, afterMarker0) = T.breakOn marker obj
|
|
afterMarker <- if T.null afterMarker0 then Nothing else Just (T.drop (T.length marker) afterMarker0)
|
|
let (val, rest) = T.breakOn "\"" afterMarker
|
|
if T.null rest then Nothing else Just val
|
|
|
|
fieldNum :: Integral a => T.Text -> T.Text -> Maybe a
|
|
fieldNum key obj = do
|
|
let marker = key <> ": "
|
|
(_, afterMarker0) = T.breakOn marker obj
|
|
afterMarker <- if T.null afterMarker0 then Nothing else Just (T.drop (T.length marker) afterMarker0)
|
|
case decimal afterMarker of
|
|
Right (n, _) -> Just n
|
|
Left _ -> Nothing
|