core: alert a subscriber that a payment failed, a renewal nears, or it was cancelled or ended

A subscription's alerts were the prepaid ones: a retired subscription said
"support ended", and nothing told the user a payment failed, an annual
renewal was near, or a cancelled subscription still runs. The derived alert
for a subscription is now, in order: subscription_ended once retired,
payment_issue while the store retries, issue_failed as before,
subscription_cancelled before paidThrough, and renewal_approaching from
three days before an annual plan renews, with a wake at that moment. Monthly
plans get no reminder, as there is no opt-out yet.

The unreleased migration's down step clears an acked subscription_cancelled,
which an older core cannot read.

Run: 003-badge-subscriptions-ui, slice 7
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-15 06:38:03 +00:00
1 parent a3b2e5c9f1
commit e051b50323
6 files changed
+169 -21

No files matched your search

+3 -1
View File
@@ -120,13 +120,14 @@ data LedgerDebitType
deriving (Eq, Show)
-- unconfirmed draft
data BadgeAlertKind = BARenewalApproaching | BAPaymentIssue | BASubscriptionEnded | BAPrepaidEnding | BASupportEnded | BAIssueFailed
data BadgeAlertKind = BARenewalApproaching | BAPaymentIssue | BASubscriptionCancelled | BASubscriptionEnded | BAPrepaidEnding | BASupportEnded | BAIssueFailed
deriving (Eq, Show)
instance TextEncoding BadgeAlertKind where
textEncode = \case
BARenewalApproaching -> "renewal_approaching"
BAPaymentIssue -> "payment_issue"
BASubscriptionCancelled -> "subscription_cancelled"
BASubscriptionEnded -> "subscription_ended"
BAPrepaidEnding -> "prepaid_ending"
BASupportEnded -> "support_ended"
@@ -134,6 +135,7 @@ instance TextEncoding BadgeAlertKind where
textDecode = \case
"renewal_approaching" -> Just BARenewalApproaching
"payment_issue" -> Just BAPaymentIssue
"subscription_cancelled" -> Just BASubscriptionCancelled
"subscription_ended" -> Just BASubscriptionEnded
"prepaid_ending" -> Just BAPrepaidEnding
"support_ended" -> Just BASupportEnded
+27 -10
View File
@@ -5637,7 +5637,10 @@ updateUserBadge userId emitted now = do
UserBadgePurchase {alertSnoozeUntil, issueError = failureAfter, purchaseKind = kindAfter} = p'
snoozeAt = find (> now) alertSnoozeUntil
stalledAt = if requestDue && not issued then Just $ badgeStalledInterval `addUTCTime` now else Nothing
wakeAt = earliestTime [serviceAt, snoozeAt, stalledAt, badgeBoundary now (shownBadgeCredential user'' p') balance']
reminderAt = case kindAfter of
PKSubscription s -> find (> now) $ renewalReminderAt s balance'
PKPrepaid -> Nothing
wakeAt = earliestTime [serviceAt, snoozeAt, stalledAt, reminderAt, badgeBoundary now (shownBadgeCredential user'' p') balance']
failed = failureAfter /= failureBefore
subscriptionChanged = kindAfter /= purchaseKind
-- written before the event, so the state it reports carries the attempt it leads to
@@ -5645,21 +5648,35 @@ updateUserBadge userId emitted now = do
when (retired || issued || failed || subscriptionChanged) $ toView . CEvtBadgeChanged user'' =<< getUserBadgeState user''
pure wakeAt
-- | The other kinds need subscriptions, and warning before a prepaid badge ends is not actionable
-- while topping up cannot credit months without issuing.
-- | Warning before a prepaid badge ends is not actionable while topping up cannot credit months without issuing.
-- TODO [badges] BAPrepaidEnding belongs here, three days before paidThrough, once that exists.
derivedBadgeAlert :: UTCTime -> UserBadgePurchase -> Maybe BadgeCredential -> StatementEntry -> Maybe BadgeAlert
derivedBadgeAlert now p@UserBadgePurchase {purchaseKind, shown} shownCred b
-- a subscription still shown past paidThrough is waiting for its store to charge, not ended
| endsAt <= now && not (subscribed && shown) = Just $ alertOf BASupportEnded endsAt
| otherwise = (\BadgeIssueError {failedSince} -> alertOf BAIssueFailed failedSince) <$> shownIssueError now p shownCred
derivedBadgeAlert now p@UserBadgePurchase {purchaseKind, shown} shownCred b = case purchaseKind of
PKPrepaid
| endsAt <= now -> Just $ alertOf BASupportEnded endsAt
| otherwise -> issueFailed
PKSubscription s@BadgeSubscription {status}
-- a subscription still shown past paidThrough is waiting for its store to charge, not ended
| endsAt <= now && not shown -> Just $ alertOf BASubscriptionEnded endsAt
| status == Just SSBillingRetry -> Just $ alertOf BAPaymentIssue endsAt
| isJust issueFailed -> issueFailed
| now < endsAt && status == Just SSCancelled -> Just $ alertOf BASubscriptionCancelled endsAt
| now < endsAt && maybe False (<= now) (renewalReminderAt s b) -> Just $ alertOf BARenewalApproaching endsAt
| otherwise -> Nothing
where
subscribed = case purchaseKind of
PKSubscription _ -> True
PKPrepaid -> False
endsAt = L.paidThrough b
issueFailed = (\BadgeIssueError {failedSince} -> alertOf BAIssueFailed failedSince) <$> shownIssueError now p shownCred
alertOf kind date = BadgeAlert {kind, episode = safeDecodeUtf8 $ strEncode date, date, price = Nothing}
-- | Only an annual plan is reminded of: with no way to turn reminders off, a monthly one would come every month.
renewalReminderAt :: BadgeSubscription -> StatementEntry -> Maybe UTCTime
renewalReminderAt BadgeSubscription {planMonths, status} b
| planMonths == 12 && status == Just SSRenewing = Just $ negate renewalReminderLead `addUTCTime` L.paidThrough b
| otherwise = Nothing
renewalReminderLead :: NominalDiffTime
renewalReminderLead = 3 * nominalDay
-- | A failure that can clear on its own is only shown once the credential lapses and contacts see it.
shownIssueError :: UTCTime -> UserBadgePurchase -> Maybe BadgeCredential -> Maybe BadgeIssueError
shownIssueError now UserBadgePurchase {issueError} shownCred = case issueError of
@@ -43,6 +43,8 @@ ALTER TABLE badge_purchases ADD COLUMN subscription_status TEXT;
down_m20260925_badge_store_receipts :: Text
down_m20260925_badge_store_receipts =
[r|
UPDATE badge_purchases SET alert_acked_kind = NULL, alert_acked_episode = NULL, alert_snooze_until = NULL WHERE alert_acked_kind = 'subscription_cancelled';
ALTER TABLE badge_purchases DROP COLUMN subscription_status;
DROP INDEX idx_badge_purchases_store_receipt;
@@ -42,6 +42,8 @@ ALTER TABLE badge_purchases ADD COLUMN subscription_status TEXT;
down_m20260925_badge_store_receipts :: Query
down_m20260925_badge_store_receipts =
[sql|
UPDATE badge_purchases SET alert_acked_kind = NULL, alert_acked_episode = NULL, alert_snooze_until = NULL WHERE alert_acked_kind = 'subscription_cancelled';
ALTER TABLE badge_purchases DROP COLUMN subscription_status;
DROP INDEX idx_badge_purchases_store_receipt;
+59 -2
View File
@@ -35,13 +35,14 @@ import Simplex.Chat.Badges
import Simplex.Chat.Badges.Code
import Simplex.Chat.Badges.Ledger
import Simplex.Chat.Badges.Service
import Simplex.Chat.Badges.Types (BadgeIssueFailure (..), BadgeSubscription (..))
import Simplex.Chat.Badges.Types (BadgeAlertKind (..), BadgeIssueError (..), BadgeIssueFailure (..), BadgeSubscription (..))
import qualified Simplex.Chat.Badges.Types as BT
import Simplex.Chat (defaultChatConfig)
import Simplex.Chat.Controller (BadgeRedeemError (..), ChatError (..), ChatErrorType (..), badgeRetryInterval, chatErrorAgent)
import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeFailureTransient, badgeIssueFailure, badgeRenewal, badgeRetryAfter, badgeServiceErrorText, badgeStalledInterval, boundedSubscriptionState, storeTransactionRef)
import Simplex.Chat.Library.Commands (badgeErrorRetry, badgeFailureTransient, badgeIssueFailure, badgeRenewal, badgeRetryAfter, badgeServiceErrorText, badgeStalledInterval, boundedSubscriptionState, derivedBadgeAlert, storeTransactionRef)
import Simplex.Chat.PaymentService (AppleTransactionFacts (..), ServicePayment (..), appleTransactionFacts, appleTransactionRef)
import Simplex.Chat.PaymentService.Types (InvoiceId (..), PaymentProvider (..), StoreTransactionRef (..))
import qualified Simplex.Chat.Store.Badges as SB
import Simplex.Chat.Types (BoolDef (..))
import Simplex.Messaging.Agent.Protocol (AgentErrorType (..), AgentServiceError (..), SMPAgentError (..))
import Simplex.Messaging.Agent.RetryInterval (RetryInterval (..), nextRetryDelay)
@@ -113,6 +114,7 @@ badgeTests = do
it "statement entries round-trip, unknown entry types verbatim" testStatementJSON
describe "badge state" $ do
it "carries a subscription's plan and last seen state, renewing only when it was seen renewing, and bounds a state it does not know" testBadgeSubscriptionJSON
it "derives one alert for a subscription in order of urgency, dated paidThrough, and leaves prepaid alerts as they were" testSubscriptionAlertDerivation
describe "store purchases" $ do
it "keys a purchase by the store's transaction id, not by the evidence signed over it" testStoreTransactionRef
it "claims an Apple subscription by its original transaction id, and any other transaction by its own" testAppleTransactionRef
@@ -1119,6 +1121,61 @@ testBadgeSubscriptionJSON = do
boundedSubscriptionState (SSUnknown (T.replicate 100 "a")) `shouldBe` SSUnknown (T.replicate 32 "a")
boundedSubscriptionState (SSUnknown "") `shouldBe` SSUnknown "unknown"
testSubscriptionAlertDerivation :: IO ()
testSubscriptionAlertDerivation = do
drg <- C.newRandom
(k, pk) <- atomically $ C.generateKeyPair drg :: IO (C.KeyPair 'C.Ed25519)
mk <- generateMasterKey drg
let start = at 2026 3 10
ends = addMonths 1 start
balance = grant start 1 (newBalance start)
failedAt = at 2026 3 20
failure = BadgeIssueError {failedSince = failedAt, lastAttemptAt = failedAt, reason = BIFServiceError {code = BSEUnknownPurchaseKey, retryable = False}}
early = addUTCTime (-10 * nominalDay) ends
soon = addUTCTime (-nominalDay) ends
past = addUTCTime nominalDay ends
subscription months st = SB.PKSubscription BadgeSubscription {planMonths = months, status = st}
alertOf t kind' isShown err =
let p = SB.UserBadgePurchase {badgePurchaseId = 1, purchaseKey = k, purchasePrivKey = pk, masterKey = mk, badgeType = BTSupporter, shown = isShown, alertAcked = Nothing, alertSnoozeUntil = Nothing, issueError = err, nextWakeAt = Nothing, purchaseKind = kind'}
in (\BT.BadgeAlert {kind, date} -> (kind, date)) <$> derivedBadgeAlert t p Nothing balance
renewing = Just SSRenewing
cancelled = Just SSCancelled
billingRetry = Just SSBillingRetry
mapM_
(\(t, kind', isShown, err, expected) -> alertOf t kind' isShown err `shouldBe` expected)
[ -- retired at or after paidThrough, whatever else holds
(past, subscription 1 renewing, False, Nothing, Just (BASubscriptionEnded, ends)),
(past, subscription 12 billingRetry, False, Just failure, Just (BASubscriptionEnded, ends)),
(past, subscription 1 cancelled, False, Nothing, Just (BASubscriptionEnded, ends)),
-- still shown past paidThrough: waiting for the store to charge
(past, subscription 1 renewing, True, Nothing, Nothing),
(past, subscription 12 Nothing, True, Nothing, Nothing),
(past, subscription 1 cancelled, True, Nothing, Nothing),
-- billing retry before and after paidThrough, over a renewal failure
(early, subscription 12 billingRetry, True, Nothing, Just (BAPaymentIssue, ends)),
(past, subscription 1 billingRetry, True, Just failure, Just (BAPaymentIssue, ends)),
-- a renewal failure over a cancellation and a reminder
(soon, subscription 1 cancelled, True, Just failure, Just (BAIssueFailed, failedAt)),
(soon, subscription 12 renewing, True, Just failure, Just (BAIssueFailed, failedAt)),
(early, subscription 1 cancelled, True, Nothing, Just (BASubscriptionCancelled, ends)),
(soon, subscription 12 cancelled, True, Nothing, Just (BASubscriptionCancelled, ends)),
-- reminded only for a renewing annual plan, and only in the last three days
(soon, subscription 12 renewing, True, Nothing, Just (BARenewalApproaching, ends)),
(early, subscription 12 renewing, True, Nothing, Nothing),
(soon, subscription 1 renewing, True, Nothing, Nothing),
(soon, subscription 12 Nothing, True, Nothing, Nothing),
(soon, subscription 12 (Just $ SSUnknown "paused"), True, Nothing, Nothing),
(soon, subscription 1 (Just SSEnded), True, Nothing, Nothing),
-- prepaid, as before
(past, SB.PKPrepaid, True, Nothing, Just (BASupportEnded, ends)),
(past, SB.PKPrepaid, False, Just failure, Just (BASupportEnded, ends)),
(soon, SB.PKPrepaid, True, Just failure, Just (BAIssueFailed, failedAt)),
(soon, SB.PKPrepaid, True, Nothing, Nothing)
]
-- the reminder starts exactly three days before paidThrough
alertOf (addUTCTime (-3 * nominalDay) ends) (subscription 12 renewing) True Nothing `shouldBe` Just (BARenewalApproaching, ends)
alertOf (addUTCTime (-3 * nominalDay - 1) ends) (subscription 12 renewing) True Nothing `shouldBe` Nothing
testErrorResponseJSON :: IO ()
testErrorResponseJSON = do
let resp = BSPError {code = BSECodeInvalid, message = Nothing, retryAfter = Nothing}
+76 -8
View File
@@ -201,6 +201,8 @@ badgeServiceTests = do
it "should keep the state the service answered when it credited and when it renewed past paid coverage, and announce a change" testSubscriptionStateKept
it "should keep the state from the credit through an issuance the service answers from its stored state" testAnnualStateNotFromStoredAnswer
it "should keep the device store's view of a credited subscription, except renewing over billing retry, and nothing for any other transaction" testSubscriptionStateFromStore
it "should alert a cancelled subscription until acknowledged, and not remind a monthly plan of its renewal" testSubscriptionAlerts
it "should remind an annual plan three days before it renews, from a wake of its own, until acknowledged" testRenewalReminder
it "should resolve an Apple renewal to the subscription's record, and credit a held record that adopted it" testAppleRenewalResolvesToPurchase
it "should adopt into a held Apple record only another transaction bought later and not revoked" testAppleAdoptionRules
it "should credit an adopted Apple transaction at once though the attempt with the one before it failed" testAdoptedReceiptNotOverwritten
@@ -2291,7 +2293,13 @@ testSubscriptionAwaitsCharge ps =
checks <- subscriptionChecks store
setClockAt bsClock requestAt
alice `send` "/_app activate"
alice <### ["/_app activate", "ok", Predicate (", subscription billingRetry" `isInfixOf`)]
alice
<### [ "/_app activate",
"ok",
paymentIssue,
Predicate (\l -> "1: supporter" `isPrefixOf` l && ", subscription billingRetry" `isInfixOf` l),
paymentIssue
]
-- nothing issued and no failure: the badge stays, and the next request is a day on
waitWakeFrom (chatController alice) (addUTCTime (nominalDay - 60) requestAt)
subscriptionChecks store `shouldReturn` checks + 1
@@ -2322,9 +2330,9 @@ testSubscriptionEnds ps =
alice
<### [ "/_app activate",
"ok",
StartsWith "badge alert: support_ended ",
StartsWith "badge alert: subscription_ended ",
StartsWith "1: supporter",
StartsWith "badge alert: support_ended "
StartsWith "badge alert: subscription_ended "
]
waitShownBadge (chatController alice) Nothing
-- a retired purchase is not asked about again
@@ -2351,9 +2359,68 @@ testSubscriptionStateKept ps =
storeReports store $ subscriptionCheck SSBillingRetry Nothing
setClockAt bsClock $ fst $ renewalMoments rows
alice `send` "/_app activate"
alice <### ["/_app activate", "ok", Predicate (", subscription billingRetry" `isInfixOf`)]
alice <### ["/_app activate", "ok", Predicate (", subscription billingRetry" `isInfixOf`), paymentIssue, paymentIssue]
subscriptionStatuses (chatController alice) `shouldReturn` [Just "billingRetry"]
nextWakes :: ChatController -> IO [Maybe UTCTime]
nextWakes ChatController {chatStore} =
withTransaction chatStore $ \db -> map fromOnly <$> DB.query_ db "SELECT next_wake_at FROM badge_purchases"
-- | A monthly plan is not reminded of its renewal; a cancellation is alerted until acknowledged.
testSubscriptionAlerts :: HasCallStack => TestParams -> IO ()
testSubscriptionAlerts ps =
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsClientCfg} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
(firstStart, _) <- clientSubscribedMonthly env alice
let paidThrough = addMonths 1 firstStart
episode = T.unpack $ safeDecodeUtf8 $ strEncode paidThrough
armWakeAt alice bsClock $ addUTCTime (-3 * nominalDay) paidThrough
nextWakes (chatController alice) >>= (`shouldSatisfy` all (maybe False (>= paidThrough)))
threadDelay 2000000
(alice </)
alice ##> ("/_badge subscription cancelled " <> paymentArg subscriptionPlay)
alice <### ["ok", StartsWith "1: supporter", subscriptionCancelled, subscriptionCancelled]
alice ##> ("/_badge ack 1 1 subscription_cancelled off " <> episode)
alice <##. "1: supporter"
ackedEpisode (chatController alice) `shouldReturn` (Just "subscription_cancelled", Just (T.pack episode))
alice ##> "/_badge state 1"
alice <##. "1: supporter"
(alice </)
-- | An annual plan is reminded three days before it renews, by a wake of its own.
testRenewalReminder :: HasCallStack => TestParams -> IO ()
testRenewalReminder ps =
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsClock, bsClientCfg, bsStore = store@FakeStore {appleSubscriptionJWS}} ->
withNewTestChatCfg ps bsClientCfg "alice" aliceProfile $ \alice -> do
firstStart <- addUTCTime (-3600) <$> serviceNow env
setSubscription store (SubscriptionApple appleSubscriptionOriginalId) $ subscriptionCheck SSRenewing $ Just $ storeCharge "SUBSCR_BADGE_SUPPORTER_YEAR_01" firstStart 12
alice ##> ("/_badge purchase 1 " <> paymentArg SPApple {jws = appleSubscriptionJWS})
inAnyOrder alice [[openPurchase ""], ["1: supporter"]]
waitWakeArmed (chatController alice)
let paidThrough = addMonths 12 firstStart
remindAt = addUTCTime (-3 * nominalDay) paidThrough
episode = T.unpack $ safeDecodeUtf8 $ strEncode paidThrough
-- with the last month issued, the reminder is the next thing due
setClockAt bsClock $ addUTCTime (-20 * nominalDay) paidThrough
alice ##> "/_app activate"
alice <## "ok"
alice <##. "1: supporter, 0 months left"
waitWakeFrom (chatController alice) remindAt
nextWakes (chatController alice) `shouldReturn` [Just remindAt]
armWakeAt alice bsClock remindAt
alice <##. "badge alert: renewal_approaching "
alice ##> ("/_badge ack 1 1 renewal_approaching off " <> episode)
alice <##. "1: supporter"
alice ##> "/_badge state 1"
alice <##. "1: supporter"
(alice </)
paymentIssue :: ConsoleResponse
paymentIssue = StartsWith "badge alert: payment_issue "
subscriptionCancelled :: ConsoleResponse
subscriptionCancelled = StartsWith "badge alert: subscription_cancelled "
-- | The service answers a request made while coverage runs from the state it stored, which for an annual plan is up to a year old.
testAnnualStateNotFromStoredAnswer :: HasCallStack => TestParams -> IO ()
testAnnualStateNotFromStoredAnswer ps =
@@ -2362,7 +2429,7 @@ testAnnualStateNotFromStoredAnswer ps =
firstStart <- addUTCTime (-3600) <$> serviceNow env
storeReports store $ subscriptionCheck SSCancelled $ Just $ storeCharge "subscr-badge-supporter-year-01" firstStart 12
alice ##> ("/_badge purchase 1 " <> paymentArg subscriptionPlay)
inAnyOrder alice [[openPurchase ""], ["1: supporter"]]
inAnyOrder alice [[openPurchase ""], ["1: supporter", "badge alert: subscription_cancelled "], ["badge alert: subscription_cancelled "]]
waitWakeArmed (chatController alice)
rows <- ledgerRows (chatController alice) "badge_ledger"
map (\(_, ch, m, _, _, t) -> (ch, m, t)) rows `shouldBe` [(12, 12, Just "charge"), (-1, 11, Just "badge")]
@@ -2373,7 +2440,7 @@ testAnnualStateNotFromStoredAnswer ps =
alice ##> "/_app activate"
alice <## "ok"
_ <- waitLedgerRows (chatController alice) 3
alice <### [Predicate (\l -> "1: supporter, 10 months left" `isPrefixOf` l && ", subscription cancelled" `isInfixOf` l)]
alice <### [Predicate (\l -> "1: supporter, 10 months left" `isPrefixOf` l && ", subscription cancelled" `isInfixOf` l), subscriptionCancelled]
subscriptionChecks store `shouldReturn` checks
subscriptionPayments cc `shouldReturn` [("google", googlePurchaseRef googleSubscriptionToken, Just "renewing", Just googleSubscriptionToken)]
subscriptionStatuses (chatController alice) `shouldReturn` [Just "cancelled"]
@@ -2385,7 +2452,7 @@ testSubscriptionStateFromStore ps =
(_, rows) <- clientSubscribedMonthly env alice
let report state payment = alice ##> ("/_badge subscription " <> state <> " " <> paymentArg payment)
report "cancelled" subscriptionPlay
alice <### ["ok", Predicate (", subscription cancelled" `isInfixOf`)]
alice <### ["ok", Predicate (", subscription cancelled" `isInfixOf`), subscriptionCancelled, subscriptionCancelled]
subscriptionStatuses (chatController alice) `shouldReturn` [Just "cancelled"]
report "cancelled" subscriptionPlay
alice <## "ok"
@@ -2393,7 +2460,7 @@ testSubscriptionStateFromStore ps =
storeReports store $ subscriptionCheck SSBillingRetry Nothing
setClockAt bsClock $ fst $ renewalMoments rows
alice `send` "/_app activate"
alice <### ["/_app activate", "ok", Predicate (", subscription billingRetry" `isInfixOf`)]
alice <### ["/_app activate", "ok", Predicate (", subscription billingRetry" `isInfixOf`), paymentIssue, paymentIssue]
checks <- subscriptionChecks store
report "renewing" subscriptionPlay
alice <## "ok"
@@ -2401,6 +2468,7 @@ testSubscriptionStateFromStore ps =
-- a held transaction, here a one-time purchase handed over while the badge is shown, and an unknown one change nothing
alice ##> ("/_badge purchase 1 " <> paymentArg supporterPlay)
alice <##. "1: supporter"
alice <##. "badge alert: payment_issue "
alice <##. openPurchase ""
report "cancelled" supporterPlay
alice <## "ok"