From a3b2e5c9f1af4e240c680fef42495a70f51c20ac Mon Sep 17 00:00:00 2001 From: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:11:10 +0000 Subject: [PATCH] core: refuse a badge code or invoice while a store receipt is pending A code redeemed while a store receipt is held put the code's badge on the profile, and the held receipt then waited behind it; for a subscription the store kept charging every period meanwhile. Code redemption and invoice creation now answer "store purchase open" while the profile holds an App Store subscription receipt, or another receipt whose last failure can clear on its own. A receipt that failed for good does not block, except an Apple subscription, which Apple keeps charging; an uncredited Play subscription is refunded by Play. Run: 003-badge-subscriptions-ui, slice 6 --- bots/api/TYPES.md | 3 ++ .../types/typescript/src/types.ts | 6 +++ .../src/simplex_chat/types/_types.py | 6 ++- src/Simplex/Chat/Controller.hs | 1 + src/Simplex/Chat/Library/Commands.hs | 17 ++++++- src/Simplex/Chat/Store/Badges.hs | 6 +++ src/Simplex/Chat/View.hs | 1 + tests/Bots/BadgeService/BotTests.hs | 47 +++++++++++++++++-- 8 files changed, 81 insertions(+), 6 deletions(-) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index fe79d847e2..84fc88c035 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -450,6 +450,9 @@ ServiceNotConfigured: BadgeActive: - type: "badgeActive" +StorePurchaseOpen: +- type: "storePurchaseOpen" + ServiceError: - type: "serviceError" - serviceError: [BadgeServiceErrorCode](#badgeserviceerrorcode) diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index cebc68abb4..03b93baed7 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -252,6 +252,7 @@ export type BadgeRedeemError = | BadgeRedeemError.InvalidReceipt | BadgeRedeemError.ServiceNotConfigured | BadgeRedeemError.BadgeActive + | BadgeRedeemError.StorePurchaseOpen | BadgeRedeemError.ServiceError | BadgeRedeemError.InvalidResponse | BadgeRedeemError.UnknownKeyIndex @@ -263,6 +264,7 @@ export namespace BadgeRedeemError { | "invalidReceipt" | "serviceNotConfigured" | "badgeActive" + | "storePurchaseOpen" | "serviceError" | "invalidResponse" | "unknownKeyIndex" @@ -288,6 +290,10 @@ export namespace BadgeRedeemError { type: "badgeActive" } + export interface StorePurchaseOpen extends Interface { + type: "storePurchaseOpen" + } + export interface ServiceError extends Interface { type: "serviceError" serviceError: BadgeServiceErrorCode diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 1befa750c3..4bacbefc36 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -197,6 +197,9 @@ class BadgeRedeemError_serviceNotConfigured(TypedDict): class BadgeRedeemError_badgeActive(TypedDict): type: Literal["badgeActive"] +class BadgeRedeemError_storePurchaseOpen(TypedDict): + type: Literal["storePurchaseOpen"] + class BadgeRedeemError_serviceError(TypedDict): type: Literal["serviceError"] serviceError: "BadgeServiceErrorCode" @@ -216,13 +219,14 @@ BadgeRedeemError = ( | BadgeRedeemError_invalidReceipt | BadgeRedeemError_serviceNotConfigured | BadgeRedeemError_badgeActive + | BadgeRedeemError_storePurchaseOpen | BadgeRedeemError_serviceError | BadgeRedeemError_invalidResponse | BadgeRedeemError_unknownKeyIndex | BadgeRedeemError_credentialNotVerified ) -BadgeRedeemError_Tag = Literal["invalidCode", "invalidReceipt", "serviceNotConfigured", "badgeActive", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] +BadgeRedeemError_Tag = Literal["invalidCode", "invalidReceipt", "serviceNotConfigured", "badgeActive", "storePurchaseOpen", "serviceError", "invalidResponse", "unknownKeyIndex", "credentialNotVerified"] BadgeServiceErrorCode = Literal["bad_request", "unsupported_version", "unknown_purchase_key", "unknown_offer_id", "offer_disabled", "offer_mismatch", "product_unavailable", "payment_not_entitled", "payment_pending", "provider_unavailable", "provider_not_configured", "rate_limited", "code_invalid", "code_used", "code_expired", "receipt_invalid", "receipt_used", "test_purchase", "internal"] diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0c2f13405c..e48779f862 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1498,6 +1498,7 @@ data BadgeRedeemError | BREInvalidReceipt -- names no transaction, or is not a store payment | BREServiceNotConfigured | BREBadgeActive + | BREStorePurchaseOpen -- a store receipt held for this profile may still be credited | BREServiceError {serviceError :: BadgeServiceErrorCode} | BREInvalidResponse {message :: String} | BREUnknownKeyIndex diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 5aa9a79fd3..e3ab94e070 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -76,7 +76,7 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Operators import Simplex.Chat.Options -import Simplex.Chat.PaymentService (ServicePayment (..), appleTransactionRef, googlePurchaseRef) +import Simplex.Chat.PaymentService (AppleTransactionFacts (..), ServicePayment (..), appleTransactionFacts, appleTransactionRef, googlePurchaseRef) import Simplex.Chat.PaymentService.Types (PaymentProvider (..), StoreTransactionRef (..)) import Simplex.Chat.ProfileGenerator (generateRandomProfile) import Simplex.Chat.Protocol @@ -3586,6 +3586,7 @@ processChatCommand cxt nm = \case APICreateBadgeInvoice userId -> withUserId userId $ \user -> do void requireBadgeService refuseWhileBadgeHeld user + refuseWhileStoreReceiptPending user g <- asks random now <- badgeNow invoiceId <- UUID.toText <$> liftIO V4.nextRandom @@ -5351,7 +5352,7 @@ 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 $ refuseWhileBadgeHeld user + unless replaying $ refuseWhileBadgeHeld user >> refuseWhileStoreReceiptPending user maybe (withStore' createStash) pure stash_ requireBadgeService :: CM (ConnectTarget 'CMContact) @@ -5360,6 +5361,18 @@ requireBadgeService = asks (badgeServiceAddress . config) >>= maybe (throwRedeem refuseWhileBadgeHeld :: User -> CM () refuseWhileBadgeHeld user = whenM (withStore' (`userHasBadge` user)) $ throwRedeemError BREBadgeActive +-- | A new badge would leave a held receipt waiting behind it. An Apple subscription always blocks, as Apple keeps +-- charging it; any other only while its last failure, if any, can clear, since one that failed for good waits anyway. +refuseWhileStoreReceiptPending :: User -> CM () +refuseWhileStoreReceiptPending user = do + pending <- withStore' (`getPendingStoreReceipts` user) + when (any blocks pending) $ throwRedeemError BREStorePurchaseOpen + where + blocks (payment, failure_) = appleSubscription payment || maybe True badgeFailureTransient failure_ + appleSubscription payment = case decodeJSON payment of + Just SPApple {jws} | Just AppleTransactionFacts {autoRenewable} <- appleTransactionFacts jws -> autoRenewable + _ -> False + -- | One attempt to turn a stash into a badge. A refusal is answered rather than thrown, with the -- service's retryAfter. requestBadgeStash :: NetworkRequestMode -> User -> ConnectTarget 'CMContact -> BadgeStash -> BadgeServiceCommand -> CM (Either (BadgeServiceErrorCode, Maybe Word32) (Maybe User, ChatResponse)) diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index 8fccf7b580..4e3eb8c0c7 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -27,6 +27,7 @@ module Simplex.Chat.Store.Badges getReceiptRecord, createBadgeStoreReceipt, getOpenStorePurchases, + getPendingStoreReceipts, getNextHeldStoreReceipt, recordStoreReceiptFailure, refuseStoreReceipt, @@ -240,6 +241,11 @@ getOpenStorePurchases db User {userId} = where toOpenStorePurchase (invoiceId, transactionRef, creditError) = OpenStorePurchase {invoiceId, transactionRef, creditError} +-- | Each held receipt's payment and its last attempt's failure, if any. +getPendingStoreReceipts :: DB.Connection -> User -> IO [(Text, Maybe BadgeIssueFailure)] +getPendingStoreReceipts db User {userId} = + DB.query db "SELECT payment, credit_error FROM badge_store_receipts WHERE user_id = ? AND payment IS NOT NULL" (Only userId) + getNextHeldStoreReceipt :: DB.Connection -> UserId -> IO (Either StoreError (Maybe BadgeReceiptRecord)) getNextHeldStoreReceipt db userId = fmap Right . maybeFirstRow toHeld $ diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 40fd52dfbf..894f06f8f6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -2885,6 +2885,7 @@ viewChatError isCmd logLevel testView = \case BREInvalidReceipt -> "invalid store receipt" BREServiceNotConfigured -> "badge service not configured" BREBadgeActive -> "badge already active" + BREStorePurchaseOpen -> "store purchase open" BREServiceError code -> "badge service error: " <> T.unpack (badgeServiceErrorText code) BREInvalidResponse m -> "invalid service response: " <> m BREUnknownKeyIndex -> "credential names an unknown badge key index" diff --git a/tests/Bots/BadgeService/BotTests.hs b/tests/Bots/BadgeService/BotTests.hs index 27b764ed47..b1f17ef355 100644 --- a/tests/Bots/BadgeService/BotTests.hs +++ b/tests/Bots/BadgeService/BotTests.hs @@ -166,6 +166,7 @@ badgeServiceTests = do it "should credit a purchase first handed over under another profile to that profile" testPurchaseStrandedUnderOtherProfile it "should credit a purchase to a hidden profile without naming it" testPurchaseDeliveredToHiddenProfile it "should record a listed transaction only into a record it already has, and a Restore hand-over as its own" testListedReceiptNotRecorded + it "should refuse a code or an invoice while a held receipt may be credited or is an Apple subscription, and not otherwise" testRedeemRefusedWhileReceiptHeld describe "invoice first" $ do it "should credit a receipt to the profile that created its invoice however late it arrives, and answer as that profile" testInvoiceOtherProfile it "should credit a receipt naming an unknown invoice to the presenting profile" testInvoiceUnknown @@ -2735,6 +2736,44 @@ testListedReceiptNotRecorded ps = heldStoreReceipts (chatController alice) `shouldReturn` 1 rowCount cc "sx_badge_service_payments" `shouldReturn` 1 +-- | A badge redeemed over a held receipt would leave the receipt waiting behind it. With no subscription check +-- the service refuses a subscription for good, though not as a refused receipt, so the record stays held. +testRedeemRefusedWhileReceiptHeld :: HasCallStack => TestParams -> IO () +testRedeemRefusedWhileReceiptHeld ps = + withBadgeServiceVerifier ps (\store -> (fakeVerifier store) {checkSubscription = Nothing}) $ \BadgeServiceEnv {bsClientCfg, bsController = cc, bsStore = FakeStore {appleSubscriptionJWS}} -> + withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do + let refused cmd = do + alice ##> cmd + alice <## "cannot get badge: store purchase open" + finalError = "service_error final provider_not_configured" + alice ##> ("/_badge purchase 1 " <> paymentArg (googlePayment "badge_supporter_01" googlePendingToken)) + storePurchaseOpen alice "" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error retry payment_pending"] + code <- issueCode cc BTSupporter 1 + refused $ "/_redeem_badge_code 1 " <> codeArg code + rowCount (chatController alice) "badge_code_redemptions" `shouldReturn` 0 + nothingPurchased cc + refused "/_badge invoice 1" + storeReceiptRows (chatController alice) `shouldReturn` [(1, Nothing, True)] + -- a Play receipt that failed for good does not block + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> ("/_badge purchase 2 " <> paymentArg subscriptionPlay) + storePurchaseOpen alice "" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error retry payment_pending", finalError] + alice ##> ("/_redeem_badge_code 2 " <> codeArg code) + alice <## "badge redeemed" + alice <## "supporter badge - active" + alice <##. "expires " + -- an Apple subscription that failed for good still blocks, as Apple keeps charging it + alice ##> "/create user alina" + showActiveUser alice "alina" + alice ##> ("/_badge purchase 3 " <> paymentArg SPApple {jws = appleSubscriptionJWS}) + storePurchaseOpen alice "" + heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error retry payment_pending", finalError, finalError] + issueCode cc BTSupporter 1 >>= \code' -> refused ("/_redeem_badge_code 3 " <> codeArg code') + refused "/_badge invoice 3" + testInvoiceUnknown :: HasCallStack => TestParams -> IO () testInvoiceUnknown ps = withBadgeServiceEnv ps $ \BadgeServiceEnv {bsClientCfg} -> @@ -2850,9 +2889,11 @@ testStoreReceiptNotBlocked ps = alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay) storePurchaseOpen alice "" heldStoreReceiptErrors (chatController alice) `shouldEventuallyReturn` ["service_error retry provider_unavailable"] - invoiceId <- createInvoice alice 1 - alice ##> purchaseWithInvoice 1 invoiceId SPApple {jws = appleSupporterJWS} - inAnyOrder alice [[openPurchase "", invoicePurchase "" invoiceId], creditedLines "" invoiceId "1: supporter"] + alice ##> "/_badge invoice 1" + alice <## "cannot get badge: store purchase open" + -- handed over with no invoice, as Restore sends it, so no settlement is announced + alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSupporterJWS}) + inAnyOrder alice [[openPurchase "", openPurchase ""], ["1: supporter"]] heldStoreReceipts (chatController alice) `shouldReturn` 1 testStoreRefusalKept :: HasCallStack => TestParams -> IO ()