mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 17:58:47 +00:00
347 lines
12 KiB
Haskell
347 lines
12 KiB
Haskell
{-# LANGUAGE DataKinds #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE PatternSynonyms #-}
|
|
{-# LANGUAGE TemplateHaskell #-}
|
|
|
|
module Simplex.Chat.Badges.Service
|
|
( BadgeServiceRequest (..),
|
|
BadgeServiceCommand (..),
|
|
BadgeServiceVersion,
|
|
VersionBadgeService,
|
|
pattern VersionBadgeService,
|
|
BadgeUpgrade (..),
|
|
BadgeServiceResponse (..),
|
|
BadgeServiceErrorCode (..),
|
|
BadgeCatalog (..),
|
|
BadgePrice (..),
|
|
BadgeOffer (..),
|
|
BadgeStatement (..),
|
|
BadgeBalance (..),
|
|
StatementEntry (..),
|
|
StatementEntryType (..),
|
|
StatementCreditType (..),
|
|
StatementDebitType (..),
|
|
) where
|
|
|
|
import Data.Aeson (FromJSON (..), ToJSON (..), (.:), (.:?), (.=))
|
|
import qualified Data.Aeson as J
|
|
import qualified Data.Aeson.Encoding as JE
|
|
import qualified Data.Aeson.TH as JQ
|
|
import qualified Data.Aeson.Types as JT
|
|
import Data.Text (Text)
|
|
import Data.Time.Clock (UTCTime)
|
|
import Data.Word (Word8, Word16, Word32)
|
|
import Simplex.Chat.Badges
|
|
import Simplex.Chat.Badges.Types
|
|
import Simplex.Chat.PaymentService
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import Simplex.Messaging.Encoding.String
|
|
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON)
|
|
import Simplex.Messaging.Version (VersionScope)
|
|
import Simplex.Messaging.Version.Internal (Version (..))
|
|
|
|
data BadgeServiceVersion
|
|
|
|
instance VersionScope BadgeServiceVersion
|
|
|
|
type VersionBadgeService = Version BadgeServiceVersion
|
|
|
|
pattern VersionBadgeService :: Word16 -> VersionBadgeService
|
|
pattern VersionBadgeService v = Version v
|
|
|
|
data BadgeServiceRequest = BadgeServiceRequest
|
|
{ version :: VersionBadgeService,
|
|
purchaseKey :: Maybe C.PublicKeyEd25519, -- optional for BSCGetBadgeCatalog, required for other commands
|
|
request :: BadgeServiceCommand
|
|
}
|
|
deriving (Show)
|
|
|
|
data BadgeServiceCommand
|
|
= BSCGetBadgeCatalog
|
|
| BSCGetBadgeInvoice
|
|
{ priceId :: BadgePriceId,
|
|
offerId :: Maybe BadgeOfferId, -- absent for 1 month at the badge price
|
|
badgeInfo :: BadgeInfo,
|
|
paymentVia :: ServicePaymentMethod,
|
|
upgrade :: Maybe BadgeUpgrade -- upgrade non-store badge
|
|
}
|
|
| BSCPurchaseBadge
|
|
{ badgeRequest :: BadgeRequest,
|
|
payment :: ServicePayment,
|
|
upgrade :: Maybe BadgeUpgrade -- upgrade store 1-time badge
|
|
}
|
|
| BSCUpgradeBadgeSubscription
|
|
{ badgeRequest :: BadgeRequest,
|
|
payment :: ServicePayment, -- store payments only
|
|
balance :: BadgeBalance
|
|
}
|
|
| BSCIssueBadge
|
|
{ badgeRequest :: BadgeRequest,
|
|
balance :: BadgeBalance
|
|
}
|
|
| BSCPauseBadge
|
|
deriving (Show)
|
|
|
|
data BadgeUpgrade = BadgeUpgrade
|
|
{ fromPurchaseKey :: C.PublicKeyEd25519,
|
|
receipt :: Text,
|
|
receiptSignature :: C.Signature 'C.Ed25519,
|
|
balance :: BadgeBalance
|
|
}
|
|
deriving (Show)
|
|
|
|
data BadgeServiceResponse
|
|
= BSPBadgeCatalog
|
|
{ catalog :: BadgeCatalog,
|
|
badgeStatement :: Maybe BadgeStatement -- for signed getBadgeCatalog
|
|
}
|
|
| BSPBadgeInvoice
|
|
{ invoice :: ServiceInvoice,
|
|
badgeType :: BadgeType,
|
|
months :: Word8
|
|
}
|
|
| BSPBadgeCredential
|
|
{ credential :: Maybe BadgeCredential, -- Nothing when no balance to issueBadge or no current credential for pause
|
|
receipt :: Maybe Text, -- not provided for lifetime badges
|
|
statement :: BadgeStatement
|
|
}
|
|
| BSPError
|
|
{ code :: BadgeServiceErrorCode,
|
|
message :: Maybe Text,
|
|
retryAfter :: Maybe Word32
|
|
}
|
|
deriving (Show)
|
|
|
|
data BadgeCatalog = BadgeCatalog
|
|
{ prices :: [BadgePrice],
|
|
offers :: [BadgeOffer]
|
|
}
|
|
deriving (Show)
|
|
|
|
data BadgePrice = BadgePrice
|
|
{ priceId :: BadgePriceId,
|
|
badgeType :: BadgeType,
|
|
monthPrice :: CurrencyAmount,
|
|
currency :: Text,
|
|
status :: BadgeItemStatus,
|
|
createdAt :: UTCTime
|
|
}
|
|
deriving (Show)
|
|
|
|
data BadgeOffer = BadgeOffer
|
|
{ offerId :: BadgeOfferId,
|
|
priceId :: Maybe BadgePriceId, -- absent applies to any price
|
|
months :: Word8,
|
|
discount :: OfferDiscount,
|
|
status :: BadgeItemStatus,
|
|
createdAt :: UTCTime,
|
|
total :: Maybe CurrencyAmount -- absent when the store layer hasn't computed totals yet (catalogTotals, A4); the service always fills it
|
|
}
|
|
deriving (Show)
|
|
|
|
data BadgeStatement = BadgeStatement
|
|
{ entries :: [StatementEntry],
|
|
previousEntryId :: Maybe Text -- matches the client's asserted entryId, absent for the full ledger
|
|
}
|
|
deriving (Show)
|
|
|
|
data BadgeBalance = BadgeBalance
|
|
{ lastEntry :: StatementEntry
|
|
}
|
|
deriving (Show)
|
|
|
|
data StatementEntry = StatementEntry
|
|
{ entryId :: Text,
|
|
changeMonths :: Int,
|
|
balanceMonths :: Int,
|
|
balanceStartTs :: UTCTime,
|
|
balanceBadgeType :: BadgeType,
|
|
wasPausedSince :: Maybe UTCTime,
|
|
createdAt :: UTCTime,
|
|
entryType :: StatementEntryType
|
|
}
|
|
deriving (Show)
|
|
|
|
data StatementEntryType = SECredit {credit :: StatementCreditType} | SEDebit {debit :: StatementDebitType}
|
|
deriving (Show)
|
|
|
|
data StatementCreditType
|
|
= SCPayment {invoiceId :: Maybe InvoiceId} -- absent for store and code payments
|
|
| SCCharge {chargeId :: Text} -- subscription_charges.charge_id TEXT NOT NULL PRIMARY KEY
|
|
| SCSupport
|
|
| SCTransferIn {fromPurchaseKey :: C.PublicKeyEd25519}
|
|
| SCOpening
|
|
| SCUnknown {tag :: Text, json :: J.Object}
|
|
deriving (Show)
|
|
|
|
data StatementDebitType
|
|
= SDRefund
|
|
| SDUpgrade {toPurchaseKey :: C.PublicKeyEd25519}
|
|
| SDTransferOut {toPurchaseKey :: C.PublicKeyEd25519}
|
|
| SDSupport
|
|
| SDBadge
|
|
| SDLapse
|
|
| SDUnknown {tag :: Text, json :: J.Object}
|
|
deriving (Show)
|
|
|
|
data BadgeServiceErrorCode
|
|
= BSEBadRequest
|
|
| BSEUnsupportedVersion
|
|
| BSEUnknownPurchaseKey
|
|
| BSEUnknownOfferId
|
|
| BSEOfferDisabled
|
|
| BSEOfferMismatch
|
|
| BSEProductUnavailable
|
|
| BSEPaymentNotEntitled
|
|
| BSEPaymentPending
|
|
| BSEProviderUnavailable
|
|
| BSERateLimited
|
|
| BSECodeInvalid
|
|
| BSECodeUsed
|
|
| BSECodeExpired
|
|
| BSEReceiptInvalid
|
|
| BSEReceiptUsed
|
|
| BSEInternal
|
|
| BSEUnknown Text -- forwards-compatible: service is deployed ahead of clients
|
|
deriving (Eq, Show)
|
|
|
|
instance TextEncoding BadgeServiceErrorCode where
|
|
textEncode = \case
|
|
BSEBadRequest -> "bad_request"
|
|
BSEUnsupportedVersion -> "unsupported_version"
|
|
BSEUnknownPurchaseKey -> "unknown_purchase_key"
|
|
BSEUnknownOfferId -> "unknown_offer_id"
|
|
BSEOfferDisabled -> "offer_disabled"
|
|
BSEOfferMismatch -> "offer_mismatch"
|
|
BSEProductUnavailable -> "product_unavailable"
|
|
BSEPaymentNotEntitled -> "payment_not_entitled"
|
|
BSEPaymentPending -> "payment_pending"
|
|
BSEProviderUnavailable -> "provider_unavailable"
|
|
BSERateLimited -> "rate_limited"
|
|
BSECodeInvalid -> "code_invalid"
|
|
BSECodeUsed -> "code_used"
|
|
BSECodeExpired -> "code_expired"
|
|
BSEReceiptInvalid -> "receipt_invalid"
|
|
BSEReceiptUsed -> "receipt_used"
|
|
BSEInternal -> "internal"
|
|
BSEUnknown t -> t
|
|
textDecode s = Just $ case s of
|
|
"bad_request" -> BSEBadRequest
|
|
"unsupported_version" -> BSEUnsupportedVersion
|
|
"unknown_purchase_key" -> BSEUnknownPurchaseKey
|
|
"unknown_offer_id" -> BSEUnknownOfferId
|
|
"offer_disabled" -> BSEOfferDisabled
|
|
"offer_mismatch" -> BSEOfferMismatch
|
|
"product_unavailable" -> BSEProductUnavailable
|
|
"payment_not_entitled" -> BSEPaymentNotEntitled
|
|
"payment_pending" -> BSEPaymentPending
|
|
"provider_unavailable" -> BSEProviderUnavailable
|
|
"rate_limited" -> BSERateLimited
|
|
"code_invalid" -> BSECodeInvalid
|
|
"code_used" -> BSECodeUsed
|
|
"code_expired" -> BSECodeExpired
|
|
"receipt_invalid" -> BSEReceiptInvalid
|
|
"receipt_used" -> BSEReceiptUsed
|
|
"internal" -> BSEInternal
|
|
t -> BSEUnknown t
|
|
|
|
instance ToJSON BadgeServiceErrorCode where
|
|
toJSON = textToJSON
|
|
toEncoding = textToEncoding
|
|
|
|
instance FromJSON BadgeServiceErrorCode where
|
|
parseJSON = textParseJSON "BadgeServiceErrorCode"
|
|
|
|
-- JSON
|
|
|
|
-- StatementCreditType/StatementDebitType are hand-written (not TH-derived) so that an unrecognised
|
|
-- "type" decodes into SCUnknown/SDUnknown and re-encodes verbatim from the stored object, per
|
|
-- docs/protocol/badges-rpc.md: "An unknown type is stored as received and decoded after an app upgrade."
|
|
|
|
(.=?) :: ToJSON v => J.Key -> Maybe v -> [(J.Key, J.Value)] -> [(J.Key, J.Value)]
|
|
key .=? value = maybe id ((:) . (key .=)) value
|
|
|
|
instance FromJSON StatementCreditType where
|
|
parseJSON (J.Object v) = do
|
|
tag <- v .: "type" :: JT.Parser Text
|
|
case tag of
|
|
"payment" -> SCPayment <$> v .:? "invoiceId"
|
|
"charge" -> SCCharge <$> v .: "chargeId"
|
|
"support" -> pure SCSupport
|
|
"transferIn" -> SCTransferIn <$> v .: "fromPurchaseKey"
|
|
"opening" -> pure SCOpening
|
|
_ -> pure $ SCUnknown tag v
|
|
parseJSON invalid = JT.prependFailure "bad StatementCreditType, " (JT.typeMismatch "Object" invalid)
|
|
|
|
instance ToJSON StatementCreditType where
|
|
toJSON = \case
|
|
SCUnknown {json} -> J.Object json
|
|
SCPayment {invoiceId} -> J.object $ ("invoiceId" .=? invoiceId) ["type" .= ("payment" :: Text)]
|
|
SCCharge {chargeId} -> J.object ["type" .= ("charge" :: Text), "chargeId" .= chargeId]
|
|
SCSupport -> J.object ["type" .= ("support" :: Text)]
|
|
SCTransferIn {fromPurchaseKey} -> J.object ["type" .= ("transferIn" :: Text), "fromPurchaseKey" .= fromPurchaseKey]
|
|
SCOpening -> J.object ["type" .= ("opening" :: Text)]
|
|
toEncoding = \case
|
|
SCUnknown {json} -> JE.value $ J.Object json
|
|
SCPayment {invoiceId} -> J.pairs $ "type" .= ("payment" :: Text) <> maybe mempty ("invoiceId" .=) invoiceId
|
|
SCCharge {chargeId} -> J.pairs $ "type" .= ("charge" :: Text) <> "chargeId" .= chargeId
|
|
SCSupport -> J.pairs $ "type" .= ("support" :: Text)
|
|
SCTransferIn {fromPurchaseKey} -> J.pairs $ "type" .= ("transferIn" :: Text) <> "fromPurchaseKey" .= fromPurchaseKey
|
|
SCOpening -> J.pairs $ "type" .= ("opening" :: Text)
|
|
|
|
instance FromJSON StatementDebitType where
|
|
parseJSON (J.Object v) = do
|
|
tag <- v .: "type" :: JT.Parser Text
|
|
case tag of
|
|
"refund" -> pure SDRefund
|
|
"upgrade" -> SDUpgrade <$> v .: "toPurchaseKey"
|
|
"transferOut" -> SDTransferOut <$> v .: "toPurchaseKey"
|
|
"support" -> pure SDSupport
|
|
"badge" -> pure SDBadge
|
|
"lapse" -> pure SDLapse
|
|
_ -> pure $ SDUnknown tag v
|
|
parseJSON invalid = JT.prependFailure "bad StatementDebitType, " (JT.typeMismatch "Object" invalid)
|
|
|
|
instance ToJSON StatementDebitType where
|
|
toJSON = \case
|
|
SDUnknown {json} -> J.Object json
|
|
SDRefund -> J.object ["type" .= ("refund" :: Text)]
|
|
SDUpgrade {toPurchaseKey} -> J.object ["type" .= ("upgrade" :: Text), "toPurchaseKey" .= toPurchaseKey]
|
|
SDTransferOut {toPurchaseKey} -> J.object ["type" .= ("transferOut" :: Text), "toPurchaseKey" .= toPurchaseKey]
|
|
SDSupport -> J.object ["type" .= ("support" :: Text)]
|
|
SDBadge -> J.object ["type" .= ("badge" :: Text)]
|
|
SDLapse -> J.object ["type" .= ("lapse" :: Text)]
|
|
toEncoding = \case
|
|
SDUnknown {json} -> JE.value $ J.Object json
|
|
SDRefund -> J.pairs $ "type" .= ("refund" :: Text)
|
|
SDUpgrade {toPurchaseKey} -> J.pairs $ "type" .= ("upgrade" :: Text) <> "toPurchaseKey" .= toPurchaseKey
|
|
SDTransferOut {toPurchaseKey} -> J.pairs $ "type" .= ("transferOut" :: Text) <> "toPurchaseKey" .= toPurchaseKey
|
|
SDSupport -> J.pairs $ "type" .= ("support" :: Text)
|
|
SDBadge -> J.pairs $ "type" .= ("badge" :: Text)
|
|
SDLapse -> J.pairs $ "type" .= ("lapse" :: Text)
|
|
|
|
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "SE") ''StatementEntryType)
|
|
|
|
$(JQ.deriveJSON defaultJSON ''StatementEntry)
|
|
|
|
$(JQ.deriveJSON defaultJSON ''BadgeBalance)
|
|
|
|
$(JQ.deriveJSON defaultJSON ''BadgeStatement)
|
|
|
|
$(JQ.deriveJSON defaultJSON ''BadgePrice)
|
|
|
|
$(JQ.deriveJSON defaultJSON ''BadgeOffer)
|
|
|
|
$(JQ.deriveJSON defaultJSON ''BadgeCatalog)
|
|
|
|
$(JQ.deriveJSON defaultJSON ''BadgeUpgrade)
|
|
|
|
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "BSP") ''BadgeServiceResponse)
|
|
|
|
$(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "BSC") ''BadgeServiceCommand)
|
|
|
|
$(JQ.deriveJSON defaultJSON ''BadgeServiceRequest)
|