PR #7716

Badge subscriptions

Supporter badges (supporter and legend tiers) were prepaid: a code bought on the web, or a one-month store purchase not yet offered. This PR sells them as App Store and Google Play subscriptions, monthly or annual, across the badge service, core and apps. On iOS this release keeps buying and Restore hidden: the app opens the badge page on the site and redeems the code bought there.

Who holds what: the apps buy, restore and report; core holds receipts, each held for a purchase, a move or a tier change, and the client ledger, and talks only to the badge service; the service verifies with the App Store Server API and the Play Developer API, credits, renews, changes tier and moves a subscription to another key.

Buying

The app passes the store's receipt, through core, to the badge service, which checks it with the store and credits the badge.

Buying a subscription on iOS: the app creates the record in core, buys with StoreKit, hands the signed transaction to core, core's worker sends purchaseBadge, the service verifies the JWS offline and asks the App Store Server API by original transaction id, then credits the charge; the app finishes the transaction when core reports it credited.
Buying a subscription on Android: the app creates the record in core, buys with Play Billing, hands the token to core without acknowledging it, core's worker sends purchaseBadge, the service reads the subscription from Play by token, acknowledges it just before crediting, and writes the payment, charge and ledger in one transaction.

Renewals

The app requests each month's badge credential at the weekly moment all apps share, then daily, and before issuing a month the service asks the store, at most hourly per subscription. Once the paid months run out, a new charge funds the next month, from its billing-period start and as the tier the store charged; while they run, as in an annual plan, a refund or revocation the store reports ends the badge, its months not yet issued debited as a refund. The app also reports the device's view of the store, such as auto-renewal off, for the badge screen and banners.

The renewal check: a request with no month due asks no store; otherwise the service asks the store at most once an hour, stamping the check first. During paid coverage a store that reports the subscription ended or the granted charge revoked ends it, the unissued months debited as a refund, and any other answer issues the month. After coverage a grantable charge, of any tier, is written with the issued month in one transaction that locks the purchase and re-reads the tip; otherwise the charge and state are recorded and the answer carries no credential.
Subscription states: renewing, cancelled, billingRetry and ended, what each store state maps to, and the Your badge row and chat-list banner each shows.

Changing tier

The app never starts a change. On iOS the user changes plan in Apple's subscription sheet; all four subscriptions share one group, so the original transaction id, the purchase and its key stay.

Changing tier in the App Store: core holds a higher tier bought after the last charge credited and not expired as a tier change and sends upgradeBadgeSubscription on the same key, up to 24 times per hand-over; the service asks Apple, ends the old tier's remaining months, grants the new tier from the charge and issues its month, which core shows at once. A lower tier, or a raise whose transaction expired first, waits for the renewal check.

Restore: moving to another device or profile

Restore purchases (Support SimpleX screen; hidden on iOS in this release) hands the store's current subscriptions to core, which asks the service to move one to fresh keys of the active profile, unless that profile already shows a badge: while it is held, core refuses the others, and the app carries on past those refusals, reporting a failure only when nothing was restored. The service asks the store, ends the old purchase, which it answers "ended" from then on, and credits the new key with the current charge; the new badge shows at once. The device it left keeps the badge it holds until that credential is due for renewal, then retires it.

A store receipt is no secret: whoever holds it can move the badge to their own keys. So a subscription moves again only once the badge already issued to the key it left has expired, up to 39 days, which keeps each paid month with at most two keys; an App Store receipt moves it only while it is the subscription's latest transaction, a Play token for the subscription's life. The service now links the two purchase keys, as the same store transaction funds both, but still not either to a profile. Badges bought by card, crypto or code don't move.

Restore on another device or profile: core holds each restored transaction as a move under fresh keys and sends transferBadge; the service refuses an older App Store transaction, a payment moved too recently or a charge it could not credit, and otherwise ends the old purchase, moves the payment and credits the new key, issuing its month.

How a subscription ends

While the store retries a failed payment, the badge stays (on iOS, until the device reports it expired), though contacts see it as expired a week after its last credential expires, and the profile can't buy or redeem another. It is removed as soon as the service answers that the subscription ended; once the paid months have run out, when Apple's store on the device reports it expired; or after 67 days of answers that renewed nothing: 60 days, the longest either store retries, plus 7 days of contacts' grace. If the service never replies and the device reports nothing, the badge stays. A retired subscription the store charges again comes back when the device next hands that charge over, unless the profile has had another badge since: then a hand-over moves it to fresh keys, as Restore does, once no badge shows, and until then the device keeps the transaction unfinished.

The retirement window on a day scale after paidThrough: the badge retires at the first answer 67 days after the first answer that renewed nothing; a long gap starts a fresh run, a renewal clears it, and requests that get no reply leave it unchanged.

Protecting the service and the stores

Purchases and Restores that ask a store, requests with a month due and tier changes, and everything else run on separate queues, so fake purchases can delay only other purchases and Restores. Each subscription is checked at most hourly per purpose (purchase, Restore, renewal), and the purchase path makes at most 300 Play subscription checks a minute; a Play purchase turned away for three days is refunded.

Request lanes: codes, invoices, App Store one-time purchases and issue requests with no month due go to the main lane, which asks no store; Play purchases, App Store subscription purchases and Restores to the purchase lane, limited per claim to once an hour and Play to 300 checks a minute; issue requests with a month due and App Store tier raises due a check to the renewal lane, limited to once an hour per subscription. Every write to a ledger locks its purchase first.

Protocol and database changes

Known limits

Before sale

Not built: iOS resubscription on another device or profile or to another badge, Restore for badges bought by card or crypto, seeing a Play refund that leaves the subscription active, and spreading the weekly moment.

Store setup: the product ids and every store setting are the store setup checklist in the badge service's apps/simplex-badge-service/README.md. Besides them: server API credentials, enough quota, translations, store review (Sandbox purchases are refused as test purchases, an open question), and products hidden until these apps ship.