mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 17:17:39 +00:00
155 lines
5.9 KiB
Haskell
155 lines
5.9 KiB
Haskell
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
module StoreService.Catalog
|
|
( OfferInvalid (..),
|
|
CatalogRefusal (..),
|
|
offerTotal,
|
|
PricedOffer (..),
|
|
priceOffer,
|
|
catalogCurrency,
|
|
defaultCatalog,
|
|
StoreProduct (..),
|
|
storeProduct,
|
|
)
|
|
where
|
|
|
|
import Data.List (find)
|
|
import Data.Text (Text)
|
|
import Data.Time.Clock (UTCTime)
|
|
import Data.Word (Word32, Word64, Word8)
|
|
import Simplex.Chat.Badges (BadgeType (..))
|
|
import Simplex.Chat.StoreService (BadgeOffer (..), BadgePrice (..))
|
|
import Simplex.Chat.Badges.Types (BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..))
|
|
import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), PaymentProvider (..))
|
|
|
|
data OfferInvalid = OIZeroMonths | OIFreeMonthsExceedTerm | OIDiscountTooLarge | OIAmountUnsellable
|
|
deriving (Eq, Show)
|
|
|
|
offerTotal :: CurrencyAmount -> Maybe BadgeOffer -> Either OfferInvalid (Word8, CurrencyAmount, CurrencyAmount)
|
|
offerTotal (CurrencyAmount p) = \case
|
|
Nothing -> charge 1 (gross 1)
|
|
Just BadgeOffer {months, discount}
|
|
| months == 0 -> Left OIZeroMonths
|
|
| otherwise -> case discount of
|
|
ODFreeMonths f
|
|
| f >= months -> Left OIFreeMonthsExceedTerm
|
|
| otherwise -> charge months (gross (months - f))
|
|
ODDiscount d
|
|
| d >= 100 -> Left OIDiscountTooLarge
|
|
| otherwise -> charge months (gross months * (100 - fromIntegral d) `div` 100)
|
|
where
|
|
gross :: Word8 -> Word64
|
|
gross m = fromIntegral p * fromIntegral m
|
|
maxAmount :: Word64 -- $1,000,000 in minor units
|
|
maxAmount = 100000000
|
|
charge :: Word8 -> Word64 -> Either OfferInvalid (Word8, CurrencyAmount, CurrencyAmount)
|
|
charge m c
|
|
-- Bound gross too, not just the charge, or a large price wraps the Word32 amount field.
|
|
| c == 0 || c > maxAmount || gross m > maxAmount = Left OIAmountUnsellable
|
|
| otherwise = Right (m, CurrencyAmount (fromIntegral (gross m)), CurrencyAmount (fromIntegral c))
|
|
|
|
data PricedOffer = PricedOffer
|
|
{ poBadgeType :: BadgeType,
|
|
poMonths :: Word8,
|
|
poPrice :: CurrencyAmount,
|
|
poAmount :: CurrencyAmount,
|
|
poCurrency :: Text
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
data CatalogRefusal
|
|
= CRUnknownPrice
|
|
| CRDisabledPrice
|
|
| CRUnknownOffer
|
|
| CRDisabledOffer
|
|
| CROfferNotForPrice
|
|
| CRUnsoldBadgeType BadgeType
|
|
| CRUnpriced OfferInvalid
|
|
deriving (Eq, Show)
|
|
|
|
priceOffer :: [BadgePrice] -> [BadgeOffer] -> BadgePriceId -> Maybe BadgeOfferId -> Either CatalogRefusal PricedOffer
|
|
priceOffer prices offers wantedPriceId wantedOfferId = do
|
|
BadgePrice {badgeType, monthPrice, currency, status = priceStatus} <- known CRUnknownPrice (findPrice wantedPriceId)
|
|
soldBadgeType badgeType
|
|
active CRDisabledPrice priceStatus
|
|
chosenOffer <- resolveOffer wantedPriceId wantedOfferId
|
|
(months, price, amount) <- either (Left . CRUnpriced) Right (offerTotal monthPrice chosenOffer)
|
|
pure PricedOffer {poBadgeType = badgeType, poMonths = months, poPrice = price, poAmount = amount, poCurrency = currency}
|
|
where
|
|
known refusal = maybe (Left refusal) Right
|
|
findPrice p = find (\BadgePrice {priceId} -> priceId == p) prices
|
|
findOffer o = find (\BadgeOffer {offerId} -> offerId == o) offers
|
|
soldBadgeType bt
|
|
| bt == BTSupporter || bt == BTLegend = Right ()
|
|
| otherwise = Left (CRUnsoldBadgeType bt)
|
|
active _ BISActive = Right ()
|
|
active _ BISDeprecated = Right ()
|
|
active refusal BISDisabled = Left refusal
|
|
resolveOffer _ Nothing = Right Nothing
|
|
resolveOffer p (Just o) = do
|
|
chosen@BadgeOffer {priceId = offerPriceId, status = offerStatus} <- known CRUnknownOffer (findOffer o)
|
|
active CRDisabledOffer offerStatus
|
|
case offerPriceId of
|
|
Nothing -> Right (Just chosen)
|
|
Just op
|
|
| op == p -> Right (Just chosen)
|
|
| otherwise -> Left CROfferNotForPrice
|
|
|
|
catalogCurrency :: Text
|
|
catalogCurrency = "usd"
|
|
|
|
-- | Mirrors web/src/catalog.ts; seeded insert-only, so repricing needs a new price id.
|
|
defaultCatalog :: UTCTime -> ([BadgePrice], [BadgeOffer])
|
|
defaultCatalog seededAt = (prices, offers)
|
|
where
|
|
prices =
|
|
[ mkPrice "price_supporter" BTSupporter 700,
|
|
mkPrice "price_legend" BTLegend 7000
|
|
]
|
|
offers =
|
|
[ mkOffer "offer_3m" "price_legend" 3 (ODFreeMonths 1),
|
|
mkOffer "offer_12m" "price_legend" 12 (ODDiscount 50),
|
|
mkOffer "offer_3m_s" "price_supporter" 3 (ODFreeMonths 1),
|
|
mkOffer "offer_12m_s" "price_supporter" 12 (ODDiscount 50)
|
|
]
|
|
mkPrice :: Text -> BadgeType -> Word32 -> BadgePrice
|
|
mkPrice pId bType mPrice =
|
|
BadgePrice
|
|
{ priceId = BadgePriceId pId,
|
|
badgeType = bType,
|
|
monthPrice = CurrencyAmount mPrice,
|
|
currency = catalogCurrency,
|
|
status = BISActive,
|
|
createdAt = seededAt
|
|
}
|
|
mkOffer :: Text -> Text -> Word8 -> OfferDiscount -> BadgeOffer
|
|
mkOffer oId pId mMonths mDiscount =
|
|
BadgeOffer
|
|
{ offerId = BadgeOfferId oId,
|
|
priceId = Just (BadgePriceId pId),
|
|
months = mMonths,
|
|
discount = mDiscount,
|
|
status = BISActive,
|
|
createdAt = seededAt
|
|
}
|
|
|
|
data StoreProduct = StoreProduct
|
|
{ badgeType :: BadgeType,
|
|
months :: Int
|
|
}
|
|
deriving (Eq, Show)
|
|
|
|
-- | A receipt proves only the product paid for, so the badge comes from here, never from the client.
|
|
-- Mirrors the one-time SKUs of BadgeStore.swift and .kt; subscriptions wait until renewals add months.
|
|
-- Not part of the seeded catalog above, which exists to pin prices this service sets: a store sets its own.
|
|
storeProduct :: PaymentProvider -> Text -> Maybe StoreProduct
|
|
storeProduct provider productId = case (provider, productId) of
|
|
(PPApple, "BADGE_SUPPORTER_01") -> Just $ StoreProduct BTSupporter 1
|
|
(PPApple, "BADGE_LEGEND_01") -> Just $ StoreProduct BTLegend 1
|
|
(PPGoogle, "badge_supporter_01") -> Just $ StoreProduct BTSupporter 1
|
|
(PPGoogle, "badge_legend_01") -> Just $ StoreProduct BTLegend 1
|
|
_ -> Nothing
|