ui: buy a badge in the app through the App Store or Google Play (#7608)

* ui: buy a badge in the app through the App Store or Google Play

* core, ios, android: create the badge store purchase record before the store charges

* core, ios, android: fixes from the store lane review

* ios, android: show a store purchase's badge only on the profile it was credited to
This commit is contained in:
spaced4ndy
2026-10-01 08:46:35 +00:00
committed by GitHub
parent 0ee34926bc
commit e3e4709381
38 changed files with 1607 additions and 343 deletions
+11
View File
@@ -30,6 +30,7 @@ module Simplex.Chat.Badges.Types
BadgeAlert (..),
BadgeAlertPrice (..),
BadgeState (..),
BadgeStorePurchase (..),
) where
import Control.Applicative ((<|>))
@@ -282,6 +283,14 @@ data BadgeState = BadgeState
}
deriving (Show)
-- | A store purchase this profile started or presented, neither credited nor closed. Neither
-- reference is a secret: Apple's is the transaction id, Google's is a hash of the token.
data BadgeStorePurchase = BadgeStorePurchase
{ invoiceId :: Maybe Text,
transactionRef :: Maybe Text
}
deriving (Show)
instance TextEncoding BadgePurchaseStatus where
textEncode = \case
PSAcquiring -> "acquiring"
@@ -413,3 +422,5 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "BIF") ''BadgeIssueFailure)
$(JQ.deriveJSON defaultJSON ''BadgeIssueError)
$(JQ.deriveJSON defaultJSON ''BadgeState)
$(JQ.deriveJSON defaultJSON ''BadgeStorePurchase)
+6 -3
View File
@@ -85,7 +85,7 @@ import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyM
import qualified Simplex.Messaging.Crypto as C
import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge)
import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry)
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..))
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..), BadgeStorePurchase (..))
import Simplex.Chat.PaymentService (ServicePayment)
import Simplex.Messaging.Crypto.BBS (BBSPublicKey)
import Simplex.Messaging.Crypto.File (CryptoFile (..))
@@ -660,7 +660,9 @@ data ChatCommand
| UpdateProfileImageFromFile FilePath -- set profile image from a .png/.jpg/.jpeg file
| AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`)
| APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service
| APIPurchaseBadge {userId :: UserId, payment :: ServicePayment} -- redeem an App Store or Google Play purchase for a badge
| APIPurchaseBadge {userId :: UserId, echoedInvoiceId :: Maybe Text, payment :: ServicePayment} -- redeem an App Store or Google Play purchase for a badge
| APICreateBadgeInvoice {userId :: UserId} -- the record of a store purchase, created before the store charges; answers the id the store echoes
| APICloseBadgeInvoice {userId :: UserId, invoiceId :: Text} -- a store purchase that came to nothing
| APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert
| APIGetBadgeLedger {userId :: UserId, badgePurchaseId :: Int64} -- the purchase's ledger, oldest first
-- episode is last because it is free text: it is the value that makes one occurrence of an
@@ -871,7 +873,8 @@ data ChatResponse
| CRServiceResponse {user :: User, responseData :: J.Object}
| CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId}
| CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState}
| CRBadgeState {user :: User, badgeState :: Maybe BadgeState}
| CRBadgeInvoice {user :: User, invoiceId :: Text}
| CRBadgeState {user :: User, badgeState :: Maybe BadgeState, storePurchases :: [BadgeStorePurchase]}
| CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]}
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
+37 -11
View File
@@ -3563,11 +3563,23 @@ processChatCommand cxt nm = \case
ShowProfile -> withUser $ \user@User {profile} -> pure $ CRUserProfile user (fromLocalProfile profile)
AddBadge cred -> withUser $ \user -> addUserBadge user cred >> ok user
APIRedeemBadgeCode userId codeText -> withUserId userId $ \user -> redeemBadgeCode nm user codeText
APIPurchaseBadge userId payment -> withUserId userId $ \user -> purchaseBadge nm user payment
APIPurchaseBadge userId echoedInvoiceId payment -> withUserId userId $ \user -> purchaseBadge nm user echoedInvoiceId payment
APICreateBadgeInvoice userId -> withUserId userId $ \user -> do
void badgeServiceTarget
refuseWhileBadgeHeld user
g <- asks random
now <- liftIO getCurrentTime
invoiceId <- UUID.toText <$> liftIO V4.nextRandom
_ <- withStore' $ \db -> createBadgeStoreReceipt db g user (Just invoiceId) Nothing now
pure $ CRBadgeInvoice user invoiceId
APICloseBadgeInvoice userId invoiceId -> withUserId userId $ \user -> do
now <- liftIO getCurrentTime
withStore' $ \db -> closeBadgeStoreInvoice db user invoiceId now
ok user
APIGetBadgeState userId -> withUserId' userId $ \user -> do
-- the read also signals the worker, whose results follow as CEvtBadgeChanged
lift $ startBadgeWork user
CRBadgeState user <$> getUserBadgeState user
badgeStateResponse user
APIGetBadgeLedger userId badgePurchaseId -> withUserId userId $ \user ->
CRBadgeLedger user <$> withStore' (\db -> getBadgeLedger db user badgePurchaseId)
APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do
@@ -3576,7 +3588,7 @@ processChatCommand cxt nm = \case
withStore' $ \db -> setBadgeAlertAcked db user badgePurchaseId alertKind episode snoozeUntil
-- after the write, so the pass it signals arms a wake for the snooze rather than raising again
lift $ startBadgeWork user
CRBadgeState user <$> getUserBadgeState user
badgeStateResponse user
SetBotCommands commands -> withUser $ \user@User {profile} -> do
let LocalProfile {preferences} = profile
prefs = Just (fromMaybe emptyChatPrefs preferences :: Preferences) {commands = Just commands}
@@ -5221,7 +5233,7 @@ presentUserBadgeToContacts user'@User {userId, profile = LocalProfile {localBadg
redeemBadgeCode :: NetworkRequestMode -> User -> Text -> CM ChatResponse
redeemBadgeCode nm user@User {userId} codeText = do
code <- maybe (throwRedeemError BREInvalidCode) pure $ parseBadgeCode codeText
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure
sendTarget <- badgeServiceTarget
g <- asks random
now <- liftIO getCurrentTime
let codeSent = badgeCodeText code
@@ -5244,16 +5256,16 @@ redeemBadgeCode nm user@User {userId} codeText = do
-- | The app presents a purchase until this returns its badge, under whichever profile is active; the
-- stash stays with the profile it was first presented under, so every retry is the signer first credited.
purchaseBadge :: NetworkRequestMode -> User -> ServicePayment -> CM ChatResponse
purchaseBadge nm presentingUser payment = do
purchaseBadge :: NetworkRequestMode -> User -> Maybe Text -> ServicePayment -> CM ChatResponse
purchaseBadge nm presentingUser echoedInvoiceId payment = do
txRef <- maybe (throwRedeemError BREInvalidReceipt) pure $ storeTransactionRef payment
sendTarget <- asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure
sendTarget <- badgeServiceTarget
g <- asks random
now <- liftIO getCurrentTime
user@User {userId} <- withStore $ \db -> liftIO (getBadgeStoreReceiptUserId db txRef) >>= maybe (pure presentingUser) (getUser db)
user@User {userId} <- withStore $ \db -> liftIO (attachBadgeStoreReceipt db echoedInvoiceId txRef) >>= maybe (pure presentingUser) (getUser db)
(present_, purchased) <- withEntityLock "badgePurchase" (CLBadgeUser userId) $ do
stash_ <- withStore' $ \db -> getBadgeStoreReceipt db user txRef
stash@BadgeStash {masterKey} <- stashBadgeKeys user stash_ $ \db -> createBadgeStoreReceipt db g user txRef now
stash@BadgeStash {masterKey} <- stashBadgeKeys user stash_ $ \db -> createBadgeStoreReceipt db g user echoedInvoiceId (Just txRef) now
requestStashedBadge nm user sendTarget stash BSCPurchaseBadge {masterKey, payment, upgrade = Nothing} terminalReceiptError
-- outside the badge lock: the chat lock must not be taken under it
mapM_ presentUserBadgeToContacts present_
@@ -5278,9 +5290,15 @@ storeTransactionRef = \case
stashBadgeKeys :: User -> Maybe BadgeStash -> (DB.Connection -> IO BadgeStash) -> CM BadgeStash
stashBadgeKeys user stash_ createStash = do
replaying <- maybe (pure False) (\s -> withStore' $ \db -> isJust <$> getStashBadgePurchase db s) stash_
unless replaying $ whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive
unless replaying $ refuseWhileBadgeHeld user
maybe (withStore' createStash) pure stash_
badgeServiceTarget :: CM (ConnectTarget 'CMContact)
badgeServiceTarget = asks (badgeServiceAddress . config) >>= maybe (throwRedeemError BREServiceNotConfigured) pure
refuseWhileBadgeHeld :: User -> CM ()
refuseWhileBadgeHeld user = whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive
-- | A refusal for which stashDead holds drops the stash; any other, and a timeout, keep it.
requestStashedBadge :: NetworkRequestMode -> User -> ConnectTarget 'CMContact -> BadgeStash -> BadgeServiceCommand -> (BadgeServiceErrorCode -> Bool) -> CM (Maybe User, ChatResponse)
requestStashedBadge nm user sendTarget stash@BadgeStash {purchaseKey, purchasePrivKey} request stashDead = do
@@ -5480,6 +5498,12 @@ emitBadgeAlert user emitted p@UserBadgePurchase {alertSnoozeUntil} shownCred now
raised <- atomically $ stateTVar emitted (,occurrence)
when (raised /= occurrence) $ toView $ CEvtBadgeAlert user alert
-- | Scoped to one profile, so another profile's store purchase, which may be hidden, never shows under it.
badgeStateResponse :: User -> CM ChatResponse
badgeStateResponse user = do
badgeState <- getUserBadgeState user
CRBadgeState user badgeState <$> withStore' (`getOpenBadgeStorePurchases` user)
-- | Read from stored rows alone; the worker's results follow as CEvtBadgeChanged.
getUserBadgeState :: User -> CM (Maybe BadgeState)
getUserBadgeState user = do
@@ -6150,7 +6174,9 @@ chatCommandP =
"/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)),
"/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP),
"/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP),
"/_badge purchase " *> (APIPurchaseBadge <$> A.decimal <* A.space <*> jsonP),
"/_badge purchase " *> (APIPurchaseBadge <$> A.decimal <*> optional (" invoice=" *> (safeDecodeUtf8 <$> A.takeTill (== ' '))) <* A.space <*> jsonP),
"/_badge invoice close " *> (APICloseBadgeInvoice <$> A.decimal <* A.space <*> textP),
"/_badge invoice " *> (APICreateBadgeInvoice <$> A.decimal),
"/_badge state " *> (APIGetBadgeState <$> A.decimal),
"/_badge ledger " *> (APIGetBadgeLedger <$> A.decimal <* A.space <*> A.decimal),
"/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP),
+58 -6
View File
@@ -21,6 +21,9 @@ module Simplex.Chat.Store.Badges
getBadgeCodeRedemption,
createBadgeCodeRedemption,
getBadgeStoreReceiptUserId,
attachBadgeStoreReceipt,
closeBadgeStoreInvoice,
getOpenBadgeStorePurchases,
getBadgeStoreReceipt,
createBadgeStoreReceipt,
deleteBadgeStash,
@@ -36,6 +39,7 @@ module Simplex.Chat.Store.Badges
where
import Control.Concurrent.STM (TVar, atomically)
import Control.Monad (forM, forM_, join)
import Crypto.Random (ChaChaDRG)
import qualified Data.Aeson as J
import Data.ByteString.Char8 (ByteString)
@@ -47,7 +51,7 @@ import Data.Time.Clock (UTCTime)
import Simplex.Chat.Badges
import Simplex.Chat.Badges.Ledger
import Simplex.Chat.Badges.Service (StatementCreditType (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..))
import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..))
import Simplex.Chat.Badges.Types (BadgeAlertKind, BadgeIssueError (..), BadgeIssueFailure, BadgePurchaseStatus (..), BadgeStorePurchase (..))
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Protocol (UserId)
@@ -114,6 +118,47 @@ getBadgeStoreReceiptUserId db StoreTransactionRef {provider, transactionRef} =
maybeFirstRow fromOnly $
DB.query db "SELECT user_id FROM badge_store_receipts WHERE provider = ? AND transaction_ref = ?" (provider, transactionRef)
-- | The profile a receipt belongs to, attaching it to the record created when Buy was tapped. A
-- transaction already attached stays with its record, whose keys the service may have credited.
attachBadgeStoreReceipt :: DB.Connection -> Maybe Text -> StoreTransactionRef -> IO (Maybe UserId)
attachBadgeStoreReceipt db invoiceId_ txRef@StoreTransactionRef {provider, transactionRef} =
getBadgeStoreReceiptUserId db txRef >>= \case
Just userId -> pure $ Just userId
Nothing -> fmap join . forM invoiceId_ $ \invoiceId -> do
userId_ <-
maybeFirstRow fromOnly $
DB.query db "SELECT user_id FROM badge_store_receipts WHERE invoice_id = ? AND transaction_ref IS NULL" (Only invoiceId)
-- a closed record is reopened: new keys for a paid transaction would be refused as receipt_used,
-- and the badge the old keys were credited with would be lost
forM_ userId_ $ \_ ->
DB.execute
db
"UPDATE badge_store_receipts SET provider = ?, transaction_ref = ?, closed_at = NULL WHERE invoice_id = ?"
(provider, transactionRef, invoiceId)
pure userId_
-- | Only a record no receipt has reached can be closed.
closeBadgeStoreInvoice :: DB.Connection -> User -> Text -> UTCTime -> IO ()
closeBadgeStoreInvoice db User {userId} invoiceId now =
DB.execute
db
"UPDATE badge_store_receipts SET closed_at = ? WHERE user_id = ? AND invoice_id = ? AND transaction_ref IS NULL"
(now, userId, invoiceId)
getOpenBadgeStorePurchases :: DB.Connection -> User -> IO [BadgeStorePurchase]
getOpenBadgeStorePurchases db User {userId} =
map (uncurry BadgeStorePurchase)
<$> DB.query
db
[sql|
SELECT r.invoice_id, r.transaction_ref
FROM badge_store_receipts r
WHERE r.user_id = ? AND r.closed_at IS NULL
AND NOT EXISTS (SELECT 1 FROM badge_purchases p WHERE p.badge_store_receipt_id = r.badge_store_receipt_id)
ORDER BY r.badge_store_receipt_id
|]
(Only userId)
getBadgeStoreReceipt :: DB.Connection -> User -> StoreTransactionRef -> IO (Maybe BadgeStash)
getBadgeStoreReceipt db User {userId} StoreTransactionRef {provider, transactionRef} =
maybeFirstRow (toBadgeStash BSRStoreReceipt) $
@@ -126,17 +171,24 @@ getBadgeStoreReceipt db User {userId} StoreTransactionRef {provider, transaction
|]
(userId, provider, transactionRef)
createBadgeStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> StoreTransactionRef -> UTCTime -> IO BadgeStash
createBadgeStoreReceipt db g User {userId} StoreTransactionRef {provider, transactionRef} now = do
-- | An invoice id another record already holds is not repeated: the record is then keyed by its transaction alone.
createBadgeStoreReceipt :: DB.Connection -> TVar ChaChaDRG -> User -> Maybe Text -> Maybe StoreTransactionRef -> UTCTime -> IO BadgeStash
createBadgeStoreReceipt db g User {userId} invoiceId_ txRef_ now = do
(purchaseKey, purchasePrivKey) <- atomically $ C.generateKeyPair g
masterKey@(BadgeMasterKey mk) <- generateMasterKey g
invoiceId' <- fmap join . forM invoiceId_ $ \invoiceId -> do
taken <- DB.query db "SELECT badge_store_receipt_id FROM badge_store_receipts WHERE invoice_id = ?" (Only invoiceId) :: IO [Only Int64]
pure $ if null taken then Just invoiceId else Nothing
let (provider_, transactionRef_) = case txRef_ of
Just StoreTransactionRef {provider, transactionRef} -> (Just provider, Just transactionRef)
Nothing -> (Nothing, Nothing)
DB.execute
db
[sql|
INSERT INTO badge_store_receipts (user_id, provider, transaction_ref, purchase_key, purchase_priv_key, master_key, created_at)
VALUES (?,?,?,?,?,?,?)
INSERT INTO badge_store_receipts (user_id, invoice_id, provider, transaction_ref, purchase_key, purchase_priv_key, master_key, created_at)
VALUES (?,?,?,?,?,?,?,?)
|]
(userId, provider, transactionRef, purchaseKey, purchasePrivKey, Binary mk, now)
((userId, invoiceId', provider_, transactionRef_) :. (purchaseKey, purchasePrivKey, Binary mk, now))
storeReceiptId <- insertedRowId db
pure BadgeStash {stashRef = BSRStoreReceipt storeReceiptId, purchaseKey, purchasePrivKey, masterKey}
@@ -12,12 +12,14 @@ m20260925_badge_store_receipts =
CREATE TABLE badge_store_receipts(
badge_store_receipt_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE,
provider TEXT NOT NULL,
transaction_ref TEXT NOT NULL,
invoice_id TEXT UNIQUE,
provider TEXT,
transaction_ref TEXT,
purchase_key BYTEA NOT NULL,
purchase_priv_key BYTEA NOT NULL,
master_key BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
closed_at TIMESTAMPTZ,
UNIQUE(provider, transaction_ref)
);
@@ -11,12 +11,14 @@ m20260925_badge_store_receipts =
CREATE TABLE badge_store_receipts(
badge_store_receipt_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
provider TEXT NOT NULL,
transaction_ref TEXT NOT NULL,
invoice_id TEXT UNIQUE,
provider TEXT,
transaction_ref TEXT,
purchase_key BLOB NOT NULL,
purchase_priv_key BLOB NOT NULL,
master_key BLOB NOT NULL,
created_at TEXT NOT NULL,
closed_at TEXT,
UNIQUE(provider, transaction_ref)
) STRICT;
+7 -2
View File
@@ -46,7 +46,7 @@ import Simplex.Chat.Markdown
import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus)
import Simplex.Chat.Badges.Ledger (creditTypeTag, debitTypeTag)
import Simplex.Chat.Badges.Service (StatementEntry (..), StatementEntryType (..))
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..))
import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeIssueError (..), BadgeState (..), BadgeStorePurchase (..))
import Simplex.Chat.Messages hiding (NewChatItem (..))
import Simplex.Chat.Messages.CIContent
import Simplex.Chat.Operators
@@ -193,7 +193,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)]
-- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be
CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"]
CRBadgeState u st -> ttyUser u $ viewUserBadgeState st
CRBadgeInvoice u invoiceId -> ttyUser u ["badge invoice: " <> plain invoiceId]
CRBadgeState u st storePurchases -> ttyUser u $ viewUserBadgeState st <> map viewBadgeStorePurchase storePurchases
CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries
CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView
CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView
@@ -1863,6 +1864,10 @@ viewBadgeIssueError BadgeIssueError {failedSince, lastAttemptAt, reason} =
viewBadgeAlert :: BadgeAlert -> [StyledString]
viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date]
viewBadgeStorePurchase :: BadgeStorePurchase -> StyledString
viewBadgeStorePurchase BadgeStorePurchase {invoiceId, transactionRef} =
plain $ "store purchase open: invoice " <> fromMaybe "none" invoiceId <> maybe "" (", transaction " <>) transactionRef
viewBadgeLedger :: [StatementEntry] -> [StyledString]
viewBadgeLedger [] = ["no ledger entries"]
viewBadgeLedger entries = map viewEntry entries