From 8cdfed8f272bfe65d58ab08ac54cf773e5dc5c33 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:59:32 +0000 Subject: [PATCH] update badge subscriptions docs (#7802) --- apps/multiplatform/product/gaps.md | 8 ++--- docs/protocol/badges-overview.md | 2 +- docs/protocol/badges-rpc.md | 4 +-- .../buy-android.svg | 4 +-- .../buy-ios.svg | 2 +- .../renewal-check.svg | 30 +++++++++++++++---- .../request-lanes.svg | 16 +++++----- .../summary.html | 24 +++++++-------- .../summary.md | 18 +++++------ .../tier-change.svg | 12 ++++---- 10 files changed, 65 insertions(+), 55 deletions(-) diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index 7c406f81f3..2caf213e0e 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -355,13 +355,13 @@ Desktop users cannot pick a skin tone in the picker, and search in other languag ## GAP-10: Badges: Limits on Moving a Badge to a New Device or Profile -**Severity:** Medium +**Severity:** Low **Category:** Feature / Payments **Platforms:** Android ### Description -Play gives a resubscription a new purchase token, so subscribing again on the same install is credited as a new purchase. On a new device or under another profile, Restore hands over the subscriptions in Play's list as restored, and the service moves the subscription to the restoring profile's purchase key. Restore is refused while the restoring profile shows a badge. A payment moves again only once the credential issued to the key it last moved from has expired; until then the move is refused `transfer_limited`. The first Restore's own answer doesn't say so: the app hears of the refusal only from core's purchase-refused event, which core sends only for a record holding an invoice id, and the next Restore is answered `transfer_limited` itself. A badge bought with Stripe or crypto cannot be moved at all. A plan change made in Play's own pages gets a new token, which the app's sweep finds but core does not record, so only Restore claims it, and not under a profile that shows a badge, so the profile showing the old badge claims it only once that badge ends; until then it stays unacknowledged, which Play refunds after three days. +Play gives a resubscription a new purchase token, so subscribing again on the same install is credited as a new purchase. On a new device or under another profile, Restore hands over the subscriptions in Play's list as restored, and the service moves the subscription to the restoring profile's purchase key. Restore is refused while the restoring profile shows a badge. A payment moves again only once the credential issued to the key it last moved from has expired; until then the move is refused `transfer_limited`. The first Restore's own answer doesn't say so: the app hears of the refusal only from core's purchase-refused event, which core sends only for a record holding an invoice id, and the next Restore is answered `transfer_limited` itself. A badge bought with Stripe or crypto cannot be moved at all. The app starts no plan change, and when tested Play's own pages offered none for these products; a plan change made there, should one become possible, would get a new token, which the app's sweep finds but core does not record, so only Restore claims it, and not under a profile that shows a badge, so the profile showing the old badge claims it only once that badge ends; until then it stays unacknowledged, which Play refunds after three days. ### Affected Locations @@ -370,10 +370,10 @@ Play gives a resubscription a new purchase token, so subscribing again on the sa ### Impact -A subscriber who moves the badge again before the previous key's credential expires waits until it does, and the first such Restore may give no reason. A badge bought with Stripe or crypto stays with the profile it was credited to. A subscriber who changes plan in Play keeps the old badge until its paid months end, and can lose the new plan to Play's refund before it is credited. +A subscriber who moves the badge again before the previous key's credential expires waits until it does, and the first such Restore may give no reason. A badge bought with Stripe or crypto stays with the profile it was credited to. Should Play offer a plan change for these products, a subscriber who makes one keeps the old badge until its paid months end, or until the service reads the old subscription ended at a month's check, and can lose the new plan to Play's refund before it is credited. ### Recommendation 1. Restore by a receipt code for Stripe and crypto purchases, shared with iOS. 2. Announce a limited move on the first Restore. -3. Credit a Play plan change's new token without waiting for Restore. +3. Before a Play plan change becomes possible, credit its new token without waiting for Restore. diff --git a/docs/protocol/badges-overview.md b/docs/protocol/badges-overview.md index cb3b4e0673..351973a897 100644 --- a/docs/protocol/badges-overview.md +++ b/docs/protocol/badges-overview.md @@ -149,7 +149,7 @@ In both cases the app generates the master key and the purchase key pair before ### Monthly issuance -A credential is issued for one month at a time, however many months were bought. The service keeps a count of the months remaining for each purchase key, and issues the next credential when the app asks for it. The months of an app store subscription are added as the store charges for them: the service asks the store whether it charged again once the paid months have run out, or at once when the app reports a higher tier, which it does for an App Store raise the device sees before the raised charge expires. The app asks about such a raise, or about a lapsed subscription its store charged again, a bounded number of times each time the device hands the charge over, and again after the next hand-over. Any other App Store change of tier is granted at the first check after the old plan's paid months run out, though Apple bills a raise at once. A Play plan change is a new purchase under a new token, credited only when Restore hands it over (see [Upgrades](./badges-rpc.md#upgrades)). Unused months, e.g. if the app was offline, lapse at the next badge issuance. +A credential is issued for one month at a time, however many months were bought. The service keeps a count of the months remaining for each purchase key, and issues the next credential when the app asks for it. The months of an app store subscription are added as the store charges for them: the service asks the store before issuing each month, so a new charge funds the next month once the paid months have run out, and a refund or revocation the store reports while they run, as in an annual plan, ends the badge; it also asks at once when the app reports a higher tier, which it does for an App Store raise the device sees before the raised charge expires. The app asks about such a raise, or about a lapsed subscription its store charged again, a bounded number of times each time the device hands the charge over, and again after the next hand-over. Any other App Store change of tier is granted at the first check after the old plan's paid months run out, though Apple bills a raise at once. On Play another badge is a new purchase: the app starts no plan change, and Play's pages offered none for these products when tested; one made there would be a new purchase under a new token, credited only when Restore hands it over (see [Upgrades](./badges-rpc.md#upgrades)). Unused months, e.g. if the app was offline, lapse at the next badge issuance. Credentials are issued monthly to limit what the expiry date discloses. If credentials were issued for the whole term, a user who bought a year would hold a credential expiring on a day a year ahead, when few other credentials expire, and this date would be disclosed in every proof for a year. Instead, every credential is issued for one month, and its expiry is rounded to the end of the Monday following the end of the paid month (UTC). For example, if the paid month ends on Wednesday 14 October 2026, the credential expires at the end of Monday 19 October, and so does every credential whose month ends between Monday 12 and Sunday 18 October. All credentials whose paid month ends in the same week thus expire at the same instant. diff --git a/docs/protocol/badges-rpc.md b/docs/protocol/badges-rpc.md index 6eebc5ae0b..e116235136 100644 --- a/docs/protocol/badges-rpc.md +++ b/docs/protocol/badges-rpc.md @@ -37,7 +37,7 @@ The client never states what is signed. The tier is that of whatever funded the - A store transaction is claimed by its own id, read from the evidence before verification, and credited only when the verified transaction carries that same id. An App Store auto-renewable subscription is claimed by its `originalTransactionId`, which every renewal keeps, so all its charges fund one purchase; a Play subscription keeps its token across renewals already. A receipt already credited to the signing key is answered from the record, without asking the store. - **Subscriptions.** A Play subscription is verified by reading the subscription itself, not by the one-time purchase read; an App Store one by its JWS offline, as any transaction, and then by reading the subscription's status. The latest charge the store reports is credited as a `charge` credit for the months of the plan it was for, dated to the store's billing period start, and the response carries `subscription`. A charge is credited only while its period runs, if it is not revoked, not in a free trial or introductory price, for a plan this service sells, while the subscription has not ended, and while the months its plan grants from its period start still run (Play derives that start by clipping at month ends, so a period's last days can fund nothing). Otherwise nothing is written. No record yet bounds how often the store is asked, so a subscription is asked about at most once an hour on this path, an App Store one per original transaction id and a Play one per token, in an hour not shared with `transferBadge`'s: presented to `purchaseBadge` again inside that hour, by any key (and an App Store one as any of its transactions), it is answered `payment_pending` with the time left, and an answer whose `retryAfter` would be shorter carries the hour. Across all requests, this path asks Play about at most 300 subscriptions a minute (one it credits is read once more to acknowledge it): a Play subscription presented when none is left is answered `rate_limited` with the seconds left in the minute, and is not held off. The answers: `payment_pending` with an hour for a period that has not started yet or a store that reports the payment pending; `receipt_invalid` for a Play subscription that has ended, since Play issues a new token when it is bought again; `payment_pending` with a day for everything a later charge on the same receipt could still change — a revoked charge, one in an offer, no charge yet, a period over while the store still retries, a charge whose months from its period start have already run out, a plan this service does not sell, and an App Store subscription that has ended, which a resubscription under the same original id would continue. A charge whose period does not end after it starts is answered `internal`, and a deployment that cannot read a store's subscriptions answers `provider_not_configured`. - Errors: `receipt_invalid`, `test_purchase`, `payment_pending`, `provider_unavailable` — see the classes below; `rate_limited` for a Play subscription when this path's Play checks for the minute are used; `receipt_used` when another key was credited with it; `product_unavailable` for a product this service does not price; `provider_not_configured` when this deployment has no verifier for the store. `receipt_invalid`, `test_purchase` and `receipt_used` are the only answers on which the client drops the keys it signed with and finishes the store transaction. Every other answer records nothing and leaves the purchase with its keys, to be presented again at the next trigger. - - **Which class an answer belongs to.** The two mistakes do not cost the same, so when it is not certain, answer unreachable. `receipt_invalid` tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good. + - **Which class an answer belongs to.** The two mistakes do not cost the same, so when it is not certain, answer unreachable. `receipt_invalid` tells the client the purchase is dead: it drops the keys it signed with and finishes the store transaction — consumed on Play, finished on StoreKit — and nothing can present it again, so a buyer answered this by mistake has paid for a badge they can never receive. Answering unreachable by mistake costs one more verification request each time the app presents the purchase again, and a Play purchase left unacknowledged for three days is refunded to the buyer. It also locks the buyer out: the app offers no Buy while a purchase is open, so until that receipt settles the profile holding it cannot buy any badge, and an answer that never changes leaves it that way for good, but for an App Store subscription charge, which stops holding the profile a day after its period ends. - **Terminal**, `receipt_invalid` — only a verdict no later attempt could change: the store answered about this purchase and cannot change its mind, or the evidence can never verify. Anything that might answer differently later — the store's lag, an outage, this service's own misconfiguration or bug, a shape of evidence this service does not know yet — is one of the other classes. - **Terminal and named**, `test_purchase` — the store vouched for a purchase that cost nothing: Apple's `Sandbox`, or Play's `purchaseType` test. It is separated from `receipt_invalid` for one reason: the device that made the purchase already knows it was a test one, so naming it reveals nothing a forger could iterate on, and it lets the app say why instead of reporting a generic failure. Every other refusal stays lumped together for exactly that reason. - **Pending**, `payment_pending` — a real purchase the store has not settled. @@ -72,7 +72,7 @@ Always a new purchase under a new key, except App Store subscriptions, where the - Non-store (not built): `getBadgeInvoice.upgrade` — `fromPurchaseKey`, the old purchase's `receipt`, `receiptSignature` binding the old key to the new, and the asserted old `balance`. The invoice returns the conversion `credit`; settlement records `debit(upgrade)` on the old purchase and the credit on the new. - Store one-time (not built: `purchaseBadge` with `upgrade` is answered `bad_request`): an upgrade SKU at a fixed discounted price; `purchaseBadge.upgrade` — `fromPurchaseKey`, `receipt`, `receiptSignature` — proves eligibility (an unexpired cheaper badge), because the store cannot gate who buys the SKU. - App Store subscription: the client never starts a change; the user makes it in Apple's manage-subscriptions sheet, and it keeps the original transaction id, as all badge subscriptions share one subscription group. A higher tier, which Apple charges at once with a prorated refund, is sent as `upgradeBadgeSubscription` when the device sees its transaction bought after the purchase's last charge credit and not yet expired, however late the device sees it, and is granted as soon as the store reports the charge; each hand-over is retried a bounded number of times, and the next one holds it again. Every other change — every lower tier, which Apple charges from the next renewal, a change of plan length, and a higher tier whose transaction has expired or is no newer than the last charge credit — the client sends nothing: the service reads the change from the store and grants the badge type the store charged at its next check after coverage ends. -- Play subscription: the client never starts a plan change. One made in Play's own pages gets a new purchase token, so it is a new purchase under a fresh key, credited as a first purchase is, and the old token is granted nothing more. No client hands that token over unprompted: the app's sweep finds it in Play's list, but core records a listed token only when it already holds a record of it, so only Restore hands it over, and Restore is refused while the profile shows a badge. Until then it stays unacknowledged, which Play refunds after three days (see Google Play above). +- Play subscription: the client never starts a plan change. Each badge is its own Play product, with a monthly and an annual base plan, and when tested (2026-10-22) Play's own pages offered neither another badge nor another billing period, so another badge is bought as a new purchase once the current one ends. A plan change made in Play's own pages, should one become possible, gets a new purchase token, so it is a new purchase under a fresh key, credited as a first purchase is, and the old token is granted nothing more. No client hands that token over unprompted: the app's sweep finds it in Play's list, but core records a listed token only when it already holds a record of it, so only Restore hands it over, and Restore is refused while the profile shows a badge. Until then it stays unacknowledged, which Play refunds after three days (see Google Play above). ## Catalog diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/buy-android.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/buy-android.svg index 39849626dd..c7281e8983 100644 --- a/plans/2026-10-13-badge-subscription-renewals-sketches/buy-android.svg +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/buy-android.svg @@ -116,6 +116,6 @@ If it is never acknowledged (any refusal before step 8), Play refunds it after three days. If anything fails after step 8, it stays paid and acknowledged, and only a later retry credits it. Renewals keep the token; the service asks Play itself. Step 8's read is not counted against the 300 subscription checks a minute. - Subscribing again, or changing plan in Play, gets a new token: a new purchase. The sweep hands over a token core has no - record of too, and core leaves it for Restore, which is refused while the profile shows a badge. + Subscribing again gets a new token: a new purchase. The sweep also hands over tokens core has no record of; core leaves them + for Restore. A retired purchase's token that Play charges again resumes it: up to 24 tries each time the sweep hands it over. diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/buy-ios.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/buy-ios.svg index 4fe7f1527b..a711d34a40 100644 --- a/plans/2026-10-13-badge-subscription-renewals-sketches/buy-ios.svg +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/buy-ios.svg @@ -103,7 +103,7 @@ Renewals: StoreKit lists each renewal's transaction, which the app hands over as listed. It names the same original id, so it joins the credited record and is finished. The service learns of a new charge only from its own renewal check. - A higher tier bought within a day is held and sent as upgradeBadgeSubscription instead (see Changing tier), and a new + A higher tier newer than the last charge credited is held and sent as upgradeBadgeSubscription (see Changing tier), and a new charge of the profile's retired purchase resumes it. A revoked subscription transaction is finished without being handed over. Apple has no acknowledgement step. diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/renewal-check.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/renewal-check.svg index 7f67a33e45..a17a36e665 100644 --- a/plans/2026-10-13-badge-subscription-renewals-sketches/renewal-check.svg +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/renewal-check.svg @@ -1,11 +1,11 @@ - + The service's renewal check - issueBadge for a purchase a subscription funds. The store is asked only after paid coverage ends, or at once for a raised App Store tier. + issueBadge for a purchase a subscription funds. The store is asked whenever a month is due, or at once for a raised App Store tier. @@ -56,8 +56,26 @@ - Unchanged path: lapse, then issue a due month - or answer the current credential. No store call. + No month due: answer the current credential, + no store call. A month due: checked first (right). + + + + + While coverage runs (annual plans) + + A month due is asked of the store too, at + most once an hour, stamped first. + Ended, or the charge it granted revoked: + one transaction locks, lapses, and debits + the months not yet issued as a refund. + → no credential, subscription ended + A stored ended within the hour: the same. + Any other answer, or a store that fails: + the month is issued; a new charge is only + recorded, granted once coverage ends. + → credential + subscription + Answer from what it last read: no new @@ -69,8 +87,8 @@ One transaction, after signing the month: - re-read the tip (moved → internal); insert the - charge; lapse to the store's period start; + lock and re-read the tip (moved → internal); + insert the charge; lapse to its period start; another tier: debit the old run (upgrade); grant, lapse to now, issue; set tier, state. → credential + subscription diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/request-lanes.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/request-lanes.svg index 13e1cf603f..90d63e8c1d 100644 --- a/plans/2026-10-13-badge-subscription-renewals-sketches/request-lanes.svg +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/request-lanes.svg @@ -1,4 +1,4 @@ - + @@ -26,8 +26,8 @@ Apple, one-time Apple, auto-renewable Google, any product - not due a store check - due a store check + no subscription month due + a subscription month due Restore, a subscription due a store check @@ -46,7 +46,7 @@ Main lane codes, invoices, App Store one-time purchases (verified offline), - issue requests whose subscription is not due a store check + other issue requests: prepaid, or no subscription month due asks no store @@ -64,8 +64,8 @@ Renewal lane - issueBadge signed by the key it names, its subscription due a - store check judged 60 s ahead, or whose routing read failed; + issueBadge signed by the key it names, for a subscription with + a month due judged 60 s ahead, or whose routing read failed; upgradeBadgeSubscription of an App Store one due a check Per subscription: once an hour, stamped before asking, failures @@ -88,6 +88,6 @@ - A ledger is appended from every lane (a Restore ends the old purchase's on the purchase lane). Each write re-reads the tip, which is - enough while transactions run one at a time: SQLite, or Postgres at --pool-size 1. A larger pool needs a row lock, not built. + A ledger is appended from every lane (a Restore ends the old purchase's on the purchase lane). Each write first locks its purchase's + row, then re-reads the tip, so the lanes may write concurrently at any Postgres --pool-size. diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/summary.html b/plans/2026-10-13-badge-subscription-renewals-sketches/summary.html index b93bdb763d..0420d4626a 100644 --- a/plans/2026-10-13-badge-subscription-renewals-sketches/summary.html +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/summary.html @@ -88,9 +88,9 @@

Renewals

-

Once the paid months run out, the app requests the next badge credential at the weekly moment all apps share, then daily, and only then does the service ask the store whether it charged again, at most hourly per subscription. A charge counts from its billing-period start, and grants the tier of the plan the store charged. The app also reports the device's view of the store, such as auto-renewal off, for the badge screen and banners.

+

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: while paid coverage runs, issueBadge asks no store; once it has ended, the service asks the store at most once an hour, stamping the check first; a grantable charge, of any tier, is written with the issued month in one transaction that re-reads the tip; otherwise the charge and state are recorded and the answer carries no credential.
+
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.
@@ -101,12 +101,12 @@

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 within a day as a tier change and sends upgradeBadgeSubscription on the same key; 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 late raise waits for the renewal check.
+
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.
@@ -121,7 +121,7 @@

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.

+

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; Restore then takes it once no badge shows.

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.
@@ -129,9 +129,9 @@

Protecting the service and the stores

-

Purchases and Restores that ask a store, renewal checks 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, and the purchase path makes at most 300 Play subscription checks a minute in total; a Play purchase turned away for three days is refunded.

+

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 not due a check 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 and App Store tier raises due a store check to the renewal lane, limited to once an hour per subscription.
+
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.
@@ -147,20 +147,16 @@

Known limits

    -
  • Crediting and renewals are tested only against fake stores built from the stores' documentation.
  • -
  • A retired Play subscription is never checked again: if Play still charges it (after a passing "ended", a pause over 67 days, or 67 days of service or store errors), the user pays for nothing. On iOS, a charge that device sees during its period brings the badge back, unless the profile has had another badge since.
  • +
  • Automated tests use fake stores built from the stores' documentation. On 22 October the lifecycle was walked by hand on an iPhone and an Android phone, against Apple's sandbox and Play's test purchases, with the real verifiers; the review fixes made that night are tested only automatically. Real money, real billing dates and the stores' own retries and refunds are still unexercised.
  • A purchase completing while another badge shows waits for it to end, and may meanwhile be refunded or lose paid periods.
  • -
  • An App Store upgrade the device sees more than a day after its charge waits for the renewal check: until the old tier's paid months end, the profile shows the old tier while Apple bills the new one.
  • -
  • Play may refund an unacknowledged plan change before Restore claims it; the profile showing the old badge can claim it only once that badge ends.
  • A Restore refused because the subscription moved recently may say nothing the first time.
  • Play purchases are acknowledged before being signed and recorded; if either fails, Play won't refund, and only a later successful retry credits them.
  • -
  • On Postgres, the service needs one database connection.

Before sale

-

Not built: iOS resubscription on another device or profile or to another badge, Restore for badges bought by card or crypto, refunds (a refunded annual keeps its badge), and spreading the weekly moment.

+

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: App Store: one subscription group with legend at a higher level than supporter, as only supporter to legend is treated as a raise; Billing Grace Period and Family Sharing off (once on, Family Sharing can't be undone). Play: minimum grace period, account hold on, pausing off. Both: no offers of any kind, 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.

diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/summary.md b/plans/2026-10-13-badge-subscription-renewals-sketches/summary.md index 918209e67c..a51b2e77d4 100644 --- a/plans/2026-10-13-badge-subscription-renewals-sketches/summary.md +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/summary.md @@ -17,7 +17,7 @@ The app passes the store's receipt, through core, to the badge service, which ch ## Renewals -Once the paid months run out, the app requests the next badge credential at the weekly moment all apps share, then daily, and only then does the service ask the store whether it charged again, at most hourly per subscription. A charge counts from its billing-period start, and grants the tier of the plan the store charged. The app also reports the device's view of the store, such as auto-renewal off, for the badge screen and banners. +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](renewal-check.svg) @@ -27,9 +27,9 @@ Once the paid months run out, the app requests the next badge credential at the 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. -- **Raising the tier:** Apple charges the new tier at once and refunds the rest of the old period. When the device sees that transaction within a day of its charge, core sends it to the service, which asks Apple, ends the old tier's remaining months and grants the new tier from the charge. The new badge shows at once. +- **Raising the tier:** Apple charges the new tier at once and refunds the rest of the old period. When the device sees that transaction before it expires, core sends it to the service, retrying up to 24 times each time the device hands it over; the service asks Apple, ends the old tier's remaining months and grants the new tier from the charge. The new badge shows at once. - **Lowering the tier, or any other change:** the app sends nothing, and the renewal check grants what Apple charged; Apple charges a lower tier from the next renewal. -- **Play:** a plan change made in Play gets a new token the app doesn't record; only Restore claims it, and not under a profile that shows a badge. +- **Play:** each badge is its own product, the app starts no plan change, and Play's pages offered none in testing; another badge is a new purchase, which the app allows once the current badge has ended. ![Changing tier](tier-change.svg) @@ -43,13 +43,13 @@ A store receipt is no secret: whoever holds it can move the badge to their own k ## 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. +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; Restore then takes it once no badge shows. ![The retirement window](retirement-window.svg) ## Protecting the service and the stores -Purchases and Restores that ask a store, renewal checks 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, and the purchase path makes at most 300 Play subscription checks a minute in total; a Play purchase turned away for three days is refunded. +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](request-lanes.svg) @@ -61,17 +61,13 @@ Purchases and Restores that ask a store, renewal checks and tier changes, and ev ## Known limits -- Crediting and renewals are tested only against fake stores built from the stores' documentation. -- A retired Play subscription is never checked again: if Play still charges it (after a passing "ended", a pause over 67 days, or 67 days of service or store errors), the user pays for nothing. On iOS, a charge that device sees during its period brings the badge back, unless the profile has had another badge since. +- Automated tests use fake stores built from the stores' documentation. On 22 October the lifecycle was walked by hand on an iPhone and an Android phone, against Apple's sandbox and Play's test purchases, with the real verifiers; the review fixes made that night are tested only automatically. Real money, real billing dates and the stores' own retries and refunds are still unexercised. - A purchase completing while another badge shows waits for it to end, and may meanwhile be refunded or lose paid periods. -- An App Store upgrade the device sees more than a day after its charge waits for the renewal check: until the old tier's paid months end, the profile shows the old tier while Apple bills the new one. -- Play may refund an unacknowledged plan change before Restore claims it; the profile showing the old badge can claim it only once that badge ends. - A Restore refused because the subscription moved recently may say nothing the first time. - Play purchases are acknowledged before being signed and recorded; if either fails, Play won't refund, and only a later successful retry credits them. -- On Postgres, the service needs one database connection. ## Before sale -Not built: iOS resubscription on another device or profile or to another badge, Restore for badges bought by card or crypto, refunds (a refunded annual keeps its badge), and spreading the weekly moment. +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: App Store: one subscription group with legend at a higher level than supporter, as only supporter to legend is treated as a raise; Billing Grace Period and Family Sharing off (once on, Family Sharing can't be undone). Play: minimum grace period, account hold on, pausing off. Both: no offers of any kind, 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. diff --git a/plans/2026-10-13-badge-subscription-renewals-sketches/tier-change.svg b/plans/2026-10-13-badge-subscription-renewals-sketches/tier-change.svg index b6e4741c10..0b25418f5f 100644 --- a/plans/2026-10-13-badge-subscription-renewals-sketches/tier-change.svg +++ b/plans/2026-10-13-badge-subscription-renewals-sketches/tier-change.svg @@ -1,4 +1,4 @@ - + @@ -48,8 +48,8 @@ - credited record; a higher tier, - bought within a day of now? + a higher tier, newer than the last + charge credited, not expired? yes → held as a tier change no → answered from the record; the renewal check grants it later @@ -91,7 +91,7 @@ Any other change (a lower tier, which Apple charges from the next renewal, or a change of plan length): the app sends nothing, and - the renewal check after the old coverage ends grants what Apple charged. A raise the device sees more than a day late waits the same way. - Play: the app starts no plan change. One made in Play gets a new token that core doesn't record; only Restore claims it, - and not under a profile that shows a badge. + the renewal check after the old coverage ends grants what Apple charged. A raise whose transaction expired unseen waits the same way. + A held raise is sent up to 24 times each time the device hands it over, then set aside until the next hand-over. + Play: each badge is its own product, with no plan change from the app or, in testing, Play's pages; another badge is a new purchase.