* core, badge-service: name Apple claims by reference
Rename appleTransactionId to appleTransactionRef, matching googlePurchaseRef
and StoreTransactionRef. No behaviour change: the next change makes it
return a subscription's original transaction id, which the old name would
misstate.
Run: 001-badge-subscriptions, slice 1
* core, badge-service: store subscription charges and carry the subscription state
Add the service migration 20261012_badge_subscriptions: the subscription
charges table, the ledger's charge_id, and the payment's subscription state,
check time and Play token, replacing three subscription columns nothing
wrote. The ledger now stores and reads back charge credits, and the badge
credential response gains an optional subscription state that older clients
ignore and that reads an unknown state without failing. Nothing writes a
charge or a state yet, so no behaviour changes.
Run: 001-badge-subscriptions, slice 2
* core, badge-service: plan subscription charges and claim Apple subscriptions by original id
Add chargeEntries, the one planner for a store charge's ledger rows: a
lapse to the billing period start, the grant dated to that start, and a
lapse to now, so coverage follows the store's periods and every row
verifies by re-running it at its createdAt. A test runs its rows through
the client's own balanceChecked for first charges, monthly and annual
renewals, billing-retry recovery and grace.
An Apple auto-renewable transaction is now claimed by its original
transaction id, which every renewal keeps, so renewals resolve to one
purchase. One function names the ref for both the claim and the real
verifier, so they cannot disagree.
Run: 001-badge-subscriptions, slice 3a
* badge-service: credit a store subscription purchase
The catalog now tells subscription products from one-time ones and maps a
store plan to its tier and months. A subscription receipt is checked with
the store once: with a grantable charge, the service plans the charge's
rows from the store's period start, signs the first month, and writes the
payment, purchase, charge, ledger rows, issuance, state and Play token in
one transaction, answering with the subscription state. Without one, it
answers payment_pending (300 s while the period has not started or the
store is pending, a day otherwise), receipt_invalid for an ended Play
subscription, or test_purchase, and writes nothing. Replays carry the
state. The store verifier interface gains the subscription check, which no
real store implements yet; the fake store does.
Run: 001-badge-subscriptions, slice 3b
* badge-service: renew a subscription once its paid coverage ends
While a subscription purchase's coverage runs, issueBadge is unchanged
and makes no store call. Once it has ended, the service asks the store at
most once an hour per subscription, stamping every attempt, and answers
from the stored state in between; a failed check is retried after the
whole interval. A grantable charge is planned from the tip at the store's
period start, signed, and written with its issuance and state in one
transaction that re-reads the tip, answering internal if it moved. Any
other charge and the state are recorded without a ledger row. Every answer
for a subscription purchase carries its state.
Run: 001-badge-subscriptions, slice 4
* core: keep a subscription badge until the service says it ended
A purchase is a subscription exactly when its ledger replica holds a
charge credit, so the kind is derived with no column or migration. A
subscription is still asked about at zero months, at the shared weekly
moment, and keeps its badge past paidThrough until an answer says the
subscription ended, so a renewed month is presented at the old
credential's expiry with no gap, and a charge the store recovers later
still re-activates it. While it waits, the client records no failure and
raises no support-ended alert; "issued no credential" now fires only while
the ledger still owes a month. Prepaid purchases are unchanged.
Run: 001-badge-subscriptions, slice 5
* badge-service: check Apple subscriptions with the App Store Server API
With an in-app purchase key configured in [apple], the service reads a
subscription's status by its original transaction id from Get All
Subscription Statuses, authenticated by an ES256 token reused until near
expiry. Each signed transaction and renewal info is verified against the
pinned Apple root through the same chain check as one-time purchases, now a
separate function, and a body that fails it is a verifier failure, never a
refusal. Statuses map to renewing, cancelled, billing retry and ended;
family-shared transactions are refused; 401 and 403 are this service's
failure, other errors and timeouts unreachable. A verifier failure thrown
by a store check is answered internal, and a test subscription is refused
in one place for first purchases and renewals. Without the key, Apple
one-time purchases verify as before and subscriptions are not configured.
Run: 001-badge-subscriptions, slice 6
* badge-service: check and acknowledge Google Play subscriptions
The Play subscription check reads purchases.subscriptionsv2 by token
through the existing service-account client, with the same HTTP classes as
product reads, and maps Play's state, base plan, period, order id and
offer phase; the period start is its end minus the plan's length, and a
pending subscription stays pending. A subscription is acknowledged with
purchases.subscriptions.acknowledge before it is credited, skipped when
Play reports it acknowledged, so it never reaches the one-time endpoint.
Each store's subscriptions answer provider_not_configured when that store
is not configured. A charge on a plan the catalog does not sell is pending
rather than a malformed period.
Run: 001-badge-subscriptions, slice 7
* review: round 1 fixes
R1-1: test an App Store subscription renewal through issueBadge, including
a charge recorded before its period starts and granted later under the
same charge id.
R1-2: make the ledger's charge_id index unique, so the schema itself grants
each store charge at most once.
R1-3: pin that a charge on a plan the catalog does not sell, with an empty
period, is pending at purchase and recorded at renewal, never internal.
Run: 001-badge-subscriptions, review round 1
* core: update query plans
getBadgePurchase derives the purchase kind from the ledger replica, and a
subscription test makes a held store receipt due.
Run: 001-badge-subscriptions, final verification
* fixes
* core: move SubscriptionState to Badges.Types (structural)
BadgeState, which lives in Badges.Types, is about to carry a subscription's
state, and Badges.Service imports Badges.Types. Service re-exports it, as it
does BadgeServiceErrorCode. No behaviour change.
Run: 003-badge-subscriptions-ui, slice 1
* core: keep a store subscription's state on the client
The screens and alerts need to know whether a subscription renews, was
cancelled, waits for a charge, or ended, and the client dropped every state
the service sent but ended. It is now stored on the purchase: from the
answer to the first credit, and from a renewal answer only when the
asserted tip's coverage had ended, since while coverage runs the service
repeats what it read at the last renewal (a year old for an annual plan).
BadgeState gains `subscription` (the plan's months, from the latest charge
credit, and the state), and fills renewsAt/willRenew from it. A changed
state is announced with CEvtBadgeChanged. The column goes into the
unreleased M20260925 migration.
Run: 003-badge-subscriptions-ui, slice 2
* core: record the device store's view of a subscription
The service reads the store only once a subscription's paid coverage has
ended, so it never sees a cancellation while the period runs. The apps read
the device's store (StoreKit's renewal state, Play's isAutoRenewing) and
report it with /_badge subscription <state> <payment>, which records it on
the credited subscription purchase the payment resolves to and announces the
change to its owner.
A renewing report does not replace billingRetry or ended: Play lists a
subscription in its grace period with auto-renew on, and the service's next
answer clears billing retry once the charge lands.
Run: 003-badge-subscriptions-ui, slice 3
* core: claim a store-listed transaction only on the device that bought it
A store account's purchases are listed on every device signed into it:
Play's purchase query lists active subscriptions for their whole life, and
Apple's Transaction.updates delivers auto-renewables made on other devices.
A hand-over with no record and an unknown echoed invoice made a new record
under the presenting profile, so another device could claim a buyer's
subscription for its own active profile, or repeat a refused request every
30 days after cleanup.
/_badge purchase now takes a `listed` mark, which the apps' sweeps and
purchase listeners send: it joins only a record this core already has for
the transaction or its echoed invoice, and otherwise records and sends
nothing (CRStoreReceiptNotRecorded). The purchase call and Restore hand over
unmarked, as before.
Run: 003-badge-subscriptions-ui, slice 4
* core: let a held App Store subscription adopt its later transactions
A held Apple subscription record is keyed by its original transaction id,
so every renewal resolves to it. It kept the payment's back-off when a new
transaction arrived, and adopted any payment handed over, including a
revoked renewal, which the service refuses as receipt_invalid and so would
refuse the whole subscription.
A held Apple record now adopts a JWS only for another transaction with a
later purchaseDate and no revocationDate, read from the unverified payload,
and is then due at once with its retry delay and credit error cleared. A
re-delivered or re-signed copy changes nothing. A failed attempt writes its
back-off only if the record still holds the payment it sent.
Run: 003-badge-subscriptions-ui, slice 5
* 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
* 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
* badge-service: let the dev mock vouch for store subscriptions
With [dev] accept_unverified_store_receipts on, every subscription was
answered provider_not_configured, so the apps' subscription screens could
not be tried against a dev service. The mock now credits an App Store
subscription for the plan its product names, and a Play subscription for
the base plan a test token test.<basePlanId>.<uuid> names, or else its
product's monthly plan, from a minute ago; it remembers the plan in memory
and grants each renewal a new charge, answering a subscription it does not
know (after a restart) as ended.
The subscription check is given the product the presented receipt names,
which only the mock reads; it reads the service's badge clock at check time.
Run: 003-badge-subscriptions-ui, slice 8
* ios: hand App Store subscriptions to the core and report their renewal state
A subscription transaction was finished uncredited, so a buyer would pay and
get nothing. Every badge product now goes through a core invoice and is
handed over, and finished only once the core answers it credited or refused.
Transactions the store lists (the launch sweep, Transaction.updates) are
handed over as listed, so another device's purchase is not claimed here;
Restore purchases syncs with the App Store and claims them for this profile.
A revoked renewal is finished without hand-over, as the service would refuse
it and so the whole subscription.
The app reports StoreKit's renewal state to the core at each sweep, after the
manage-subscriptions sheet and after a restore. Monthly and annual are offered
only to an Apple ID with no earlier subscription in the group, which Apple
would fold a new one into.
Run: 003-badge-subscriptions-ui, slice 9
* ios: sell badge subscriptions and show their state on Your badge
Monthly and annual subscriptions are offered on How long and Check your
order, with the App Store's subscription disclosure, Terms of use and
Privacy policy links, and Restore purchases. Your badge shows a
subscription's state - Renews {date} · {price}, Ends {date}, Payment
problem, Renewing or Ended - with why another badge cannot be bought or
redeemed while it waits for its charge, Manage subscription and Restore
purchases. The chat list shows one banner for the current badge alert of
any kind: renewal soon, payment problem, cancelled, ended, as well as the
expired and renewal-failed ones it had.
Run: 003-badge-subscriptions-ui, slice 10
* android: hand Play subscriptions to the core and report their renewal state
The Kotlin port of the iOS store change. A Play subscription was
acknowledged uncredited; every badge product now goes through a core
invoice and is handed over. The sweep queries subscriptions as well as
one-time products, and the sweep and purchases listener hand over as listed,
so another device on the Google account does not claim them; Restore claims
them for this profile. A subscription is bought on its base plan's own
offer, and the app never acknowledges one itself: the service does when it
credits it, so Play still refunds one the service never credits.
The app reports Play's isAutoRenewing to the core as renewing or cancelled.
Run: 003-badge-subscriptions-ui, slice 11
* android, desktop: sell badge subscriptions and show their state on Your badge
The Kotlin port of the iOS screens. How long and Check your order offer
monthly and annual with Google Play's subscription disclosure, the privacy
policy link and Restore purchases. Your badge shows the subscription's state
with the same texts as iOS, the store named Google Play with a store lane and
"the store" without one, and Manage subscription and Restore purchases only
with a store lane on the local host. The chat list shows one banner for the
current badge alert of any kind. Desktop shows the state, footers and banners
without store rows or a price.
Run: 003-badge-subscriptions-ui, slice 12
* docs: describe the one badge alert banner in the apps' chat-list specs
Both chat-list specs described two hand-written badge banners (expired,
renewal failed); the chat list now has one banner for the current badge
alert of any kind. Also moves the line links this run shifted.
Run: 003-badge-subscriptions-ui, slice 13
* 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
* badge-service: answer a long-expired Play subscription as ended, and hash Play order ids
Play answers HTTP 410 for a subscription that expired too long ago to be
queried; it was read as unreachable, so the service never answered ended,
the badge was never retired and the profile stayed blocked from buying or
redeeming (R1-2). Play order ids appear on the buyer's receipt email, so
charges now store a hash of the order id, as purchase tokens already are
(R1-10).
Run: 004-badge-subscriptions-review, review round 1
* review: run 003 round 2 and run 004 round 1 app fixes
ios: buy a subscription only while it is still for sale, after re-reading
the Apple ID's transactions, and disable Pay once it is not, so a Check
your order screen opened before another profile or device subscribed
cannot buy one Apple folds into the earlier subscription (run 003 R2-1,
run 004 R1-4). android: skip the subscriptions query only on a device
whose Play does not support subscriptions, so Restore no longer says
"Nothing to restore" when the query failed (run 003 R2-2).
Run: 003-badge-subscriptions-ui round 2; 004-badge-subscriptions-review round 1
* review: round 3 fixes
ios: a subscription refused because this Apple ID subscribed before has
its own error, and Check your order says why Pay is disabled (R3-2).
android: skip the subscriptions query only when Play does not support
subscriptions, so a disconnected client fails the sweep or Restore
rather than reading as no subscriptions (R3-1).
Run: 003-badge-subscriptions-ui, review round 3
* core: update query plans
Regenerated by the full suite for the purchase, subscription status and adoption queries. Run: 003-badge-subscriptions-ui, final step
* badge-service: say what hashing the Play order id protects
The hash is unkeyed, so a holder of the buyer's receipt can still find the row (R2-1). Run: 004-badge-subscriptions-review, review round 2
* badge-service: name the rule for when a subscription is due a store check
The issue path's purchase read becomes getIssuePurchase and its "ask the
store now" test becomes subscriptionCheckDue, so the request router can
read the same rows by the same rule. No behaviour change.
Run: 005-subscriptions-load-timing, slice 1
* badge-service: answer every request that may ask a store on the store lane
App Store subscription purchases and subscription renewal checks called
their store from the main lane, so one slow or repeated store call held up
code redemptions and every other renewal. The router now reads the
purchase the way the issue path does and sends to the store lane every
request that may ask a store: Play purchases, App Store auto-renewable
purchases, and issue requests for a subscription due its store check,
judged a minute ahead so the handler never finds one due on the main lane.
Run: 005-subscriptions-load-timing, slice 2
* core: send no badge request before the wake a request that issued nothing set
While a subscription waited for its store charge, or a prepaid renewal
kept failing, every launch, foreground, profile switch and badge-state
read sent the issue request again, so the badge service saw the user's
app-open times for weeks. A due request now waits for the wake the last
pass stored, as long as that wake is no more than a stall interval away,
so it is asked again a day later as the renewal design says.
Run: 005-subscriptions-load-timing, slice 3
* fixup! core: send no badge request before the wake a request that issued nothing set
* fixup! badge-service: answer every request that may ask a store on the store lane
* badge-service: ask the App Store about a subscription purchase at most once an hour per claim
Anyone holding a real but uncreditable App Store subscription JWS, such as
their own lapsed subscription, could present it again and again, each time
spending an App Store Server API call from the quota every subscriber's
renewal check needs. The purchase path now remembers, in memory, which
subscription claims it asked about within the hour and answers a repeat
payment_pending with the time left. Keys are original transaction ids from
JWS that verified offline, entries expire after the hour, and a full table
asks rather than refuses, so memory stays bounded and no buyer waits on
another's claim.
Run: 005-subscriptions-load-timing, slice 4
* fixup! badge-service: ask the App Store about a subscription purchase at most once an hour per claim
* review: round 1 fixes
badge-service: a Play charge whose clipped period start leaves its month
already spent is answered as period over, not internal (R1-A1).
docs: the per-store answer classes list Play's 410 and the App Store
subscription check (R1-A2).
tests: a held receipt with no attempt yet blocks an invoice (R1-B2); a
renewing report keeps an ended status (R1-B3); a test comment names
[google] (run 005 C2-R2-1).
ios: a subscription refused because this Apple ID subscribed before
shows its sentence, not the error's name (R1-C2); ios, android, desktop:
the Check your order footnotes share one helper (R1-C3).
Run: 006-subscriptions-branch-review, review round 1
* fixup! review: round 1 fixes
* core: wait a day after a badge request that issued nothing, whatever the service or a snooze says
The hold on the stored wake let two moments the client does not schedule end it early. A renewal whose store check failed was answered with an hour's retryAfter, so the next activation after each hour sent it again, sampling when the app is opened. A snooze set before a request ended its wait at the snooze's end, timing the next request by the user's tap. Now a request that issued nothing waits the stall or a ledger moment: retryAfter only marks the failure transient, and a snooze is not a wake while a request waits; the request's own wake raises the alert again.
Run: 005-subscriptions-load-timing, slice 5
* fixup! review: round 1 fixes
* fixup! core: wait a day after a badge request that issued nothing, whatever the service or a snooze says
* badge-service: ask Play about a subscription purchase at most once an hour per token
The purchase path's hourly bound covered only App Store claims, so a re-presented Play subscription token, uncredited or credited to another key, spent a subscriptionsv2.get call each time, from the quota bucket Play renewal checks use. The bound now lives in purchaseSubscription, which both stores' purchase paths call, with one map per store: Play's keyed by the token's hash, so made-up Play tokens cannot fill the App Store's. Distinct made-up tokens are still one call each.
Run: 005-subscriptions-load-timing, slice 6
* badge-service: read Play's paused subscription as waiting to renew
An unmapped SUBSCRIPTION_STATE_PAUSED was answered internal, so a paused subscriber's renewal failed for the whole pause. Play reports a pause with auto-renewal on and charges again when it ends, so it now maps as an active subscription does: renewing, or cancelled with auto-renewal off. Pause is being turned off on the base plan; this is defence in depth. A pause grants nothing: Play names no new order, and the last one is already granted.
Run: 005-subscriptions-load-timing, slice 9
* core: retire a subscription badge once no store could still charge it, whatever the service answers
A subscription badge retired only on an ended answer, so a service that never sent one (a final error such as unknown_purchase_key, a rollback, a service that never answers) left the badge shown, one request a day forever, and the profile unable to redeem, buy or have a held receipt credited. Now it also retires once paidThrough is older than the longest store recovery window: Apple retries a failed renewal for up to 60 days, the longest with Play's pause off, plus a week. paidThrough counts every month the ledger holds, so banked months are never retired by it.
Run: 005-subscriptions-load-timing, slice 8
* ios, android: no Restore purchases on Your badge, where a restored purchase could not be credited
Your badge shows only while a badge is shown, so a purchase Restore found there with no record on this device was held under a profile whose held receipts are not processed while it shows a badge: not credited, nothing said, and on Android acknowledged, ending Play's three-day refund. Restore stays on Check your order, the one screen where a restored purchase can be processed.
Run: 005-subscriptions-load-timing, slice 7
* review: round 4 fixes
R4-1: a subscription badge is asked about first and retired only after the service answers, judged on the ledger after that answer. Retiring before asking used the client's paidThrough, which moves only when the client asks, so a subscriber who had not opened the app for longer than the recovery window while the store kept charging lost the badge at the next launch and was never asked again. A request that is not answered retires nothing. Tests: testSubscriptionRetiredAfterRecoveryWindow changed, testIdleSubscriptionKeptPastWindow and testSubscriptionNotRetiredWithoutAnswer added.
R4-2: one snooze comment where two contradicted each other.
R4-3: the recovery window's comment names Play's clipped period start.
R4-4: the retirement tests use subscriptionRecoveryWindow.
* badge-service: name the lane each request waits on
The router answered "may this request ask a store" as a Bool; renewal checks are to get a lane of their own, which a Bool cannot say. requestLane now names the lane: main, purchase or renewal. Both store lanes still share one queue, so no request changes lane.
Run: 007-subscriptions-retirement-lane, slice 1
* badge-service: check renewals on a lane no purchase waits on
Renewal checks shared the store lane with every purchase, so made-up Play tokens, which anyone can send, and the weekly burst of genuine purchases delayed them. Renewal checks now wait on a lane of their own, which only a request signed by the purchase key it names, and due its hourly store check, can reach.
Run: 007-subscriptions-retirement-lane, slice 2
* badge-service: name the web limiter's fixed-window step
takeToken counted a request against a minute's limit inline. The step becomes takeFrom, a pure function, so the purchase path's Play read allowance can use the same rule. No behaviour change.
Run: 007-subscriptions-retirement-lane, slice 3a
* badge-service: read Play's subscriptions at most 300 times a minute for purchases
A Play subscription purchase is asked about without any offline check, so any token in the accepted grammar spends a read from the Subscriptions quota renewal checks share. The purchase path now takes one of a minute's 300 reads for each Play subscription it asks about, a tenth of Play's default quota; with none left, the claim is answered rate_limited with the seconds left in the minute and is not held, as Play was not asked. A claim already held takes no read, and App Store claims, verified offline first, take none.
Run: 007-subscriptions-retirement-lane, slice 3
* core: count the subscription retirement window from the first answer that renewed nothing
The window was counted from the client's paidThrough, which moves only when the client asks, so after months away the first answer that granted nothing for any reason, a transient error, Play's clipped last days or a billing retry, retired a subscriber whose store may still have been charging. The purchase now stores its unrenewed run: the first answered request after which the ledger covered nothing, and the latest one. The badge retires once the window has passed since the run started, still only after an answer; a renewal ends the run, and an answer that comes a window or more after the latest one starts a new run, since an absence or an outage that long may hide a renewal. The window stays 67 days, written as Apple's 60-day billing retry plus the week contacts give an expired credential.
Run: 007-subscriptions-retirement-lane, slice 4
* review: round 1 fixes
R1-A1: the docs and the PlayReads comment said the purchase path reads Play's subscriptions at most 300 times a minute; it asks about at most 300 subscriptions, and a credited one is read once more to acknowledge it, so made-up tokens can spend at most a tenth of the quota renewal checks share.
R1-A2: a comment on RequestLane repeated the one above the queues.
R1-B1: a test that a held pass, which sends no request, leaves the unrenewed run unchanged.
R1-B2: the outage test's last wait for the run is 30 s instead of 5 s.
Run: 007-subscriptions-retirement-lane, review round 1
* core: add the badge subscription columns in their own migration
M20261016_badge_store_receipts shipped to master in #7587, so it must
stay as released. Restore it in both dialects and move the
subscription_status, unrenewed_since and last_unrenewed_at columns,
with the down step that clears subscription_cancelled acknowledgements
master cannot read, into M20261016_badge_subscriptions.
Run: 008-subs-migration-split, slice 1
* tests: drop the FilePath import a merge duplicated
The branch and master each added import System.FilePath ((</>)) to
Direct.hs on 2026-10-14 at different positions, and an earlier merge
kept both, so GHC warned that one was redundant. Keep master's.
Run: 008-subs-migration-split, slice 2
* fixup! core: add the badge subscription columns in their own migration
* ios, android, desktop: choose a badge in the app
* ios, android, desktop: use the dev badge page for testing (don't merge)
* update postgres schema
* update ios lib
* ios, android: hide Restore purchases on the order screen
* docs: summarize the badge subscriptions PR for reviewers, with diagrams
A summary of PR #7716 as built: buying, renewing and retiring on each
store, the retirement window, lanes and store limits, RPC, API and
database changes, alerts, tests, open items, and a review map. Seven
SVG diagrams, an HTML page with them inline, and a link from the plan.
* ios, android, desktop: restore purchases on the Support SimpleX screen
* ios, android, desktop: show the badge privacy policy in the app
* docs: keep only the badge subscription diagrams, with a README
The reviewer summary leaves the repo; the folder keeps the seven
diagrams with a README that captions each, and the plan links the
README.
* ios, android, desktop: smaller Restore purchases label
* ios: no alert when a badge purchase is cancelled
* docs: replace the badge subscriptions diagrams README with a short summary
A plain-English summary for anyone on the team, with the seven
diagrams and a self-contained HTML page; the plan links it again.
Diagrams follow the branch: Restore purchases is on Support SimpleX,
"Renewing" shows past paidThrough, and only a present retryAfter is
raised to an hour.
* ui
* ui wip
* core: retire a subscription badge as soon as its store says it ended
A subscription badge retired only on an ended answer to a request that was due, and only once the ledger's paidThrough had passed, so an ended answer while the ledger still covered, or the iOS app's report of StoreKit's expired or revoked state, left the badge shown, Support SimpleX unreachable and every purchase refused until a later request. Now an answered ended state is stored even while coverage runs (the service writes it only from a store check, after which it grants nothing), and every pass retires a subscription whose state, from this pass's answer or else as stored, is ended, whatever the ledger covers. The paidThrough guard stays at the prepaid call site, its only remaining user.
Run: 010-subscription-lifecycle, slice S1
* docs: record that a badge subscription cannot be restored or bought again
Restoring a subscription on a new device or under another profile fails for two independent reasons, the client's restore reading only unfinished transactions on iOS and the service answering any key but the one it credited receipt_used, and a lapsed iOS subscriber cannot subscribe again on the same install either. Both gaps files record it in their own format, and both restorePurchases() say why reading the store's entitlements alone would not help.
Run: 010-subscription-lifecycle, slice S2c
* fixup! core: retire a subscription badge as soon as its store says it ended
* transfer plan
* shorter plan
* update plan
* badge-service: let a dev deployment ask its mock store while coverage runs
[dev] check_store_during_coverage = on makes every issueBadge for a
subscription ask the dev mock, during paid coverage too, and bank the
month of a charge it can grant, issued once coverage reaches it. So a
renewal can be watched by hand without waiting a month, and a mock that
forgot the plan after a restart answers ended at once.
Off by default, and refused unless accept_unverified_store_receipts is
on, which is itself refused beside a real store: a real store has not
started its next charge while coverage runs, and asking spends the
quota every subscriber shares.
Run: 011-subscription-tools, slice S3a
* core: send a badge request at once from a build for the dev badge service
/_badge request <userId> sends the profile's badge request now, while
its badge is shown, and runs the badge pass on the answer, so a renewal
or an ended subscription can be watched by hand against the dev
service.
It is allowed only when ChatConfig's badgeDevRequests is set, which no
shipped build does and nothing sets at runtime: the dev-service branch
sets it beside its own badge service overrides. A request off the shared
expiry would tell requests apart, so every other build refuses it, and
it is refused from a paired remote controller.
Run: 011-subscription-tools, slice S3b
* fixup! badge-service: let a dev deployment ask its mock store while coverage runs
* fixup! badge-service: let a dev deployment ask its mock store while coverage runs
* fixup! core: send a badge request at once from a build for the dev badge service
* core: split the badge request's lock from its body
The receipt worker's attempt already holds the owner's badge lock, which is not reentrant, so it cannot call requestBadgeIssue. The body becomes sendBadgeIssue, and both answer a service error as Left (code, retryAfter) instead of Nothing, so that caller can classify it as it does today. No behaviour change.
Run: 011-subscription-resume, slice S2-0
* core: retire on a device's ended report only once the paid period is over
StoreKit can report a subscription expired during Apple's billing retry, and Apple may then recover the charge, so retiring at once on that report left a paying user without the badge. The service's answered ended still retires at once, also while the ledger covers. A stored ended, which is how the device's report arrives, retires once paidThrough has passed.
Run: 011-subscription-resume, slice S1c
* core: resume a retired Apple subscription when its store charges it again
A same-install resubscription keeps Apple's original transaction id, so its hand-over found the old credited record and was answered from it with the old credential: the subscriber paid and got nothing. A credited record is now held again when a later charge of its subscription is handed over, the charge is not revoked, its paid period runs and it is dated less than a day ahead of this device's clock, and the record's purchase is its owner's newest, a subscription and not shown, with no other receipt held. The receipt worker then sends the purchase's issueBadge, and the service grants the charge as a renewal. A covered answer shows the purchase again with the newest credential and clears an ended state. A charge the service answers without crediting is deferred an hour and refused a day after its purchase date, so it holds the profile for about a day. A refused resume is remembered on the record (every credit, re-hold and resume clears credit_error), so the app's sweep does not hold the same charge again after its day.
Run: 011-subscription-resume, slice S2a
* fixup! core: resume a retired Apple subscription when its store charges it again
Review round 1 fixes:
- R1-1: a resumable charge waiting behind another held receipt of the owner is answered held and left unfinished, rather than answered from the record and finished uncredited.
- R1-3: a resume shows the purchase again only with a credential that has not expired.
- R1-2, R1-8: tests of the resumable-charge rule and the hold's edge, of a held charge whose paid period ends before it is sent, and that a resumed profile shows the newest credential. The resume test is split in two, one environment each.
- R2-V1: a test comment that the reordered steps made false.
Run: 011-subscription-resume, review rounds 1 and 2
* core, ios, android, desktop: let a lapsed iOS subscriber subscribe again to the badge the profile retired
The iOS app refused any subscription to an Apple ID that ever subscribed, so a lapsed subscriber could never subscribe again in the app. Core now tells the apps each subscription's store reference (BadgeSubscription.transactionRef, optional in JSON), and the iOS gate opens a subscription card only when every earlier subscription of the Apple ID is the active profile's newest purchase, retired, of the card's badge: the one charge core resumes. Kotlin gets the model field only, as Play gives a resubscription a new token and needs no gate.
Run: 011-subscription-resume, slice S2b
* docs: bound a badge move by the previous credential's expiry
* badge-service: credit test purchases and check the store during coverage in a dev deployment
* core: let a dev build ask the badge service on an interval
* badge-service: log each request's command and each store check's outcome
* fix compilation
* badge-service: name the purchase each request came from in the log
* badge-service: name the purchase each request came from in the log
* core, ios, android: explain a failed badge renewal and stop retrying a subscription the store forgot; tests (#7788)
* core, badge-service, ios, android: change badge tier in the store, and restore a subscription on another device (#7795)
* core: tell the apps when a badge request banks a month without issuing it
A pass announced the badge state only when the balance start moved, so a charge granted onto running coverage, or a debit the service wrote, changed months left and paid-through without an event, and the apps kept showing the old renewal date until the screen was reopened. The pass now announces whenever the ledger's last entry is not the one it started from, which a moved balance start implies.
Run: 015-badge-fixes, slice 1
* core: test that every Play charge of a month-end subscription funds its month
Play reports only a period's end, and the service dates the charge a plan length before it, so month-end clipping could leave a charge funding less than its month. Walk a monthly and an annual Play plan anchored on 31 January through their renewals at the device's request moments, through the deployed Play verifier: each charge funds exactly its months, paid-through stays at most the grace behind Play's period end (three days at most in this walk), and no charge is left without a grant.
Run: 015-badge-fixes, slice 2
* core: test that the newest issued badge credential is taken by its ledger row
After a tier change the newer issuance can end before the older one, so ordering issuances by period end would answer the superseded credential to the worker and to the resume and tier-change paths. Pin the row order: the test fails if the query goes back to period_end order.
Run: 015-badge-fixes, slice 3
* review: round 1 fixes
The App Store tier-change hold, reviewed for the first time:
- R1-B2: a tier change's credit now shows its purchase again (shown_badge_id) in the same transaction, as a resume's does, so a retire that ran while the change was with the service cannot leave a paid purchase unshown and never renewed.
- R1-B5: a tier change of a shown purchase is held whatever other receipt is held, as the worker serves it while the badge is shown; only a resume waits for other held receipts.
- R1-B3: test a tier change the store does not vouch for: refused in silence, the badge kept, and answered from the record after.
- R1-B8: the changeTier comment names when the record settles.
Run: 015-badge-fixes, review round 1
* review: round 2 fixes
- R2-1: a granted tier change clears an ended state the device reported before it. A renewing report never replaces ended, and a charge of the new tier granted proves the subscription has not ended, so without this the worker retired the new tier's badge at its paid-through while the subscription renewed.
- R1-B3: test a tier change whose credential does not verify: the failure stays on the receipt record, not the purchase, and Supporter stays shown.
- R2-3: the refused tier change's test buys Supporter through an invoice, so it shows the refusal announces nothing.
- R2-2: the changeTier comment says what follows each way the record settles.
Run: 015-badge-fixes, review round 2
* review: round 3 fixes
- R3-1: a tier change whose answer says the subscription ended is not credited, as a resume is not, so the credit's clearing of a stored ended never drops the one this answer carries.
- R3-2: the changeTier comment in two lines.
Run: 015-badge-fixes, review round 3
* core: apply an App Store tier change at once only when it raises the tier
Apple keeps charging the old plan to the end of the period, so applying a downgrade at once took away a badge the subscriber had already paid for. A charge of another tier is now held and sent at once only from supporter to legend; a lower tier, or one that is not ranked (investor, unknown), is answered from the record and the renewal check grants it, as badges-rpc.md says. A record already held for such a change settles silently.
Run: 016-tier-change-direction
* core: test that an App Store change to a lower tier waits for the renewal
A Supporter charge handed over while Legend is shown is answered from the record and sends nothing, and the renewal check grants Supporter, shown once the Legend credential expires; a record an older build held for a lower tier settles with no request. Both fail if every change of tier is sent at once again.
Run: 016-tier-change-direction
* badge-service: let a restore ask the store without waiting for the purchase's own check
* docs: describe badge tier changes and restore as the service handles them
The RPC document splits a store tier change by store and direction: the
app never starts one, an App Store raise seen within a day of its charge
is sent as upgradeBadgeSubscription, every other change waits for the
renewal check, and a Play plan change is a new token, a new purchase.
transferBadge and its limits are checked against the handlers, and the
upgrades that are not built are marked so.
The overview admits what Restore discloses: the service links the two
purchase keys, a store receipt moves its subscription to whoever presents
it within the move limit, and a new badge or tier is shown as soon as it
is issued. The Android gap names what remains of Restore.
* docs: tell the badge subscriptions story with tier changes and restore
The summary now covers the whole lifecycle: buying, renewing, changing
tier, moving to another device or profile, and ending. The badge retires
as soon as the store says the subscription ended, a lapsed iOS subscriber
can resume the retired badge, an App Store raise is granted at once and
a lower tier at the renewal, and a Play plan change is a new purchase.
The known limits and the before-sale list follow the code.
The seven diagrams are updated, with stale file and line references
dropped, and two are added: a tier change and a restore. The page now
points at the diagram files instead of carrying copies of them.
* fixup! docs: describe badge tier changes and restore as the service handles them
* fixup! docs: tell the badge subscriptions story with tier changes and restore
* fixup! docs: describe badge tier changes and restore as the service handles them
* fixup! docs: tell the badge subscriptions story with tier changes and restore
* core: make the badge subscription tests fail on the defects they name (#7798)
* fix compilation
* fix api
* ios, android, desktop: open the badge page on the site again
* ios: choose a badge on the site, not in the app
* tests: hold the move with no charge to grant, not with the purchase's store check
* unfocus test
* update postgres schema
* badge-service: break a tie between issuances by their period, so both databases agree
* badge subscription review fixes (#7799)
* update badge subscriptions docs (#7802)
* core: show a changed badge tier to contacts as soon as it is granted
* update plans
* core: show a raised badge tier to contacts at once, hold back a lower one
* update sqlite schema
* badge subscriptions review fixes (#7903)
* update ios lib
* update query plans
* ios: one property wrapper on the chat view's chat (#7910)
* android: a shorter note under Restore purchases
* Revert "ios: one property wrapper on the chat view's chat (#7910)"
This reverts commit d5a24bb342.
* ci: larger RAM disk so tests do not run out of space
* update sqlite schema
* ios: preview references for the badge screens
---------
Co-authored-by: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com>
33 KiB
SimpleX badge service
simplex-badge-service runs the whole supporter-badge service as one process, two
lanes: a SimpleX chat bot that answers service RPC over a double-ratchet contact
address, and — only when --service-config names a badge_service.ini — the badge-codes
web checkout (BTCPay and Stripe payment, code issuance, the poller). Without
--service-config the web listener does not start at all; the process still runs the
chat side and nothing else.
The wire protocol for the RPC side is
docs/protocol/badges-rpc.md. The web checkout is
specified in
plans/badges-codes/2026-08-27-badge-codes.md,
whose §9 is the badge_service.ini reference, with the implementation plan in
plans/badges-codes/2026-08-31-service-btcpay.md.
Earlier plans (2026-07-30-supporter-badges-v3-ux.md, 2026-07-31-badges-core-implementation.md,
2026-08-04-badges-mvp-scope.md) predate the web checkout and describe the RPC-only
scaffold this service started as.
At this stage the service:
- creates a double-ratchet contact address on first start (service RPC requires DR, see
docs/protocol/badges-rpc.md), - listens for service requests (
CEvtServiceRequest) on that address, rejects a request whosepurchaseKeyis not the key the agent verified the signature against, and answersredeemBadgeCode, - issues redemption codes, storing only their
SHA-256in its code table, - does not accept contact requests: the address is for RPC only,
- in service mode with
[group]in the ini, manages one SimpleX group and serves/issue,/bulkand/revokein it (see Issuing codes), - in service mode with
--service-config, also serves the built web app (npm run buildinweb/),POST /api/invoiceandGET /api/invoice/:id, the BTCPay and Stripe webhook routes, and a payment poller, seeding its price/offer catalog on every start, - owns the
sx_badge_service_-prefixed tables and its own migrations table (sx_badge_service_migrations).
Every other command answers unsupported_version, or unknown_purchase_key when the key that
signed it is not one the service has stored — every command but redeemBadgeCode needs a
purchase that already exists.
Build
Build prerequisites and the general contribution flow are in docs/CONTRIBUTING.md.
cabal build exe:simplex-badge-service
Run
simplex-badge-service --help
-
default (no
--run-cli): background service mode, no interactive terminal. -
--run-cli: interactive CLI that also processes service requests (mirrorssimplex-directory-service --run-cli). This mode is the chat/RPC side and the//commands below: it starts no web listener and no poller, and serves no group commands, whatever--service-configsays. It still updates a code's group message when the code is redeemed or revoked.[apple],[google]and[dev]apply to it. -
--no-address: skip address creation on start-up (for operators who provision the address themselves). The service cannot sign credentials without an issuer key and refuses to start without one: -
--issuer-key-idx IDX— the index the apps find the matching public key under (badgePublicKeysinChatConfig). -
--issuer-secret SECRET— the issuer secret fromsimplex-chat badge keygen.
The service checks the secret against the configured public key at that index and refuses to start if they disagree: credentials signed with the wrong key cannot be verified by any client, and the codes redeemed against them would be spent for nothing.
The key can come from badge_service.ini instead:
[issuer]
index = 1
private_key = <secret from `simplex-chat badge keygen`>
index is the index clients verify against (badgePublicKeys in ChatConfig), and private_key
is the secret that signs. Both are required, and the key is checked at startup like the flags.
Rotating is a change to both and a restart, once clients trust the new index. The service logs the
index it signs with at startup.
The command line wins over the file when both --issuer-key-idx and --issuer-secret are given.
Note that a secret passed as a flag is visible to every user on the machine through ps, where one
in the ini is only as readable as the file: badge_service.ini is gitignored and already holds the
provider API keys and webhook secrets.
Other options:
--service-config INI_FILE: path tobadge_service.ini. Omit it to run the chat/RPC side only; the process never starts a web listener without it, and never starts one under--run-cli, which parses and validates the whole file ([listener] static_dirincluded) but uses only its[issuer],[apple],[google]and[dev]sections. An issuer key is still required either way.--service-name NAME: the bot's display name, without*s or spaces (defaultSimpleX Badges).--client-service: use the client service certificate.- also accepts the standard SimpleX Chat core options — database path, SMP/XFTP servers,
--socks-proxy,--log-level/-l, and the rest — runsimplex-badge-service --helpfor the complete list.
Running the web checkout
badge_service.ini holds the listener bind address and static_dir, an optional
[btcpay] section (omitting it disables Bitcoin and Monero), an optional [stripe]
section (omitting it disables card payments), an optional [group] section (omitting it
turns off the group's commands, though a group created earlier still has its code messages updated)
and the poll cadence.
badge_service.ini.example is the committed template; badge_service.ini itself is
gitignored, since a real one holds API keys and webhook secrets.
The full walkthrough is in
web/README.md. Short version:
cd apps/simplex-badge-service/web && npm install && npm run build && cd ../../..
cp apps/simplex-badge-service/badge_service.ini.example apps/simplex-badge-service/badge_service.ini
cabal run simplex-badge-service -- \
--issuer-key-idx IDX --issuer-secret SECRET \
--service-config apps/simplex-badge-service/badge_service.ini
IDX and SECRET must be one of the issuer keys clients already ship, not a fresh
simplex-chat badge keygen pair: startup refuses a key no client could verify against.
trust_forwarded_for decides what the rate limiter counts. Behind a reverse proxy it must be
on, or every request keys on the proxy's address and the whole service shares one bucket of 60
reads and 5 checkouts a minute. Where the listener is reached directly it must be off, because
then the header is whatever the caller wrote. The last entry is the one read, which is the one a
proxy appends.
The BTCPay API key needs four permissions, each scoped to the one store:
cancreateinvoice and canviewinvoices for checkout and the poller,
canviewstoresettings to log the store's live payment methods at startup, and
canmodifyinvoices so POST /api/invoice/:id/cancel can invalidate an invoice at BTCPay
rather than only in this store.
Give the service a BTCPay store of its own, and a Stripe account of its own if card payments are on. The poller lists every invoice and payment intent of the last three days or so, and one it did not create and cannot read is reported in a warning once an hour until it falls out of that window.
Card payments (Stripe)
An optional [stripe] section enables the card lane; omitting it disables card payments
and POST /api/invoice answers provider_unavailable for a card order, the same as an
absent [btcpay] does for Bitcoin and Monero. Its keys:
secret_key— a restricted key (rk_...), never a full secret key (sk_...). Grant it PaymentIntents: write, to create and cancel intents, plus Charges: read — the poller reads an intent withexpand[]=latest_charge, and Stripe rejects the whole read if the key lacks read on an expanded object (settlement then falls back to the slower list pass).publishable_key(pk_...) — the public key the browser mounts the Payment Element with.webhook_secret(whsec_...) — the signing secret of the/webhooks/stripeendpoint, configured in the Stripe Dashboard alongside it.session_minutes— how many minutes an unpaid card order stays open (1 to 1440, default 60). Ten minutes after that, the service cancels the payment at Stripe, and retries if that fails. If the order is over 72 hours old and the cancel has kept failing for an hour, the service marks it expired and logs an error. Check such orders in the Stripe Dashboard. After a restart, the hour starts again.
The service fills publishable_key into the shell at boot: the built index.html ships
with an empty <meta id="stripe-publishable-key" name="stripe-publishable-key" content="">
(web/public/index.html), and the listener serves a copy with the configured key
substituted in, so the ini is the only place the key is set. With no [stripe] section the
pristine (empty) shell is served and the page shows its development stand-in card form. The
dev mock (web/mock/server.py) does the same substitution from $STRIPE_PUBLISHABLE_KEY,
since it runs without an ini.
POST /webhooks/stripe verifies Stripe-Signature against webhook_secret and queues a
read, the same hint-only role as POST /webhooks/btcpay: the poller carries authority, so
an unverified or unreadable delivery costs nothing but a log line. A delivery signed more than
15 minutes away from the server's clock is refused with a warning, so keep the clock synced (NTP).
The reverse proxy in front of this service must send a Content-Security-Policy that allows Stripe.js and its iframes, since Stripe forbids bundling or self-hosting its script:
script-src 'self' https://js.stripe.com https://*.js.stripe.com;
frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com
Stripe Link must be disabled in the Dashboard. Left on, it needs link.com in the
CSP above and reintroduces an email prompt of its own, which this design otherwise avoids.
Enable card only; do not enable redirect-based methods (iDEAL, Bancontact, PayPal, and
the rest) in the Dashboard. The embedded checkout sends no return_url, which Stripe requires
the moment a redirect-based method is offered, so enabling one makes every checkout fail to
create. The service pins the Stripe API version it was built against, so the account's default
version does not affect the card lane.
The checkout endpoints
The browser in web/ is the only client of the /api routes; the two webhooks below are the
providers', one each for BTCPay and Stripe.
The invoice id is the only credential for reading or cancelling an order: it is 16 random bytes,
it travels in the path, and anything that logs request paths logs it. It is also what the buyer
is shown as their reference and asked to quote, so support sees it: losing it lets someone cancel
an invoice, which is why the code, which lets them take the badge, is never printed until the
service says the invoice is paid. The service keeps it out of its own logs, which name the
provider's reference instead.
| Route | Answers |
|---|---|
POST /api/invoice |
{invoiceId, badgeType, months, amount, currency, expiresAt} plus a destination: clientSecret for a card, or address, cryptoAmount, cryptoCurrency. Refuses with code_conflict, catalog_changed, bad_request, provider_unavailable or rate_limited. |
GET /api/invoice/:id |
{status, badgeType, months, amount, currency, expiresAt} and the same destination — no invoiceId, since the caller already has it — plus amountPaid, cryptoAmountPaid, cryptoAmountDue, paidInFull, settledAt and requiredConfirmations once each has a value. Never the code, which this service has never seen. |
GET /api/invoice/:id?wait=<status>&seenPaid=<figure>&seenFull=<0|1> |
The same, held for up to 30 seconds while the invoice's status is still <status> and its payment is the one the caller says it has rendered. wait=paid and any value that is not a status answer at once, since neither can change. A status the caller has not seen, or a payment it has not seen, answers at once — the provider's verdict counts as much as the figure, because Monero reports an invoice as confirming while its figures are still zero. A request that omits seenPaid holds on the status alone. |
POST /api/invoice/:id/cancel |
Invalidates an open invoice at the provider and expires it here. Refuses a settled or expired one with not_open, and one that already holds a payment with funded. The provider is told first, and a payment landing in between does not keep the invoice open: nothing can reach that address any more, so the row is expired either way and the poller settles or reports what arrived. |
POST /webhooks/btcpay |
Verifies BTCPay-Sig over the bytes as received and queues a read. A hint only: the poller is what carries authority, so an unverified or unreadable delivery costs nothing but a log line. |
POST /webhooks/stripe |
Verifies Stripe-Signature over the bytes as received and queues a read. The card lane's equivalent of the row above, and a hint just the same: the poller carries authority, so an unverified or unreadable delivery costs nothing but a log line. |
Every /api refusal is {"error": "<code>"}. Besides the codes above, any of them can answer
internal, an unknown id answers not_found, and a wrong verb answers method_not_allowed —
ten codes in all, which is what the browser's WIRE_ERROR_CODES lists. The two webhook routes are
the exception: each answers 200, 400 or 413 with an empty body, because its provider is the only
caller and nothing it could read would change what the route does. A wrong verb on any route, those
two included, answers method_not_allowed.
Store purchases (App Store and Google Play)
Each store is verified only when its section is present; without one, every receipt from that store
is answered provider_not_configured and the app keeps the purchase to present again. A section
missing a key, a root certificate that cannot be read or is not a root, an unreadable service
account key, or either section beside
[dev] accept_unverified_store_receipts = on, stops the service at startup.
[apple]
bundle_id = chat.simplex.app
root_certificates = /etc/simplex-badge-service/AppleRootCA-G3.cer
api_key_id = 2X9R4HXF34
api_issuer_id = 57246542-96fe-1a63-e053-0824d011072a
api_private_key_file = /etc/simplex-badge-service/AuthKey_2X9R4HXF34.p8
[google]
package_name = chat.simplex.app
service_account_file = /etc/simplex-badge-service/play-service-account.json
[apple]verifies the signed transaction offline, against the rootsroot_certificateslists, comma-separated, each in DER. Today that is Apple Root CA - G3 as Apple publishes it at https://www.apple.com/certificateauthority/AppleRootCA-G3.cer. Each must be self-signed. Verification reads no validity period, so an expired root keeps verifying. A transaction for another bundle id, or an intermediate no listed root signed, is answeredinternaland logged as an error rather than refused, sincebundle_idmay be wrong or a root missing; check both when those errors appear, unless the purchase was made in Xcode's StoreKit testing, which the App Store does not sign, so no root verifies it. When Apple signs under a new root, list it beside the old one rather than in its place: a purchase signed under the old root still verifies, and one answeredinternalmeanwhile is credited when the app presents it again.- With
api_key_id,api_issuer_idandapi_private_key_file,[apple]also verifies subscriptions: it asks the App Store Server API for a subscription's status, with a token signed by the in-app purchase key App Store Connect issued (the.p8file, its key id and the account's issuer id), and checks what Apple signed in the answer against the same roots. Without them, one-time purchases verify as before and every subscription is answeredprovider_not_configured. The three are set together or not at all.api_hostdefaults to the production host,https://api.storekit.itunes.apple.com, which answers nothing about a Sandbox transaction; a test deployment with[dev] credit_test_purchases = on(below) setshttps://api.storekit-sandbox.itunes.apple.com. The service refuses to start with any other host, or with the Sandbox host whilecredit_test_purchasesis off, as the Sandbox never charges anyone; the host's case and a trailing slash do not matter. A 401 or 403 from Apple, the key refused, is answeredinternaland logged. Keep the key file readable by this service alone. [google]asks the Google Play Developer API about each purchase and subscription, and acknowledges it before crediting it, as the service account whose JSON keyservice_account_fileholds. The account needs both Play Console permissions Google's getting started lists for its billing APIs: "View financial data, orders, and cancellation survey responses" to read a purchase, and "Manage orders and subscriptions" to acknowledge it. Without the second, a Play purchase the app has not already acknowledged itself is answeredinternaland logged as an error naming the acknowledgement, and the app presents it again once the permission is granted, which must be within three days, before Play refunds it. The key signs in for an access token, so keep the file readable by this service alone. A request that may ask a store waits on a lane of its own: Play purchases, App Store subscription purchases and transfers of a subscription (Restore) on one, and issue requests and tier raises for a subscription due its hourly store check on another. A slow or unreachable store therefore delays only those, not code redemptions, App Store one-time purchases or other renewals; and no purchase or transfer, genuine or made up, delays a renewal check. The purchase path asks Play about at most 300 subscriptions a minute (a purchase it credits is read once more to acknowledge it), so made-up tokens can spend at most a tenth of the quota renewal checks share.
While a subscription's paid coverage runs, as an annual plan's does for a year, the service asks its store before it issues each new month, at most once an hour. When the store reports the subscription ended, or the charge the service granted revoked, the months not yet issued are debited as a refund and the badge ends with the month already issued; a store that fails or does not answer ends nothing. A Play refund that leaves the subscription active is not seen: the service does not read Play's voided purchases.
Which answers are terminal for a purchase and which leave it to be presented again is listed per
store in docs/protocol/badges-rpc.md.
The root certificates, the App Store Server API key and the Play service account key are read only
at startup, so rotating any of them takes a restart. Until then the old key keeps being used, and
each request the store refuses it for is answered internal and logged as an error.
Do not roll back the 20261012_badge_subscriptions migration on a database that holds store
subscriptions. Its down migration drops their columns without refusing: every subscription becomes
a prepaid purchase that is never renewed, every Play purchase token is lost, and every ledger with a
charge, transfer or tier-change row can no longer be read, so its purchase answers internal.
Upgrading again restores none of it. Take a backup first if a rollback is unavoidable.
Store setup
The subscriptions must be sold under the ids the apps buy and the service credits, and set up as follows:
- App Store: one subscription group holding all four subscriptions, so a change of tier or plan keeps the original transaction id, and with it the purchase.
- App Store levels:
SUBSCR_BADGE_LEGEND_MONTH_01andSUBSCR_BADGE_LEGEND_YEAR_01at level 1,SUBSCR_BADGE_SUPPORTER_MONTH_01andSUBSCR_BADGE_SUPPORTER_YEAR_01at level 2. Apple charges a move to a higher level at once, and the apps send only Supporter to Legend as a raise. - App Store: Billing Grace Period off, and Family Sharing off, which cannot be turned off once on: a family-shared subscription is refused.
- Play: the product
subscr_badge_supporter_01with the base planssubscr-badge-supporter-month-02andsubscr-badge-supporter-year-01, and the productsubscr_badge_legend_01withsubscr-badge-legend-month-01andsubscr-badge-legend-year-01. A charge for any other base plan is not credited, and a charge for one of these under the other product is answeredinternal. - Play, on each of the four base plans: Resubscribe off, pausing off, the minimum grace period, and account hold on.
- Both: no offers of any kind: no free trial, introductory or promotional price, or offer code. The service credits no App Store charge made with an offer and no Play charge in a free trial or introductory price.
Accepting store receipts unverified, for local testing
[dev]
accept_unverified_store_receipts = on
Without a store verifier every store purchase is answered provider_not_configured. With this on,
the service accepts any well-formed receipt without asking or verifying anything: an Apple JWS for
the transactionId and productId its payload names, unsigned or not, and a Play product id and
token as given. It applies to both run modes, since --run-cli answers service requests too, and
the service logs a warning at every start while it is on. Off by default, and only on/off
parse. The service refuses to start with it on beside an [apple] or [google] section.
It answers subscriptions too, each as renewing, charged from a minute before the check for the length of its plan:
- an App Store subscription: the plan its product names;
- a Play subscription: the base plan a test token
test.<basePlanId>.<uuid>names, when it is a plan of the product (the apps' test products sign their tokens so), or else the product's monthly base plan, so a licence tester's purchase is credited too; - an App Store tier raise: a charge for the raised plan, which its renewals then charge;
- a renewal: a new charge for the plan it last charged. It keeps that plan in memory only, so after a restart it answers a renewal of a subscription it credited before as ended, and the unissued months of a plan still covered are refunded at its next month.
It acknowledges nothing, so Play refunds a licence tester's subscription it credited once Play's acknowledgement window lapses.
With it on, anyone who can reach the service address can mint badges by sending a made-up receipt. The service starts only with an issuer key released apps trust, so they accept every badge it mints this way, and a subscription credited so renews at every check for as long as the process runs. Never turn it on where anyone but you can reach the service.
[dev]
accept_unverified_store_receipts = on
check_store_during_coverage = on
With check_store_during_coverage on as well, the service asks its store, the mock or a real one
with credit_test_purchases on (below), at every issueBadge for a subscription, rather than at
most once an hour and only when a month is due. A charge it reports during coverage is banked: a
charge row, the plan's months added to what is left and a later paid-through date, and those
months are issued once coverage reaches them; a deployment without the setting only records it.
So a renewal shows at once, though its credential waits for the paid month to end on the service's
clock, and after a restart the mock answers the subscription ended at once, so the months banked
are refunded. The mock charges anew at every
check, so every such request banks the plan's months again; space requests over a minute apart, as
the mock dates a charge a minute back and the app fails a ledger row dated before the one it
follows. The service logs each banked charge as
store subscription charge banked: <n> months, which tells a banked month
from a repeated badge issued. Off by default, and the service refuses to start with it on unless
accept_unverified_store_receipts or credit_test_purchases is on: a real store has not started
its next charge while coverage runs, and asking it then would spend the quota every subscriber
shares.
Crediting test purchases, for testing against a real store
[dev]
credit_test_purchases = on
A purchase the store itself calls a test, an App Store Sandbox purchase (which TestFlight builds
buy in) or a Play licence tester's, is refused test_purchase by default, which is terminal, so it
can never be credited. With this on, the service credits it as a real one, one-time purchases and
subscriptions alike. It applies beside a real [apple] or [google] section, which still verifies
every receipt, and changes nothing beside accept_unverified_store_receipts, whose mock reports
no test purchase. The service logs a warning at every start while it is on, and at every test
purchase and test subscription it takes as paid. Off by default, and only on/off parse.
Beside [apple] the service starts with it on only when [apple] sets the server API key with
api_host = https://api.storekit-sandbox.itunes.apple.com: a Sandbox receipt verifies offline like
any other, so beside the production host, or without the key, Sandbox purchases would be credited as
real ones. Beside [google] it is allowed, as Play calls a purchase a test only for the licence
testers the Play Console lists.
[dev]
credit_test_purchases = on
check_store_during_coverage = on
With both on, the real store is asked at every issueBadge for a subscription, as above, to watch
its renewals by hand: a Sandbox subscription renews every few minutes.
The service starts only with an issuer key released apps trust, so they accept every badge it
credits this way. Nothing tells a production [google] from a test one, so never turn this on
beside the production Play app's [google], or every licence tester the Play Console lists gets a
badge without paying.
Issuing codes
Operators issue codes two ways: from the service's own command line in --run-cli mode, and from the
managed group in service mode. Both are commands to a running process, so no second process
touches the service's database.
From the command line
The command is sent to the running service in --run-cli mode, not a way to start it:
//issue <badge_type> [months] [paid|unpaid|free]
//issue supporter 12
months defaults to 1 and must be between 1 and 255; the status defaults to free and records
whether the code was sold (paid), is awaiting payment (unpaid), or was issued by an operator
(free). Redemption refuses an unpaid code with payment_pending: the web checkout writes the
code row when the invoice is created, and settlement is what marks it paid.
The code is printed once and only its SHA-256 is stored, so a code that is not copied when it is
shown cannot be recovered.
A code that leaked, or that was refunded, is withdrawn the same way:
//revoke <code>
A revoked code answers redemption with code_invalid, as if it had never existed, so its holder
learns nothing from trying. A client that redeemed it before the revoke still gets its own badge
back when it asks again. Revoking it again answers "already revoked" and fixes its group
message if the first revoke didn't. A code with no uses left can't be revoked, because its badges
were already given out, and the command answers with an error. A multi-use code with uses left can
be revoked, which stops the uses that remain.
Core parses //... into CustomChatCommand and leaves it to the service's preCmdHook, which is
why issuing codes lives in the service rather than in core.
From the group
With [group] in badge_service.ini, the service manages one group and serves three commands in
it: /issue <type> [months <M>] [uses <N>] and /bulk <type> [months <M>] count <B> for moderators
and above, /revoke <code> for admins and owners. months is 1 to 255, uses 1 to 1000 and
count 1 to 100; a value outside these gets the usage reply. A member's role is checked as the
service last saw it, so a command sent by a moderator just demoted or removed can still run if it
reaches the service first; revoke any code the service posts for them after the change. uses
above 1 makes a multi-use code, tracked by a group message showing its remaining uses and the time
of the last one; when every use is redeemed, the same message says so. Every reply carrying a code is read by every member,
since the group has no private lane, so a code issued there is only as private as its least trusted
member.
Those replies are also kept as plain text in the service's chat database, so a copy of the database
holds every code issued in the group. Keep the group's visible history off: with it on, each new
member receives recent messages, and the codes in them, when they join. A multi-use code's message
carries the code, and every redemption edits it or, after a day, posts it again; either way every
current member receives it, so a member who joined after the code was issued gets the code while it
still has uses left. A message replaced by a new post stays in the group with its old count. Keep
disappearing messages off in the group and set no message TTL for the service's chats: a code's
message that expires is treated as deleted and never posted again, so its counter stops.
Every member can see when each use of a multi-use code was redeemed: the message shows the time of
the last one, and its edit times show the rest.
/revoke <code> names the code in an ordinary group message, so every member holds it before the
service reads the command, and the code stays redeemable until the service acts on it — for the
whole of any downtime. Revoke a code that is not already public in the group, a refunded one above
all, with //revoke in --run-cli mode. A /revoke <code> with nothing after the code, from a
member below admin, is answered that the code was not revoked and is now visible to the group. A
group command the service received but had not run when it stopped, or received while it ran in
--run-cli mode, is dropped with no reply, so resend it, or use //revoke.
The first member to join through the link is promoted to owner, so the operator joins before sharing
it. Keep the service an owner too: below owner it cannot update the group's command menu, and below
author it cannot post codes or replies. A failed promotion is logged at once, and an owner who left
is logged at the next start or join. Then make the member you choose owner with the /mr command
that the log line names, in --run-cli mode; the service never promotes anyone once the first
promotion was attempted.
The join link logged when the group is created stays valid: anyone who has it can join later, as a
member, and read every code posted or edited from then on. That includes a removed member, who can
rejoin through it, so removing a member does not stop them seeing new codes. Keep the log that holds
it private. The link is also stored in the group_link column of sx_badge_service_group, where it
can be read again.
If an owner deletes the group, or removes the service from it, the service logs an error on start
and stops serving the group. To create a new group, stop the service, delete the row, and start it
again: with SQLite, run DELETE FROM sx_badge_service_group; on the <prefix>_chat.db file
(~/.simplex/simplex_badge_service_chat.db by default), opened with sqlcipher and the database key
if one is set; with PostgreSQL, run
DELETE FROM <schema-prefix>_chat_schema.sx_badge_service_group; (simplex_v1_chat_schema by default).
Multi-use codes issued in the old group stay redeemable, but their messages there are no longer
updated, so revoke with //revoke any that should not stay live.
The group is identified by the single sx_badge_service_group row. Rolling back past the
20260918_badge_group_ops migration drops that table, so a later re-upgrade creates a second group
and orphans the first one with its members and roles; multi-use codes come back single-use with
their claims re-derived, and outstanding trackers come back unanchored. Redeemed credentials are
preserved and no code becomes redeemable again, though while the old version runs, only the holder
whose credential ends last gets it back on a retry, and any other holder of a multi-use code gets
code_used; every holder of a revoked code gets code_invalid. Rolling back means re-creating and
re-sharing the group; delete the orphaned one with /d #'<old local name>' in --run-cli mode, as its
join link still works and its messages hold every code posted there.
The configured display_name and description apply only to the group the service creates. Editing
them later is logged as not applied and changes nothing.