Commit Graph
1013 Commits
Author SHA1 Message Date
spacedandy-agents-simplex bc79a360ce 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
2026-10-16 11:46:45 +00:00
spacedandy-agents-simplex 6db4feb821 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
2026-10-16 11:29:10 +00:00
spacedandy-agents-simplex 293cf6aadc 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
2026-10-16 11:18:25 +00:00
spacedandy-agents-simplex ce7fed16b0 Merge branch 'badge-in-app-checkout' into f/badge-subscriptions
# Conflicts:
#	src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt
2026-10-16 10:19:54 +00:00
spacedandy-agents-simplex e3324c6828 Merge branch 'master' into badge-in-app-checkout
# Conflicts:
#	simplex-chat.cabal
#	src/Simplex/Chat/Store/Postgres/Migrations.hs
#	src/Simplex/Chat/Store/SQLite/Migrations.hs
#	src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt
#	src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql
2026-10-16 09:40:53 +00:00
spacedandy-agents-simplex d479e58713 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.
2026-10-15 19:38:56 +00:00
spacedandy-agents-simplex 3e28c7d9a9 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
2026-10-15 18:39:32 +00:00
spacedandy-agents-simplex d618c83d94 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
2026-10-15 18:39:28 +00:00
spacedandy-agents-simplex 23b4e07d21 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
2026-10-15 17:29:40 +00:00
spacedandy-agents-simplex e2249c64ae fixup! core: wait a day after a badge request that issued nothing, whatever the service or a snooze says 2026-10-15 17:11:12 +00:00
spacedandy-agents-simplex c238017311 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
2026-10-15 16:49:10 +00:00
spacedandy-agents-simplex 908922060e fixup! review: round 1 fixes 2026-10-15 16:31:33 +00:00
spacedandy-agents-simplex 06c816de10 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
2026-10-15 15:50:21 +00:00
spacedandy-agents-simplex 823a172670 fixup! badge-service: ask the App Store about a subscription purchase at most once an hour per claim 2026-10-15 14:45:37 +00:00
spacedandy-agents-simplex 5815c0150d 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
2026-10-15 14:23:16 +00:00
spacedandy-agents-simplex 7fd2fd20db 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
2026-10-15 13:27:57 +00:00
spacedandy-agents-simplex 707effe55c 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
2026-10-15 13:06:34 +00:00
spacedandy-agents-simplex 1626b48149 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
2026-10-15 09:36:08 +00:00
EvgenyandEvgeny @ SimpleX Chat e8c98e8231 core, ios: fix notifications for forwarded and channel messages (#7706)
* plan: add notification flag for forwarded messages

* core, ui: fix notifications for forwarded and relay messages

* improvements

* only send duplicate message event in NSE

* query plans

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-10-15 08:41:57 +01:00
spacedandy-agents-simplex 84b5c328d5 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
2026-10-15 06:53:20 +00:00
spacedandy-agents-simplex e051b50323 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
2026-10-15 06:38:03 +00:00
spacedandy-agents-simplex a3b2e5c9f1 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
2026-10-15 06:11:10 +00:00
spacedandy-agents-simplex 664e5388ff 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
2026-10-15 03:42:23 +00:00
spacedandy-agents-simplex c91df49dad 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
2026-10-15 00:53:16 +00:00
spacedandy-agents-simplex f126ef075d 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
2026-10-14 23:00:34 +00:00
spacedandy-agents-simplex 7bc4dbca36 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
2026-10-14 20:27:40 +00:00
Evgeny Poberezkin 4c09af0619 tests: fix tests compilation for postgres 2026-10-14 20:08:33 +01:00
spaced4ndy 8e1623b8b0 fixes 2026-10-14 16:47:51 +04:00
spacedandy-agents-simplex 141ac06d47 Merge branch 'badge-in-app-checkout' into f/badge-subscriptions
# Conflicts:
#	apps/simplex-badge-service/README.md
#	apps/simplex-badge-service/badge_service.ini.example
#	apps/simplex-badge-service/src/BadgeService/Config.hs
#	apps/simplex-badge-service/src/BadgeService/Service.hs
#	apps/simplex-badge-service/src/BadgeService/StoreReceipts.hs
#	apps/simplex-badge-service/src/BadgeService/StoreReceipts/Apple.hs
#	apps/simplex-badge-service/src/BadgeService/StoreReceipts/Google.hs
#	scripts/badge-service/badge_service.ini.example
#	tests/Bots/BadgeService/BotTests.hs
#	tests/Bots/BadgeService/ConfigTests.hs
#	tests/Bots/BadgeService/FakePlay.hs
#	tests/Bots/BadgeService/FakeStore.hs
#	tests/Bots/BadgeService/StoreVerifierTests.hs
2026-10-14 11:44:31 +00:00
shandspaced4ndy daa7c4f3d8 badge-service: accept a list of Apple roots (#7707)
* badge-service: accept a list of Apple roots

* badge-service: document root_certificates

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-10-14 10:43:53 +00:00
spaced4ndy 5db181c10e badge-service: answer Play purchases on a lane of their own (#7712) 2026-10-14 10:40:34 +00:00
spacedandy-agents-simplex 2636f85d31 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
2026-10-14 09:18:50 +00:00
spacedandy-agents-simplex 7c05719796 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
2026-10-14 08:47:30 +00:00
spacedandy-agents-simplex b3ed7dd8cc 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
2026-10-14 06:38:19 +00:00
spacedandy-agents-simplex 8087e3804e 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
2026-10-14 05:36:33 +00:00
spacedandy-agents-simplex 732db2dfb0 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
2026-10-14 05:20:00 +00:00
spacedandy-agents-simplex c8b8ba4d4f 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
2026-10-14 04:36:13 +00:00
spacedandy-agents-simplex 648aee4cea 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
2026-10-14 02:35:51 +00:00
spacedandy-agents-simplex 85901458ec 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
2026-10-13 22:45:10 +00:00
spacedandy-agents-simplex dcbf3ee262 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
2026-10-13 20:56:50 +00:00
spaced4ndy 59c588932e badge-service: verify App Store and Google Play purchases (#7654) 2026-10-13 15:24:07 +00:00
Evgeny Poberezkin 9711dcfc9c core: 7.1.0.11 (simplexmq 7.1.0.11) 2026-10-11 23:01:35 +01:00
EvgenyandEvgeny @ SimpleX Chat 07c363849b core, ui: invite member of one group to another group (#7682)
* rfc: invite member of one group to another group

* update

* update

* implement

* version

* update

* do not show error if contact was removed when joining group

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-10-11 09:50:42 +01:00
EvgenyandEvgeny @ SimpleX Chat 2e56d32b52 core: badge presentations in profiles (#7573)
* badge presentation in groups

* fix tests

* badge presentations in profiles

* update simplexmq

* change types

* badge profile presentations

* improvements

* remove proof from cards, send owner public key for channel to relay

* add test

* merge migrations

* update

* prevent fallback to PHTest

* fixes

* many fixes

* fixes

* fixes

* cleanup

* improve

* query plans

* fix test

* diff

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-10-10 17:48:45 +01:00
spaced4ndy 57ab333a2b Merge branch 'master' into badge-in-app-checkout 2026-10-09 15:00:36 +04:00
EvgenyandEvgeny @ SimpleX Chat f924d233e4 core: fix encoding for name proof (#7672)
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-10-09 07:48:04 +01:00
EvgenyandEvgeny @ SimpleX Chat f2b3fe385c core: remove legacy field to support contact connections in groups (#7671)
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-10-09 07:00:27 +01:00
EvgenyandEvgeny @ SimpleX Chat 9c9c86f9c0 core, ui: fix profiles, add error alerts (#7663)
* core, ui: fix profiles, add error alerts

* simplify

* query plans

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-10-08 22:53:59 +01:00
EvgenyandEvgeny @ SimpleX Chat 64d68ff710 core: fix test (#7666)
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-10-08 17:11:46 +01:00
spaced4ndy b071285d15 tests: drop store purchase tests covered by their neighbours, and pin the rules none covered (#7665) 2026-10-08 15:45:12 +00:00