review: round 1 fixes

ios: stop selling a subscription once this device has seen one, so a
second profile is not charged for a subscription Apple folds into the
first (R1-12). Continue pins the chosen period on both apps (R1-13).
Only a device with a subscription asks its store for products and status
when reporting, and the purchases are marked checked before the report
(R1-14, R1-5: Kotlin now loads the products, so the price shows after a
restart). Restore says "Nothing to restore" after a cancelled purchase
(R1-7). Android keeps sweeping one-time purchases when the subscriptions
query fails (R1-6). The freshness comment in requestBadgeIssue states the
service's one-hour repeat of its last check (R1-1).

Run: 003-badge-subscriptions-ui, review round 1
This commit is contained in:
spacedandy-agents-simplex committed 2026-10-15 09:25:48 +00:00
1 parent 1b336bc23d
commit 18617ade60
6 files changed
+33 -14

No files matched your search

+11 -3
View File
@@ -205,6 +205,12 @@ final class BadgeStore: ObservableObject {
await loadBadgeStateAsync(userId)
do {
let outcome = try await storePurchase(product, invoiceId)
if product.type == .autoRenewable {
switch outcome {
case .purchased, .pending: await MainActor.run { subscribedBefore = true }
case .cancelled: break
}
}
if case let .purchased(receipt) = outcome, receipt.signatureVerified {
try await handOver(receipt, listed: false)
}
@@ -302,9 +308,9 @@ final class BadgeStore: ObservableObject {
for await verification in Transaction.unfinished {
await reconcileListed(verification)
}
await reportSubscriptionStates()
await loadCurrentBadgeState()
await MainActor.run { reconciledOnce = true }
await reportSubscriptionStates()
}
// transactions the store settles outside a purchase call, such as an approved Ask to Buy
@@ -339,6 +345,7 @@ final class BadgeStore: ObservableObject {
private func reconcile(_ verification: VerificationResult<Transaction>, listed: Bool) async throws {
let receipt = storeReceipt(verification)
let t = receipt.transaction
if t.productType == .autoRenewable { await MainActor.run { subscribedBefore = true } }
if t.productType == .autoRenewable && t.revocationDate != nil {
// the service refuses a revoked charge as invalid, and that would refuse the whole subscription's record
await t.finish()
@@ -352,6 +359,7 @@ final class BadgeStore: ObservableObject {
// The service asks the store only once the paid period ends, so a cancellation or a failed charge before
// then is seen only here. Sent with the latest transaction, which names the subscription as its first one did.
func reportSubscriptionStates() async {
guard await hasSubscribedBefore() else { return }
await load()
guard let subscription = await MainActor.run(body: { products.values.first { $0.type == .autoRenewable }?.subscription }) else { return }
do {
@@ -392,7 +400,7 @@ final class BadgeStore: ObservableObject {
}
// Hands over as the device's own whatever the store lists, so a purchase no record here has is claimed by this profile.
// Answers whether a badge is shown or a store purchase is open afterwards.
// Answers whether a badge is shown, or a purchase is being added or awaits approval, afterwards.
func restorePurchases() async throws -> Bool {
try await AppStore.sync()
var handOverError: Error? = nil
@@ -408,7 +416,7 @@ final class BadgeStore: ObservableObject {
return await MainActor.run {
let userId = ChatModel.shared.currentUser?.userId
let badgeShown = BadgeModel.shared.userId == userId && BadgeModel.shared.badgeState?.shown == true
return badgeShown || !openStorePurchases(userId).isEmpty
return badgeShown || purchaseState(userId) != nil
}
}
@@ -183,6 +183,7 @@ struct BadgesHowLongView: View {
private func continueButton() -> some View {
ZStack {
Button {
chosenPeriod = selectedPeriod
continueActive = true
} label: {
Text("Continue")
@@ -107,9 +107,15 @@ suspend fun purchaseBadge(id: BadgeStoreProductId, invoiceId: String): BadgePurc
// and either awaiting a slow payment
suspend fun unfinishedBadgePurchases(): List<BadgePurchaseOutcome> {
val client = connectedBadgeBillingClient()
val purchases = queryBadgePurchases(client, BillingClient.ProductType.INAPP) +
queryBadgePurchases(client, BillingClient.ProductType.SUBS)
return purchases.mapNotNull(::badgePurchaseOutcome)
val oneTime = queryBadgePurchases(client, BillingClient.ProductType.INAPP)
// a failed subscription query must not cost the one-time purchases their sweep
val subscriptions = try {
queryBadgePurchases(client, BillingClient.ProductType.SUBS)
} catch (e: BadgeStoreError.BillingError) {
Log.e(TAG, "unfinishedBadgePurchases: subscriptions ${e.message}")
emptyList()
}
return (oneTime + subscriptions).mapNotNull(::badgePurchaseOutcome)
}
private suspend fun queryBadgePurchases(client: BillingClient, productType: String): List<Purchase> {
@@ -365,11 +365,11 @@ object BadgeStore {
Log.e(TAG, "BadgeStore.presentUnfinished: ${e.message}")
}
}
reportSubscriptionStates()
chatModel.controller.loadBadgeState(chatModel.remoteHostId())
} finally {
withContext(Dispatchers.Main + NonCancellable) { reconciledOnce.value = true }
}
reportSubscriptionStates()
}
// Play lists a Google account's purchases on every device signed into it, so these are handed over as listed
@@ -410,7 +410,10 @@ object BadgeStore {
// the store's purchases are this device's, so they are told to its own core
if (chatModel.remoteHostId() != null || useBadgeTestProducts || !platform.androidHasPlatformStore) return
try {
for (outcome in platform.androidUnfinishedBadgePurchases()) {
val purchases = platform.androidUnfinishedBadgePurchases()
if (purchases.none { (it as? BadgePurchaseOutcome.Purchased)?.receipt?.autoRenewing != null }) return
load()
for (outcome in purchases) {
val receipt = (outcome as? BadgePurchaseOutcome.Purchased)?.receipt ?: continue
val state = when (receipt.autoRenewing) {
true -> BadgeSubscriptionState.Renewing
@@ -434,7 +437,7 @@ object BadgeStore {
}
// Hands over as the device's own whatever the store lists, so a purchase no record here has is claimed by this profile.
// Answers whether a badge is shown or a store purchase is open afterwards.
// Answers whether a badge is shown, or a purchase is being added or awaits approval, afterwards.
suspend fun restorePurchases(): Boolean {
var handOverError: Exception? = null
val purchases = if (useBadgeTestProducts) emptyList() else platform.androidUnfinishedBadgePurchases()
@@ -454,7 +457,7 @@ object BadgeStore {
return withContext(Dispatchers.Main) {
val userId = chatModel.currentUser.value?.userId
val badgeShown = BadgeModel.isCurrent(rhId, userId) && BadgeModel.badgeState.value?.shown == true
badgeShown || openStorePurchases(userId).isNotEmpty()
badgeShown || purchaseState(userId) != null
}
}
@@ -123,7 +123,7 @@ fun BadgesHowLongView(level: BadgeLevel, modalManager: ModalManager, unwindToDep
Spacer(Modifier.weight(1f).heightIn(min = 8.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ContinueButton(level, selectedPeriod, modalManager, unwindToDepth)
ContinueButton(level, selectedPeriod, modalManager, unwindToDepth) { chosenPeriod = it }
BadgeBillingFooter(selectedPeriod)
}
}
@@ -188,12 +188,13 @@ private fun savingsPercent(level: BadgeLevel, period: BadgePeriod): Int? =
if (period == BadgePeriod.Annual) BadgeStore.annualSavings(level) else null
@Composable
private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int) {
private fun ContinueButton(level: BadgeLevel, selectedPeriod: BadgePeriod, modalManager: ModalManager, unwindToDepth: Int, choosePeriod: (BadgePeriod) -> Unit) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.badges_continue,
onboarding = null,
onclick = {
choosePeriod(selectedPeriod)
modalManager.showModal(cardScreen = true) { BadgesCheckOrderView(level, selectedPeriod, modalManager, unwindToDepth) }
}
)
+2 -2
View File
@@ -5867,8 +5867,8 @@ requestBadgeIssue userId UserBadgePurchase {badgePurchaseId, badgeType, purchase
storedAt <- badgeNow
(applied, balance_) <- withStore' $ \db -> do
applied <- applyBadgeStatement db g badgePurchaseId badgeType statement verifiedCred_ storedAt
-- the service reads the store only for a request whose asserted coverage had ended when it was made;
-- to any other it repeats the state it stored, which for an annual plan can be a year old
-- past the asserted coverage the state is the store's, except within an hour of an earlier check, when it can be
-- the last successful check's (a later fresh answer or device report corrects it); before then it can be a year old
when (L.paidThrough lastEntry <= now) $ forM_ subscription $ setBadgeSubscriptionStatus db badgePurchaseId . boundedSubscriptionState
(applied,) <$> getBadgeLedgerLastEntry db badgePurchaseId
-- the statement is applied either way, so a month can be spent with nothing to show for it