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.
No files matched your search
@@ -0,0 +1,119 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="982" viewBox="0 0 980 982" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Buying a subscription on Android: the app creates the record in core, buys with Play Billing, hands the token to core without acknowledging it, core's worker sends purchaseBadge, the service reads the subscription from Play by token before any one-time verification, acknowledges it just before crediting, and writes the payment, charge and ledger in one transaction.">
|
||||
<defs>
|
||||
<marker id="a-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" markerUnits="userSpaceOnUse" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#5b6577"/></marker>
|
||||
</defs>
|
||||
<rect width="980" height="982" fill="#f7f8fb"/>
|
||||
|
||||
<text x="24" y="36" font-size="18" font-weight="600" fill="#1a2030">Buying a subscription on Android</text>
|
||||
<text x="24" y="57" font-size="12.5" fill="#5b6577">The app sends the product id and token; the service reads the subscription from Play and acknowledges it itself, just before crediting.</text>
|
||||
|
||||
<!-- lifelines -->
|
||||
<line x1="130" y1="108" x2="130" y2="870" stroke="#c9cfdb" stroke-width="1.2" stroke-dasharray="4 4"/>
|
||||
<line x1="390" y1="108" x2="390" y2="870" stroke="#c9cfdb" stroke-width="1.2" stroke-dasharray="4 4"/>
|
||||
<line x1="640" y1="108" x2="640" y2="870" stroke="#c9cfdb" stroke-width="1.2" stroke-dasharray="4 4"/>
|
||||
<line x1="885" y1="108" x2="885" y2="870" stroke="#c9cfdb" stroke-width="1.2" stroke-dasharray="4 4"/>
|
||||
|
||||
<rect x="30" y="72" width="200" height="36" rx="8" fill="#eef1f5" stroke="#c9cfdb"/>
|
||||
<text x="130" y="95" font-size="12.5" font-weight="600" fill="#334155" text-anchor="middle">Android app · Play Billing</text>
|
||||
<rect x="300" y="72" width="180" height="36" rx="8" fill="#e3f1f3" stroke="#b5dadd"/>
|
||||
<text x="390" y="95" font-size="12.5" font-weight="600" fill="#0f5f69" text-anchor="middle">Chat core</text>
|
||||
<rect x="550" y="72" width="180" height="36" rx="8" fill="#eceefe" stroke="#c5c8f0"/>
|
||||
<text x="640" y="95" font-size="12.5" font-weight="600" fill="#3a3fa6" text-anchor="middle">Badge service</text>
|
||||
<rect x="790" y="72" width="182" height="36" rx="8" fill="#e7f4ec" stroke="#8cc6a3"/>
|
||||
<text x="881" y="95" font-size="12.5" font-weight="600" fill="#11713f" text-anchor="middle">Play Developer API</text>
|
||||
|
||||
<!-- step numbers -->
|
||||
<g font-size="11" font-weight="600" fill="#8a93a6" text-anchor="middle">
|
||||
<text x="14" y="150">1</text>
|
||||
<text x="14" y="217">2</text>
|
||||
<text x="14" y="266">3</text>
|
||||
<text x="14" y="386">4</text>
|
||||
<text x="14" y="448">5</text>
|
||||
<text x="14" y="510">6</text>
|
||||
<text x="14" y="597">7</text>
|
||||
<text x="14" y="650">8</text>
|
||||
<text x="14" y="731">9</text>
|
||||
<text x="14" y="784">10</text>
|
||||
<text x="14" y="824">11</text>
|
||||
</g>
|
||||
|
||||
<!-- 1 -->
|
||||
<line x1="130" y1="146" x2="388" y2="146" stroke="#5b6577" stroke-width="1.4" marker-end="url(#a-arr)"/>
|
||||
<text x="260" y="140" font-size="11" fill="#334155" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">/_badge invoice</text>
|
||||
<line x1="390" y1="170" x2="132" y2="170" stroke="#5b6577" stroke-width="1.2" stroke-dasharray="5 3" marker-end="url(#a-arr)"/>
|
||||
<text x="260" y="164" font-size="11.5" fill="#1a2030" text-anchor="middle">invoiceId</text>
|
||||
|
||||
<!-- 2 -->
|
||||
<rect x="30" y="186" width="200" height="54" rx="6" fill="#f3f5f8" stroke="#cfd5df"/>
|
||||
<text x="40" y="201" font-size="11.5" fill="#1a2030">launchBillingFlow: the base</text>
|
||||
<text x="40" y="216" font-size="11.5" fill="#1a2030">plan's own offer, account id =</text>
|
||||
<text x="40" y="231" font-size="11.5" fill="#1a2030">invoiceId → purchase token</text>
|
||||
|
||||
<!-- 3 -->
|
||||
<line x1="130" y1="262" x2="388" y2="262" stroke="#5b6577" stroke-width="1.4" marker-end="url(#a-arr)"/>
|
||||
<text x="260" y="256" font-size="11" fill="#334155" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">/_badge purchase {google}</text>
|
||||
<text x="260" y="277" font-size="11" fill="#5b6577" text-anchor="middle">product id + token</text>
|
||||
<line x1="390" y1="304" x2="132" y2="304" stroke="#5b6577" stroke-width="1.2" stroke-dasharray="5 3" marker-end="url(#a-arr)"/>
|
||||
<text x="260" y="298" font-size="11.5" fill="#1a2030" text-anchor="middle">held</text>
|
||||
<rect x="30" y="318" width="200" height="38" rx="6" fill="#fff4e0" stroke="#e7c48a"/>
|
||||
<text x="40" y="333" font-size="11.5" fill="#1a2030">no acknowledgement here for a</text>
|
||||
<text x="40" y="348" font-size="11.5" fill="#1a2030">subscription (one-time: yes)</text>
|
||||
<rect x="290" y="318" width="200" height="38" rx="6" fill="#eef7f8" stroke="#b5dadd"/>
|
||||
<text x="300" y="333" font-size="11.5" fill="#1a2030">held under the Buy record's</text>
|
||||
<text x="300" y="348" font-size="11.5" fill="#1a2030">keys, claimed by token hash</text>
|
||||
|
||||
<!-- 4 -->
|
||||
<line x1="390" y1="382" x2="638" y2="382" stroke="#5b6577" stroke-width="1.4" marker-end="url(#a-arr)"/>
|
||||
<text x="515" y="376" font-size="11" fill="#334155" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">purchaseBadge {google}</text>
|
||||
<text x="515" y="397" font-size="11" fill="#5b6577" text-anchor="middle">signed by the purchase key</text>
|
||||
|
||||
<!-- 5 -->
|
||||
<rect x="525" y="412" width="230" height="70" rx="6" fill="#f3f4ff" stroke="#c5c8f0"/>
|
||||
<text x="535" y="427" font-size="11.5" fill="#1a2030">purchase lane</text>
|
||||
<text x="535" y="442" font-size="11.5" fill="#1a2030">routed before verifyReceipt</text>
|
||||
<text x="535" y="457" font-size="11.5" fill="#1a2030">asked < 1 h ago → payment_pending</text>
|
||||
<text x="535" y="472" font-size="11.5" fill="#1a2030">no Play read left → rate_limited</text>
|
||||
|
||||
<!-- 6 -->
|
||||
<line x1="640" y1="506" x2="883" y2="506" stroke="#5b6577" stroke-width="1.4" marker-end="url(#a-arr)"/>
|
||||
<text x="762" y="500" font-size="11" fill="#334155" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">subscriptionsv2.get(token)</text>
|
||||
<line x1="885" y1="552" x2="642" y2="552" stroke="#5b6577" stroke-width="1.2" stroke-dasharray="5 3" marker-end="url(#a-arr)"/>
|
||||
<text x="762" y="532" font-size="11.5" fill="#1a2030" text-anchor="middle">state; line item: basePlanId,</text>
|
||||
<text x="762" y="546" font-size="11.5" fill="#1a2030" text-anchor="middle">expiryTime, order id, offerPhase</text>
|
||||
|
||||
<!-- 7 -->
|
||||
<rect x="525" y="570" width="230" height="54" rx="6" fill="#f3f4ff" stroke="#c5c8f0"/>
|
||||
<text x="535" y="585" font-size="11.5" fill="#1a2030">grantable? start = expiryTime</text>
|
||||
<text x="535" y="600" font-size="11.5" fill="#1a2030">less the plan's months (clipped);</text>
|
||||
<text x="535" y="615" font-size="11.5" fill="#1a2030">does it still fund a month now?</text>
|
||||
|
||||
<!-- 8 -->
|
||||
<line x1="640" y1="646" x2="883" y2="646" stroke="#5b6577" stroke-width="1.4" marker-end="url(#a-arr)"/>
|
||||
<text x="762" y="640" font-size="11.5" fill="#1a2030" text-anchor="middle">read again, then :acknowledge</text>
|
||||
<text x="762" y="661" font-size="11" fill="#5b6577" text-anchor="middle">unless already acknowledged</text>
|
||||
<line x1="885" y1="688" x2="642" y2="688" stroke="#5b6577" stroke-width="1.2" stroke-dasharray="5 3" marker-end="url(#a-arr)"/>
|
||||
<text x="762" y="682" font-size="11.5" fill="#1a2030" text-anchor="middle">acknowledged</text>
|
||||
|
||||
<!-- 9 -->
|
||||
<rect x="525" y="704" width="230" height="54" rx="6" fill="#f3f4ff" stroke="#c5c8f0"/>
|
||||
<text x="535" y="719" font-size="11.5" fill="#1a2030">one transaction: payment + token,</text>
|
||||
<text x="535" y="734" font-size="11.5" fill="#1a2030">charge (hashed order id), ledger</text>
|
||||
<text x="535" y="749" font-size="11.5" fill="#1a2030">(grant at period start), issuance</text>
|
||||
|
||||
<!-- 10 -->
|
||||
<line x1="640" y1="780" x2="392" y2="780" stroke="#5b6577" stroke-width="1.4" marker-end="url(#a-arr)"/>
|
||||
<text x="515" y="774" font-size="11" fill="#1a2030" text-anchor="middle">badgeCredential, subscription: renewing</text>
|
||||
|
||||
<!-- 11 -->
|
||||
<line x1="390" y1="820" x2="132" y2="820" stroke="#5b6577" stroke-width="1.4" marker-end="url(#a-arr)"/>
|
||||
<text x="260" y="814" font-size="11.5" fill="#1a2030" text-anchor="middle">event: storePurchaseCredited</text>
|
||||
<rect x="30" y="834" width="200" height="26" rx="6" fill="#f3f5f8" stroke="#cfd5df"/>
|
||||
<text x="40" y="851" font-size="11.5" fill="#1a2030">finish: nothing to do</text>
|
||||
|
||||
<!-- footer -->
|
||||
<rect x="24" y="882" width="932" height="80" rx="8" fill="#eef1f5"/>
|
||||
<text x="40" y="902" font-size="12" fill="#1a2030">Play lists the subscription until it ends; the sweep hands it over as listed and it resolves to the credited record by its token.</text>
|
||||
<text x="40" y="919" font-size="12" fill="#1a2030">If it is never acknowledged (any refusal before step 8), Play refunds it after three days. If anything fails after step 8,</text>
|
||||
<text x="40" y="936" font-size="12" fill="#b3261e">it stays paid and acknowledged, and only a later retry credits it. Renewals keep the token; the service asks Play itself.</text>
|
||||
<text x="40" y="953" font-size="12" fill="#1a2030">Step 8's read is not counted against the 300 subscription checks a minute.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,107 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="884" viewBox="0 0 980 884" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Buying a subscription on iOS: the app creates the record in core, buys with StoreKit, hands the signed transaction to core, core's worker sends purchaseBadge, the service verifies the JWS offline and asks the App Store Server API by original transaction id, then credits the charge; the app finishes the transaction when core reports it credited.">
|
||||
<defs>
|
||||
<marker id="i-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" markerUnits="userSpaceOnUse" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#5b6577"/></marker>
|
||||
</defs>
|
||||
<rect width="980" height="884" fill="#f7f8fb"/>
|
||||
|
||||
<text x="24" y="36" font-size="18" font-weight="600" fill="#1a2030">Buying a subscription on iOS</text>
|
||||
<text x="24" y="57" font-size="12.5" fill="#5b6577">The app sends Apple's signed transaction; the service checks it offline, then asks the App Store Server API. Nothing is acknowledged.</text>
|
||||
|
||||
<!-- lifelines -->
|
||||
<line x1="130" y1="108" x2="130" y2="790" stroke="#c9cfdb" stroke-width="1.2" stroke-dasharray="4 4"/>
|
||||
<line x1="390" y1="108" x2="390" y2="790" stroke="#c9cfdb" stroke-width="1.2" stroke-dasharray="4 4"/>
|
||||
<line x1="650" y1="108" x2="650" y2="790" stroke="#c9cfdb" stroke-width="1.2" stroke-dasharray="4 4"/>
|
||||
<line x1="890" y1="108" x2="890" y2="790" stroke="#c9cfdb" stroke-width="1.2" stroke-dasharray="4 4"/>
|
||||
|
||||
<rect x="40" y="72" width="180" height="36" rx="8" fill="#eef1f5" stroke="#c9cfdb"/>
|
||||
<text x="130" y="95" font-size="12.5" font-weight="600" fill="#334155" text-anchor="middle">iOS app · StoreKit 2</text>
|
||||
<rect x="300" y="72" width="180" height="36" rx="8" fill="#e3f1f3" stroke="#b5dadd"/>
|
||||
<text x="390" y="95" font-size="12.5" font-weight="600" fill="#0f5f69" text-anchor="middle">Chat core</text>
|
||||
<rect x="560" y="72" width="180" height="36" rx="8" fill="#eceefe" stroke="#c5c8f0"/>
|
||||
<text x="650" y="95" font-size="12.5" font-weight="600" fill="#3a3fa6" text-anchor="middle">Badge service</text>
|
||||
<rect x="800" y="72" width="170" height="36" rx="8" fill="#e9f1fc" stroke="#8db4e8"/>
|
||||
<text x="885" y="95" font-size="12.5" font-weight="600" fill="#0b5cbf" text-anchor="middle">App Store Server API</text>
|
||||
|
||||
<!-- step numbers -->
|
||||
<g font-size="11" font-weight="600" fill="#8a93a6" text-anchor="middle">
|
||||
<text x="14" y="144">1</text>
|
||||
<text x="14" y="188">2</text>
|
||||
<text x="14" y="252">3</text>
|
||||
<text x="14" y="304">4</text>
|
||||
<text x="14" y="408">5</text>
|
||||
<text x="14" y="474">6</text>
|
||||
<text x="14" y="538">7</text>
|
||||
<text x="14" y="626">8</text>
|
||||
<text x="14" y="688">9</text>
|
||||
<text x="14" y="734">10</text>
|
||||
</g>
|
||||
|
||||
<!-- 1 -->
|
||||
<rect x="30" y="122" width="200" height="38" rx="6" fill="#f3f5f8" stroke="#cfd5df"/>
|
||||
<text x="40" y="137" font-size="11.5" fill="#1a2030">Transaction.all: subscribed</text>
|
||||
<text x="40" y="152" font-size="11.5" fill="#1a2030">before? Then not for sale.</text>
|
||||
|
||||
<!-- 2 -->
|
||||
<line x1="130" y1="184" x2="388" y2="184" stroke="#5b6577" stroke-width="1.4" marker-end="url(#i-arr)"/>
|
||||
<text x="260" y="178" font-size="11" fill="#334155" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">/_badge invoice</text>
|
||||
<line x1="390" y1="208" x2="132" y2="208" stroke="#5b6577" stroke-width="1.2" stroke-dasharray="5 3" marker-end="url(#i-arr)"/>
|
||||
<text x="260" y="202" font-size="11.5" fill="#1a2030" text-anchor="middle">invoiceId</text>
|
||||
|
||||
<!-- 3 -->
|
||||
<rect x="30" y="222" width="200" height="54" rx="6" fill="#f3f5f8" stroke="#cfd5df"/>
|
||||
<text x="40" y="237" font-size="11.5" fill="#1a2030">Product.purchase with</text>
|
||||
<text x="40" y="252" font-size="11.5" fill="#1a2030">appAccountToken = invoiceId</text>
|
||||
<text x="40" y="267" font-size="11.5" fill="#1a2030">→ signed transaction (JWS)</text>
|
||||
|
||||
<!-- 4 -->
|
||||
<line x1="130" y1="300" x2="388" y2="300" stroke="#5b6577" stroke-width="1.4" marker-end="url(#i-arr)"/>
|
||||
<text x="260" y="294" font-size="11" fill="#334155" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">/_badge purchase {apple: jws}</text>
|
||||
<line x1="390" y1="324" x2="132" y2="324" stroke="#5b6577" stroke-width="1.2" stroke-dasharray="5 3" marker-end="url(#i-arr)"/>
|
||||
<text x="260" y="318" font-size="11.5" fill="#1a2030" text-anchor="middle">held; transaction stays unfinished</text>
|
||||
<rect x="290" y="338" width="200" height="38" rx="6" fill="#eef7f8" stroke="#b5dadd"/>
|
||||
<text x="300" y="353" font-size="11.5" fill="#1a2030">held under the Buy record's</text>
|
||||
<text x="300" y="368" font-size="11.5" fill="#1a2030">keys, claimed by original id</text>
|
||||
|
||||
<!-- 5 -->
|
||||
<line x1="390" y1="404" x2="648" y2="404" stroke="#5b6577" stroke-width="1.4" marker-end="url(#i-arr)"/>
|
||||
<text x="520" y="398" font-size="11" fill="#334155" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">purchaseBadge {apple: jws}</text>
|
||||
<text x="520" y="419" font-size="11" fill="#5b6577" text-anchor="middle">signed by the purchase key</text>
|
||||
|
||||
<!-- 6 -->
|
||||
<rect x="540" y="436" width="230" height="70" rx="6" fill="#f3f4ff" stroke="#c5c8f0"/>
|
||||
<text x="550" y="451" font-size="11.5" fill="#1a2030">purchase lane (auto-renewable)</text>
|
||||
<text x="550" y="466" font-size="11.5" fill="#1a2030">JWS verified offline, pinned root</text>
|
||||
<text x="550" y="481" font-size="11.5" fill="#1a2030">Sandbox → test_purchase</text>
|
||||
<text x="550" y="496" font-size="11.5" fill="#1a2030">asked < 1 h ago → payment_pending</text>
|
||||
|
||||
<!-- 7 -->
|
||||
<line x1="650" y1="534" x2="888" y2="534" stroke="#5b6577" stroke-width="1.4" marker-end="url(#i-arr)"/>
|
||||
<text x="770" y="528" font-size="11.5" fill="#1a2030" text-anchor="middle">Get All Subscription Statuses</text>
|
||||
<text x="770" y="549" font-size="11" fill="#5b6577" text-anchor="middle">original id, ES256 token</text>
|
||||
<line x1="890" y1="572" x2="652" y2="572" stroke="#5b6577" stroke-width="1.2" stroke-dasharray="5 3" marker-end="url(#i-arr)"/>
|
||||
<text x="770" y="566" font-size="11.5" fill="#1a2030" text-anchor="middle">signed transaction + renewal info</text>
|
||||
|
||||
<!-- 8 -->
|
||||
<rect x="540" y="590" width="230" height="70" rx="6" fill="#f3f4ff" stroke="#c5c8f0"/>
|
||||
<text x="550" y="605" font-size="11.5" fill="#1a2030">both verified to the root;</text>
|
||||
<text x="550" y="620" font-size="11.5" fill="#1a2030">grantable charge → one transaction:</text>
|
||||
<text x="550" y="635" font-size="11.5" fill="#1a2030">payment, purchase, charge, ledger</text>
|
||||
<text x="550" y="650" font-size="11.5" fill="#1a2030">(grant at purchaseDate), issuance</text>
|
||||
|
||||
<!-- 9 -->
|
||||
<line x1="650" y1="684" x2="392" y2="684" stroke="#5b6577" stroke-width="1.4" marker-end="url(#i-arr)"/>
|
||||
<text x="520" y="678" font-size="11.5" fill="#1a2030" text-anchor="middle">badgeCredential, subscription: renewing</text>
|
||||
<text x="520" y="699" font-size="11" fill="#5b6577" text-anchor="middle">stored with the state; badge shown</text>
|
||||
|
||||
<!-- 10 -->
|
||||
<line x1="390" y1="730" x2="132" y2="730" stroke="#5b6577" stroke-width="1.4" marker-end="url(#i-arr)"/>
|
||||
<text x="260" y="724" font-size="11.5" fill="#1a2030" text-anchor="middle">event: storePurchaseCredited</text>
|
||||
<rect x="30" y="744" width="200" height="26" rx="6" fill="#f3f5f8" stroke="#cfd5df"/>
|
||||
<text x="40" y="761" font-size="11" fill="#334155" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">transaction.finish()</text>
|
||||
|
||||
<!-- footer -->
|
||||
<rect x="24" y="802" width="932" height="62" rx="8" fill="#eef1f5"/>
|
||||
<text x="40" y="822" font-size="12" fill="#1a2030">Renewals: StoreKit lists each renewal's transaction, which the app hands over as listed. It names the same original id,</text>
|
||||
<text x="40" y="839" font-size="12" fill="#1a2030">so it joins the credited record and is finished. The service learns of a new charge only from its own renewal check.</text>
|
||||
<text x="40" y="856" font-size="12" fill="#1a2030">A revoked subscription transaction is finished without being handed over. Apple has no acknowledgement step.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.1 KiB |
@@ -0,0 +1,121 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="604" viewBox="0 0 980 604" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Components: the apps buy in the store and hand receipts to core; core holds receipts and the client ledger and talks only to the badge service; the service verifies with the App Store Server API and the Play Developer API, credits and renews.">
|
||||
<defs>
|
||||
<marker id="c-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" markerUnits="userSpaceOnUse" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#5b6577"/></marker>
|
||||
<marker id="c-arr-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="8" markerHeight="8" markerUnits="userSpaceOnUse" orient="auto"><path d="M10,0 L0,5 L10,10 z" fill="#5b6577"/></marker>
|
||||
</defs>
|
||||
<rect width="980" height="604" fill="#f7f8fb"/>
|
||||
|
||||
<text x="24" y="36" font-size="18" font-weight="600" fill="#1a2030">Who holds what</text>
|
||||
<text x="24" y="57" font-size="12.5" fill="#5b6577">The apps buy and report; core holds receipts and the client ledger and never calls a store; the service verifies, credits and renews.</text>
|
||||
|
||||
<!-- Apps -->
|
||||
<rect x="24" y="78" width="250" height="280" rx="10" fill="#ffffff" stroke="#c9cfdb"/>
|
||||
<path d="M24,88 a10,10 0 0 1 10,-10 h230 a10,10 0 0 1 10,10 v24 h-250 z" fill="#eef1f5"/>
|
||||
<text x="38" y="100" font-size="14" font-weight="600" fill="#334155">Apps</text>
|
||||
<text x="260" y="100" font-size="11.5" fill="#5b6577" text-anchor="end">iOS · Android · desktop</text>
|
||||
<text x="38" y="138" font-size="12" fill="#1a2030">Buy, then hand the receipt to core</text>
|
||||
<text x="38" y="157" font-size="11" fill="#334155" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">/_badge purchase … [listed]</text>
|
||||
<text x="38" y="184" font-size="12" fill="#1a2030">Report what the store says</text>
|
||||
<text x="38" y="203" font-size="11" fill="#334155" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">/_badge subscription <state></text>
|
||||
<text x="38" y="230" font-size="12" fill="#1a2030">Your badge: Renews, Payment</text>
|
||||
<text x="38" y="248" font-size="12" fill="#1a2030">problem, Ends; Manage subscription</text>
|
||||
<text x="38" y="275" font-size="12" fill="#1a2030">Check your order: disclosure,</text>
|
||||
<text x="38" y="293" font-size="12" fill="#1a2030">Restore purchases</text>
|
||||
<text x="38" y="320" font-size="12" fill="#1a2030">One chat-list banner per alert</text>
|
||||
<text x="38" y="342" font-size="11.5" fill="#5b6577">Desktop: state only, no store rows</text>
|
||||
|
||||
<!-- Core -->
|
||||
<rect x="344" y="78" width="272" height="280" rx="10" fill="#ffffff" stroke="#c9cfdb"/>
|
||||
<path d="M344,88 a10,10 0 0 1 10,-10 h252 a10,10 0 0 1 10,10 v24 h-272 z" fill="#e3f1f3"/>
|
||||
<text x="358" y="100" font-size="14" font-weight="600" fill="#0f5f69">Chat core</text>
|
||||
<text x="602" y="100" font-size="11.5" fill="#5b6577" text-anchor="end">client database</text>
|
||||
<text x="358" y="138" font-size="11.5" fill="#0f5f69" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">badge_store_receipts</text>
|
||||
<text x="358" y="155" font-size="11.5" fill="#5b6577">a held receipt and its fresh keys</text>
|
||||
<text x="358" y="180" font-size="11.5" fill="#0f5f69" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">badge_purchases</text>
|
||||
<text x="358" y="197" font-size="11.5" fill="#5b6577">+ subscription_status,</text>
|
||||
<text x="358" y="213" font-size="11.5" fill="#5b6577">unrenewed_since, last_unrenewed_at</text>
|
||||
<text x="358" y="238" font-size="11.5" fill="#0f5f69" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">badge_ledger</text>
|
||||
<text x="358" y="255" font-size="11.5" fill="#5b6577">replica; a charge credit = subscription</text>
|
||||
<text x="358" y="282" font-size="12" fill="#1a2030">receipt worker → purchaseBadge</text>
|
||||
<text x="358" y="302" font-size="12" fill="#1a2030">badge worker → issueBadge, also at</text>
|
||||
<text x="358" y="320" font-size="12" fill="#1a2030">zero months; derives the alerts;</text>
|
||||
<text x="358" y="338" font-size="12" fill="#1a2030">retires on ended or after 67 days</text>
|
||||
|
||||
<!-- Service -->
|
||||
<rect x="706" y="78" width="250" height="280" rx="10" fill="#ffffff" stroke="#c9cfdb"/>
|
||||
<path d="M706,88 a10,10 0 0 1 10,-10 h230 a10,10 0 0 1 10,10 v24 h-250 z" fill="#eceefe"/>
|
||||
<text x="720" y="100" font-size="14" font-weight="600" fill="#3a3fa6">Badge service</text>
|
||||
<text x="720" y="138" font-size="12" fill="#1a2030">Three serial lanes:</text>
|
||||
<text x="720" y="156" font-size="12" fill="#1a2030">main · purchase · renewal</text>
|
||||
<text x="720" y="180" font-size="11.5" fill="#1a2030">Subscription purchases, in memory:</text>
|
||||
<text x="720" y="198" font-size="11.5" fill="#1a2030">a claim hourly; Play ≤ 300 a minute</text>
|
||||
<text x="720" y="224" font-size="11.5" fill="#3a3fa6" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">payments</text>
|
||||
<text x="720" y="241" font-size="11.5" fill="#5b6577">+ subscription_state, checked_at,</text>
|
||||
<text x="720" y="257" font-size="11.5" fill="#5b6577">provider_token (Play token)</text>
|
||||
<text x="720" y="282" font-size="11.5" fill="#3a3fa6" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">subscription_charges</text>
|
||||
<text x="720" y="299" font-size="11.5" fill="#5b6577">one row per store charge</text>
|
||||
<text x="720" y="324" font-size="11.5" fill="#3a3fa6" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">badge_ledger.charge_id</text>
|
||||
<text x="720" y="341" font-size="11.5" fill="#5b6577">unique: a charge is granted once</text>
|
||||
|
||||
<!-- Apps <-> Core -->
|
||||
<line x1="276" y1="170" x2="342" y2="170" stroke="#5b6577" stroke-width="1.5" marker-start="url(#c-arr-start)" marker-end="url(#c-arr)"/>
|
||||
<text x="309" y="162" font-size="11" fill="#5b6577" text-anchor="middle">commands</text>
|
||||
<text x="309" y="186" font-size="11" fill="#5b6577" text-anchor="middle">events</text>
|
||||
|
||||
<!-- Core <-> Service -->
|
||||
<line x1="618" y1="170" x2="704" y2="170" stroke="#5b6577" stroke-width="1.5" marker-start="url(#c-arr-start)" marker-end="url(#c-arr)"/>
|
||||
<text x="661" y="148" font-size="10.5" fill="#5b6577" text-anchor="middle">purchaseBadge</text>
|
||||
<text x="661" y="161" font-size="10.5" fill="#5b6577" text-anchor="middle">issueBadge</text>
|
||||
<text x="661" y="186" font-size="10.5" fill="#5b6577" text-anchor="middle">credential,</text>
|
||||
<text x="661" y="199" font-size="10.5" fill="#5b6577" text-anchor="middle">subscription</text>
|
||||
|
||||
<!-- Apps -> device stores -->
|
||||
<line x1="83.5" y1="358" x2="83.5" y2="416" stroke="#5b6577" stroke-width="1.5" marker-end="url(#c-arr)"/>
|
||||
<line x1="214.5" y1="358" x2="214.5" y2="416" stroke="#5b6577" stroke-width="1.5" marker-end="url(#c-arr)"/>
|
||||
<text x="149" y="392" font-size="11" fill="#5b6577" text-anchor="middle">buy · list · status</text>
|
||||
|
||||
<!-- Service -> store APIs -->
|
||||
<line x1="765.5" y1="358" x2="765.5" y2="416" stroke="#5b6577" stroke-width="1.5" marker-end="url(#c-arr)"/>
|
||||
<line x1="896.5" y1="358" x2="896.5" y2="416" stroke="#5b6577" stroke-width="1.5" marker-end="url(#c-arr)"/>
|
||||
<text x="831" y="392" font-size="11" fill="#5b6577" text-anchor="middle">check · acknowledge</text>
|
||||
|
||||
<!-- Device stores -->
|
||||
<rect x="24" y="418" width="119" height="106" rx="8" fill="#e7f4ec" stroke="#8cc6a3"/>
|
||||
<text x="36" y="440" font-size="12" font-weight="600" fill="#11713f">Play Billing</text>
|
||||
<text x="36" y="478" font-size="10.5" fill="#334155">purchase tokens</text>
|
||||
<text x="36" y="494" font-size="10.5" fill="#334155">isAutoRenewing</text>
|
||||
<text x="36" y="510" font-size="10.5" fill="#334155">subscriptions page</text>
|
||||
|
||||
<rect x="155" y="418" width="119" height="106" rx="8" fill="#e9f1fc" stroke="#8db4e8"/>
|
||||
<text x="167" y="440" font-size="12" font-weight="600" fill="#0b5cbf">StoreKit 2</text>
|
||||
<text x="167" y="478" font-size="10.5" fill="#334155">signed purchases</text>
|
||||
<text x="167" y="494" font-size="10.5" fill="#334155">renewal status</text>
|
||||
<text x="167" y="510" font-size="10.5" fill="#334155">manage sheet</text>
|
||||
|
||||
<!-- Store APIs -->
|
||||
<rect x="706" y="418" width="119" height="106" rx="8" fill="#e9f1fc" stroke="#8db4e8"/>
|
||||
<text x="718" y="440" font-size="12" font-weight="600" fill="#0b5cbf">App Store</text>
|
||||
<text x="718" y="455" font-size="12" font-weight="600" fill="#0b5cbf">Server API</text>
|
||||
<text x="718" y="478" font-size="10.5" fill="#334155">statuses by</text>
|
||||
<text x="718" y="494" font-size="10.5" fill="#334155">original id</text>
|
||||
<text x="718" y="510" font-size="10.5" fill="#334155">signed answers</text>
|
||||
|
||||
<rect x="837" y="418" width="119" height="106" rx="8" fill="#e7f4ec" stroke="#8cc6a3"/>
|
||||
<text x="849" y="440" font-size="12" font-weight="600" fill="#11713f">Google Play</text>
|
||||
<text x="849" y="455" font-size="12" font-weight="600" fill="#11713f">Developer API</text>
|
||||
<text x="849" y="478" font-size="10.5" fill="#334155">subscriptionsv2</text>
|
||||
<text x="849" y="494" font-size="10.5" fill="#334155">acknowledge</text>
|
||||
<text x="849" y="510" font-size="10.5" fill="#334155">by token</text>
|
||||
|
||||
<!-- Core note -->
|
||||
<text x="480" y="448" font-size="12.5" font-style="italic" fill="#5b6577" text-anchor="middle">Core never calls a store: the apps</text>
|
||||
<text x="480" y="466" font-size="12.5" font-style="italic" fill="#5b6577" text-anchor="middle">hand it receipts and the device's</text>
|
||||
<text x="480" y="484" font-size="12.5" font-style="italic" fill="#5b6577" text-anchor="middle">view, and the service tells it</text>
|
||||
<text x="480" y="502" font-size="12.5" font-style="italic" fill="#5b6577" text-anchor="middle">what the store charged.</text>
|
||||
|
||||
<!-- One store, two faces -->
|
||||
<polyline points="214.5,524 214.5,548 765.5,548 765.5,524" fill="none" stroke="#0b5cbf" stroke-width="1.3" stroke-dasharray="5 4"/>
|
||||
<text x="490" y="542" font-size="11" fill="#0b5cbf" text-anchor="middle">one Apple ID: the same transactions on both sides</text>
|
||||
<polyline points="83.5,524 83.5,578 896.5,578 896.5,524" fill="none" stroke="#11713f" stroke-width="1.3" stroke-dasharray="5 4"/>
|
||||
<text x="490" y="572" font-size="11" fill="#11713f" text-anchor="middle">one Google account: the same purchase tokens on both sides</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,122 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="786" viewBox="0 0 980 786" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="The renewal check: while paid coverage runs, issueBadge asks no store; once it has ended, the service asks the store at most once an hour, stamping the check first; a grantable charge is written with the issued month in one transaction that re-reads the tip; otherwise the charge and state are recorded and the answer carries no credential.">
|
||||
<defs>
|
||||
<marker id="r-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" markerUnits="userSpaceOnUse" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#5b6577"/></marker>
|
||||
</defs>
|
||||
<rect width="980" height="786" fill="#f7f8fb"/>
|
||||
|
||||
<text x="24" y="36" font-size="18" font-weight="600" fill="#1a2030">The service's renewal check</text>
|
||||
<text x="24" y="57" font-size="12.5" fill="#5b6577">issueBadge for a purchase a subscription funds (BadgeService/Service.hs:703-790). The store is asked only after paid coverage ends.</text>
|
||||
|
||||
<!-- left column: the decision path -->
|
||||
<rect x="40" y="84" width="220" height="36" rx="18" fill="#ffffff" stroke="#8a93a6"/>
|
||||
<text x="150" y="107" font-size="12" fill="#1a2030" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">issueBadge {lastEntry}</text>
|
||||
|
||||
<rect x="40" y="148" width="220" height="50" rx="8" fill="#ffffff" stroke="#9aa0e0"/>
|
||||
<text x="150" y="168" font-size="12.5" font-weight="600" fill="#1a2030" text-anchor="middle">Coverage still runs?</text>
|
||||
<text x="150" y="187" font-size="10.5" fill="#5b6577" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">paidThrough tip > now</text>
|
||||
|
||||
<rect x="40" y="228" width="220" height="50" rx="8" fill="#ffffff" stroke="#9aa0e0"/>
|
||||
<text x="150" y="248" font-size="12.5" font-weight="600" fill="#1a2030" text-anchor="middle">Checked within the hour?</text>
|
||||
<text x="150" y="267" font-size="10.5" fill="#5b6577" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">subscription_checked_at</text>
|
||||
|
||||
<rect x="40" y="308" width="220" height="40" rx="8" fill="#ffffff" stroke="#8a93a6"/>
|
||||
<text x="150" y="325" font-size="12" fill="#1a2030" text-anchor="middle">Stamp the check time first,</text>
|
||||
<text x="150" y="340" font-size="12" fill="#1a2030" text-anchor="middle">in its own write</text>
|
||||
|
||||
<rect x="40" y="376" width="220" height="50" rx="8" fill="#ffffff" stroke="#8a93a6"/>
|
||||
<text x="150" y="396" font-size="12.5" font-weight="600" fill="#1a2030" text-anchor="middle">Ask the store, ≤ 10 s</text>
|
||||
<text x="150" y="414" font-size="10.5" fill="#5b6577" text-anchor="middle">Apple by original id · Play by token</text>
|
||||
|
||||
<rect x="40" y="456" width="220" height="50" rx="8" fill="#ffffff" stroke="#9aa0e0"/>
|
||||
<text x="150" y="476" font-size="12.5" font-weight="600" fill="#1a2030" text-anchor="middle">Charge grantable now?</text>
|
||||
<text x="150" y="495" font-size="10.5" fill="#5b6577" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">grantableCharge, fundsMonthNow</text>
|
||||
|
||||
<g stroke="#5b6577" stroke-width="1.5" fill="none">
|
||||
<line x1="150" y1="120" x2="150" y2="146" marker-end="url(#r-arr)"/>
|
||||
<line x1="150" y1="198" x2="150" y2="226" marker-end="url(#r-arr)"/>
|
||||
<line x1="150" y1="278" x2="150" y2="306" marker-end="url(#r-arr)"/>
|
||||
<line x1="150" y1="348" x2="150" y2="374" marker-end="url(#r-arr)"/>
|
||||
<line x1="150" y1="426" x2="150" y2="454" marker-end="url(#r-arr)"/>
|
||||
<line x1="260" y1="173" x2="314" y2="173" marker-end="url(#r-arr)"/>
|
||||
<line x1="260" y1="253" x2="314" y2="253" marker-end="url(#r-arr)"/>
|
||||
<line x1="260" y1="401" x2="314" y2="401" marker-end="url(#r-arr)"/>
|
||||
<line x1="260" y1="481" x2="314" y2="481" marker-end="url(#r-arr)"/>
|
||||
<path d="M150,506 V594 H314" marker-end="url(#r-arr)"/>
|
||||
</g>
|
||||
<g font-size="11" fill="#5b6577">
|
||||
<text x="158" y="216">no</text>
|
||||
<text x="158" y="296">no</text>
|
||||
<text x="158" y="444">answered</text>
|
||||
<text x="287" y="167" text-anchor="middle">yes</text>
|
||||
<text x="287" y="247" text-anchor="middle">yes</text>
|
||||
<text x="287" y="395" text-anchor="middle">fails</text>
|
||||
<text x="287" y="475" text-anchor="middle">yes</text>
|
||||
<text x="158" y="588">no</text>
|
||||
</g>
|
||||
|
||||
<!-- outcomes -->
|
||||
<rect x="316" y="146" width="320" height="54" rx="8" fill="#f1f3f7" stroke="#cfd5df"/>
|
||||
<text x="330" y="166" font-size="11.5" fill="#1a2030">Unchanged path: lapse, then issue a due month</text>
|
||||
<text x="330" y="182" font-size="11.5" fill="#1a2030">or answer the current credential. No store call.</text>
|
||||
|
||||
<rect x="316" y="226" width="320" height="54" rx="8" fill="#f1f3f7" stroke="#cfd5df"/>
|
||||
<text x="330" y="246" font-size="11.5" fill="#1a2030">Answer from what it last read: no new</text>
|
||||
<text x="330" y="262" font-size="11.5" fill="#1a2030">credential, the stored subscription state.</text>
|
||||
|
||||
<rect x="316" y="374" width="320" height="54" rx="8" fill="#fff4e0" stroke="#e7c48a"/>
|
||||
<text x="330" y="394" font-size="11.5" fill="#1a2030">provider_unavailable, or the store's refusal,</text>
|
||||
<text x="330" y="410" font-size="11.5" fill="#1a2030">retryAfter at least an hour; only the stamp written.</text>
|
||||
|
||||
<rect x="316" y="446" width="320" height="104" rx="8" fill="#e7f4ec" stroke="#8cc6a3"/>
|
||||
<text x="330" y="466" font-size="11.5" fill="#1a2030">One transaction, after signing the month:</text>
|
||||
<text x="330" y="483" font-size="11.5" fill="#1a2030">re-read the tip (moved → internal); insert the</text>
|
||||
<text x="330" y="500" font-size="11.5" fill="#1a2030">charge; lapse to the store's period start, grant</text>
|
||||
<text x="330" y="517" font-size="11.5" fill="#1a2030">there, lapse to now; issue the month; set the state.</text>
|
||||
<text x="330" y="538" font-size="11.5" font-weight="600" fill="#11713f">→ credential + subscription</text>
|
||||
|
||||
<rect x="316" y="566" width="320" height="58" rx="8" fill="#f1f3f7" stroke="#cfd5df"/>
|
||||
<text x="330" y="586" font-size="11.5" fill="#1a2030">Record the charge, if any, and the state;</text>
|
||||
<text x="330" y="603" font-size="11.5" fill="#1a2030">no ledger row. → no credential + subscription</text>
|
||||
|
||||
<!-- reasons -->
|
||||
<rect x="676" y="446" width="280" height="178" rx="8" fill="#ffffff" stroke="#c9cfdb"/>
|
||||
<text x="690" y="468" font-size="12" font-weight="600" fill="#334155">Not grantable when</text>
|
||||
<g font-size="11.5" fill="#1a2030">
|
||||
<text x="690" y="490">the state is ended, or there is no charge</text>
|
||||
<text x="690" y="507">the charge was revoked, or is in an offer</text>
|
||||
<text x="690" y="524">its plan is not sold, or is another tier</text>
|
||||
<text x="690" y="541">its period has not started, or is over</text>
|
||||
<text x="690" y="558">its months from the period start ran out</text>
|
||||
<text x="690" y="575">a ledger row already grants it</text>
|
||||
</g>
|
||||
<text x="690" y="600" font-size="11" fill="#5b6577">A period that ends before it starts:</text>
|
||||
<text x="690" y="615" font-size="11" fill="#5b6577">internal, logged.</text>
|
||||
<line x1="636" y1="594" x2="674" y2="594" stroke="#c9cfdb" stroke-width="1.2" stroke-dasharray="3 3"/>
|
||||
|
||||
<!-- the app -->
|
||||
<rect x="24" y="648" width="932" height="116" rx="8" fill="#eef1f5"/>
|
||||
<text x="40" y="670" font-size="12.5" font-weight="600" fill="#1a2030">What the app does with the answer</text>
|
||||
<g font-size="11" fill="#1a2030">
|
||||
<text x="40" y="692" font-weight="600">A credential</text>
|
||||
<text x="40" y="708">stored, shown when the</text>
|
||||
<text x="40" y="723">current one expires; the</text>
|
||||
<text x="40" y="738">unrenewed run ends</text>
|
||||
|
||||
<text x="270" y="692" font-weight="600">No credential, or an error</text>
|
||||
<text x="270" y="708">the unrenewed run starts or grows;</text>
|
||||
<text x="270" y="723">badge kept until 67 days after the</text>
|
||||
<text x="270" y="738">run started; asked again at the</text>
|
||||
<text x="270" y="753">next daily moment of its expiry</text>
|
||||
|
||||
<text x="500" y="692" font-weight="600">subscription: ended</text>
|
||||
<text x="500" y="708">badge retired in the same pass;</text>
|
||||
<text x="500" y="723">alert subscription_ended;</text>
|
||||
<text x="500" y="738">nothing more is asked</text>
|
||||
|
||||
<text x="730" y="692" font-weight="600">No reply (timeout)</text>
|
||||
<text x="730" y="708">nothing retired; the run and the</text>
|
||||
<text x="730" y="723">ledger untouched; the worker</text>
|
||||
<text x="730" y="738">records the failure and retries</text>
|
||||
<text x="730" y="753">on its backoff, 30 s to an hour</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,89 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="566" viewBox="0 0 980 566" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Request lanes: requestLane sends codes, invoices, App Store one-time purchases and issue requests not due a check to the main lane, which asks no store; Play purchases and App Store subscription purchases to the purchase lane, where subscriptions are limited per claim to once an hour and Play subscriptions to 300 checks a minute, and Play one-time purchases are not limited; issue requests due a store check to the renewal lane, limited to once an hour per subscription.">
|
||||
<defs>
|
||||
<marker id="l-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" markerUnits="userSpaceOnUse" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#5b6577"/></marker>
|
||||
</defs>
|
||||
<rect width="980" height="566" fill="#f7f8fb"/>
|
||||
|
||||
<text x="24" y="36" font-size="18" font-weight="600" fill="#1a2030">Request lanes and the store limits on each</text>
|
||||
<text x="24" y="57" font-size="12.5" fill="#5b6577">requestLane reads the request, and for issueBadge the purchase, and never writes. Each lane answers one request at a time.</text>
|
||||
|
||||
<!-- incoming -->
|
||||
<rect x="24" y="86" width="166" height="384" rx="10" fill="#ffffff" stroke="#c9cfdb"/>
|
||||
<text x="38" y="110" font-size="13" font-weight="600" fill="#334155">Service requests</text>
|
||||
<text x="38" y="127" font-size="11" fill="#5b6577">one queue, as they arrive</text>
|
||||
<g font-size="11" fill="#334155" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">
|
||||
<text x="38" y="166">redeemBadgeCode</text>
|
||||
<text x="38" y="196">getBadgeInvoice</text>
|
||||
<text x="38" y="226">purchaseBadge</text>
|
||||
<text x="38" y="256">purchaseBadge</text>
|
||||
<text x="38" y="286">purchaseBadge</text>
|
||||
<text x="38" y="336">issueBadge</text>
|
||||
<text x="38" y="380">issueBadge</text>
|
||||
</g>
|
||||
<g font-size="10.5" fill="#5b6577">
|
||||
<text x="38" y="240">Apple, one-time</text>
|
||||
<text x="38" y="270">Apple, auto-renewable</text>
|
||||
<text x="38" y="300">Google, any product</text>
|
||||
<text x="38" y="350">not due a store check</text>
|
||||
<text x="38" y="394">due a store check</text>
|
||||
</g>
|
||||
|
||||
<line x1="190" y1="278" x2="212" y2="278" stroke="#5b6577" stroke-width="1.5" marker-end="url(#l-arr)"/>
|
||||
|
||||
<!-- router -->
|
||||
<rect x="214" y="246" width="122" height="64" rx="8" fill="#ffffff" stroke="#8a93a6"/>
|
||||
<text x="275" y="273" font-size="12" font-weight="600" fill="#1a2030" text-anchor="middle" font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace">requestLane</text>
|
||||
<text x="275" y="292" font-size="10.5" fill="#5b6577" text-anchor="middle">Service.hs:334</text>
|
||||
|
||||
<path d="M336,278 H352 V131 H370" fill="none" stroke="#5b6577" stroke-width="1.5" marker-end="url(#l-arr)"/>
|
||||
<path d="M336,278 H370" fill="none" stroke="#5b6577" stroke-width="1.5" marker-end="url(#l-arr)"/>
|
||||
<path d="M336,278 H352 V414 H370" fill="none" stroke="#5b6577" stroke-width="1.5" marker-end="url(#l-arr)"/>
|
||||
|
||||
<!-- main lane -->
|
||||
<rect x="372" y="86" width="420" height="92" rx="10" fill="#f1f3f7" stroke="#cfd5df"/>
|
||||
<text x="388" y="109" font-size="13" font-weight="600" fill="#334155">Main lane</text>
|
||||
<text x="388" y="129" font-size="11.5" fill="#1a2030">codes, invoices, App Store one-time purchases (verified offline),</text>
|
||||
<text x="388" y="145" font-size="11.5" fill="#1a2030">issue requests whose subscription is not due a store check</text>
|
||||
<rect x="388" y="154" width="112" height="18" rx="9" fill="#ffffff" stroke="#cfd5df"/>
|
||||
<text x="444" y="167" font-size="10.5" fill="#334155" text-anchor="middle">asks no store</text>
|
||||
|
||||
<!-- purchase lane -->
|
||||
<rect x="372" y="196" width="420" height="146" rx="10" fill="#eceefe" stroke="#c5c8f0"/>
|
||||
<text x="388" y="219" font-size="13" font-weight="600" fill="#3a3fa6">Purchase lane</text>
|
||||
<text x="388" y="239" font-size="11.5" fill="#1a2030">Play purchases, and App Store purchases whose unverified</text>
|
||||
<text x="388" y="255" font-size="11.5" fill="#1a2030">payload says auto-renewable. Play one-time: no limit</text>
|
||||
<rect x="388" y="266" width="390" height="38" rx="6" fill="#ffffff" stroke="#c5c8f0"/>
|
||||
<text x="398" y="281" font-size="10.5" fill="#1a2030">Subscriptions, per claim: once an hour, whoever presents it (App</text>
|
||||
<text x="398" y="296" font-size="10.5" fill="#1a2030">Store by original id after the offline check, Play by token hash)</text>
|
||||
<rect x="388" y="310" width="390" height="22" rx="6" fill="#ffffff" stroke="#c5c8f0"/>
|
||||
<text x="398" y="325" font-size="10.5" fill="#1a2030">Play subscriptions: ≤ 300 checks a minute, then rate_limited</text>
|
||||
|
||||
<!-- renewal lane -->
|
||||
<rect x="372" y="360" width="420" height="110" rx="10" fill="#e3f1f3" stroke="#b5dadd"/>
|
||||
<text x="388" y="383" font-size="13" font-weight="600" fill="#0f5f69">Renewal lane</text>
|
||||
<text x="388" y="403" font-size="11.5" fill="#1a2030">issueBadge signed by the key it names, its subscription due a</text>
|
||||
<text x="388" y="419" font-size="11.5" fill="#1a2030">store check judged 60 s ahead, or whose routing read failed</text>
|
||||
<rect x="388" y="428" width="390" height="34" rx="6" fill="#ffffff" stroke="#b5dadd"/>
|
||||
<text x="398" y="442" font-size="10.5" fill="#1a2030">Per subscription: once an hour, stamped before asking, failures</text>
|
||||
<text x="398" y="456" font-size="10.5" fill="#1a2030">included; requests in between are answered from the stored state</text>
|
||||
|
||||
<!-- stores -->
|
||||
<rect x="830" y="196" width="126" height="274" rx="10" fill="#ffffff" stroke="#c9cfdb"/>
|
||||
<text x="893" y="218" font-size="12" font-weight="600" fill="#334155" text-anchor="middle">Stores</text>
|
||||
<rect x="842" y="232" width="102" height="50" rx="6" fill="#e9f1fc" stroke="#8db4e8"/>
|
||||
<text x="893" y="253" font-size="11" font-weight="600" fill="#0b5cbf" text-anchor="middle">App Store</text>
|
||||
<text x="893" y="268" font-size="11" font-weight="600" fill="#0b5cbf" text-anchor="middle">Server API</text>
|
||||
<rect x="842" y="292" width="102" height="50" rx="6" fill="#e7f4ec" stroke="#8cc6a3"/>
|
||||
<text x="893" y="313" font-size="11" font-weight="600" fill="#11713f" text-anchor="middle">Play</text>
|
||||
<text x="893" y="328" font-size="11" font-weight="600" fill="#11713f" text-anchor="middle">Developer API</text>
|
||||
<text x="893" y="372" font-size="10.5" fill="#5b6577" text-anchor="middle">each call up to</text>
|
||||
<text x="893" y="386" font-size="10.5" fill="#5b6577" text-anchor="middle">verifyTimeout, 10 s</text>
|
||||
|
||||
<line x1="792" y1="252" x2="828" y2="252" stroke="#5b6577" stroke-width="1.5" marker-end="url(#l-arr)"/>
|
||||
<line x1="792" y1="414" x2="828" y2="414" stroke="#5b6577" stroke-width="1.5" marker-end="url(#l-arr)"/>
|
||||
|
||||
<!-- note -->
|
||||
<rect x="24" y="490" width="932" height="52" rx="8" fill="#eef1f5"/>
|
||||
<text x="40" y="510" font-size="12" fill="#1a2030">A ledger is appended from the main and renewal lanes. Each write re-reads the tip, which is enough while transactions run one</text>
|
||||
<text x="40" y="527" font-size="12" fill="#1a2030">at a time: SQLite, or Postgres at --pool-size 1. A larger Postgres pool needs a row lock on the purchase, which is not built.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,119 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="610" viewBox="0 0 980 610" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="The retirement window on a day scale after paidThrough: the first answer that renews nothing sets unrenewed_since and last_unrenewed_at; later such answers move last_unrenewed_at; the badge retires at the first answer 67 days after unrenewed_since. A gap of 67 days or more without such an answer starts a fresh run, a renewal clears the run, and requests that get no reply leave the run unchanged.">
|
||||
<defs>
|
||||
<marker id="w-tick" viewBox="0 0 2 10" refX="1" refY="5" markerWidth="2" markerHeight="10" markerUnits="userSpaceOnUse" orient="auto"><rect width="2" height="10" fill="#5b6577"/></marker>
|
||||
</defs>
|
||||
<rect width="980" height="610" fill="#f7f8fb"/>
|
||||
|
||||
<text x="24" y="36" font-size="18" font-weight="600" fill="#1a2030">The retirement window</text>
|
||||
<text x="24" y="57" font-size="12.5" fill="#5b6577">Two columns, no timer: the rule runs in the badge worker's pass. Days are counted from paidThrough.</text>
|
||||
|
||||
<!-- legend -->
|
||||
<g font-size="11" fill="#334155">
|
||||
<circle cx="192" cy="78" r="6" fill="#ffffff" stroke="#3a3fa6" stroke-width="2"/>
|
||||
<text x="204" y="82">a run starts: unrenewed_since</text>
|
||||
<circle cx="404" cy="78" r="3.5" fill="#334155"/>
|
||||
<text x="414" y="82">answer that renewed nothing</text>
|
||||
<path d="M596,74 l8,8 m0,-8 l-8,8" stroke="#8a93a6" stroke-width="1.6"/>
|
||||
<text x="610" y="82">no reply</text>
|
||||
<path d="M676,72 l6,6 l-6,6 l-6,-6 z" fill="#13804a"/>
|
||||
<text x="688" y="82">renewal</text>
|
||||
<path d="M756,72 l6,6 l-6,6 l-6,-6 z" fill="#b3261e"/>
|
||||
<text x="768" y="82">retired</text>
|
||||
</g>
|
||||
|
||||
<!-- axis: x = 186 + (day + 6) * 4.6 -->
|
||||
<text x="24" y="112" font-size="11" fill="#5b6577">days after paidThrough</text>
|
||||
<line x1="186" y1="108" x2="950" y2="108" stroke="#8a93a6" stroke-width="1.2"/>
|
||||
<g stroke="#8a93a6" stroke-width="1.2">
|
||||
<line x1="213.6" y1="104" x2="213.6" y2="112"/>
|
||||
<line x1="351.6" y1="104" x2="351.6" y2="112"/>
|
||||
<line x1="489.6" y1="104" x2="489.6" y2="112"/>
|
||||
<line x1="627.6" y1="104" x2="627.6" y2="112"/>
|
||||
<line x1="765.6" y1="104" x2="765.6" y2="112"/>
|
||||
<line x1="903.6" y1="104" x2="903.6" y2="112"/>
|
||||
</g>
|
||||
<g font-size="11" fill="#5b6577" text-anchor="middle">
|
||||
<text x="213.6" y="100">0</text>
|
||||
<text x="351.6" y="100">30</text>
|
||||
<text x="489.6" y="100">60</text>
|
||||
<text x="627.6" y="100">90</text>
|
||||
<text x="765.6" y="100">120</text>
|
||||
<text x="903.6" y="100">150</text>
|
||||
</g>
|
||||
<line x1="213.6" y1="112" x2="213.6" y2="500" stroke="#c9cfdb" stroke-width="1" stroke-dasharray="3 3"/>
|
||||
|
||||
<!-- row A -->
|
||||
<rect x="24" y="124" width="932" height="88" rx="8" fill="#ffffff" stroke="#e1e5ec"/>
|
||||
<text x="36" y="160" font-size="12" font-weight="600" fill="#1a2030">A persistent fault</text>
|
||||
<text x="36" y="176" font-size="11" fill="#5b6577">every answer renews nothing</text>
|
||||
<line x1="213.6" y1="168" x2="526.4" y2="168" stroke="#c9cfdb" stroke-width="1.2"/>
|
||||
<circle cx="218.2" cy="168" r="6" fill="#ffffff" stroke="#3a3fa6" stroke-width="2"/>
|
||||
<g fill="#334155">
|
||||
<circle cx="250.4" cy="168" r="3.5"/><circle cx="282.6" cy="168" r="3.5"/><circle cx="314.8" cy="168" r="3.5"/>
|
||||
<circle cx="347" cy="168" r="3.5"/><circle cx="379.2" cy="168" r="3.5"/><circle cx="411.4" cy="168" r="3.5"/>
|
||||
<circle cx="443.6" cy="168" r="3.5"/><circle cx="475.8" cy="168" r="3.5"/><circle cx="508" cy="168" r="3.5"/>
|
||||
</g>
|
||||
<path d="M526.4,161 l7,7 l-7,7 l-7,-7 z" fill="#b3261e"/>
|
||||
<text x="218.2" y="152" font-size="11.5" fill="#3a3fa6">T0 sets both columns</text>
|
||||
<text x="540" y="172" font-size="11.5" fill="#b3261e">retired at the first answer ≥ T0 + 67 days,</text>
|
||||
<text x="540" y="188" font-size="11.5" fill="#b3261e">alert subscription_ended</text>
|
||||
<line x1="218.2" y1="186" x2="526.4" y2="186" stroke="#5b6577" stroke-width="1.2" marker-start="url(#w-tick)" marker-end="url(#w-tick)"/>
|
||||
<text x="372" y="202" font-size="11" fill="#1a2030" text-anchor="middle">67 days = 60 (longest store recovery) + 7 (contacts' grace)</text>
|
||||
<text x="372" y="152" font-size="11" fill="#5b6577">each answer moves last_unrenewed_at</text>
|
||||
|
||||
<!-- row B -->
|
||||
<rect x="24" y="224" width="932" height="88" rx="8" fill="#ffffff" stroke="#e1e5ec"/>
|
||||
<text x="36" y="260" font-size="12" font-weight="600" fill="#1a2030">Away longer than</text>
|
||||
<text x="36" y="276" font-size="12" font-weight="600" fill="#1a2030">the window</text>
|
||||
<circle cx="218.2" cy="268" r="6" fill="#ffffff" stroke="#3a3fa6" stroke-width="2"/>
|
||||
<circle cx="250.4" cy="268" r="3.5" fill="#334155"/>
|
||||
<circle cx="282.6" cy="268" r="3.5" fill="#334155"/>
|
||||
<line x1="290" y1="268" x2="620" y2="268" stroke="#8a93a6" stroke-width="1.4" stroke-dasharray="6 5"/>
|
||||
<text x="455" y="258" font-size="11.5" fill="#5b6577" text-anchor="middle">app not opened for 75 days</text>
|
||||
<circle cx="627.6" cy="268" r="6" fill="#ffffff" stroke="#3a3fa6" stroke-width="2"/>
|
||||
<text x="640" y="264" font-size="11.5" fill="#3a3fa6">fresh run: both columns = day 90</text>
|
||||
<line x1="282.6" y1="286" x2="627.6" y2="286" stroke="#5b6577" stroke-width="1.2" marker-start="url(#w-tick)" marker-end="url(#w-tick)"/>
|
||||
<text x="455" y="302" font-size="11" fill="#1a2030" text-anchor="middle">75 days ≥ 67 since last_unrenewed_at (day 15)</text>
|
||||
<text x="640" y="302" font-size="11" fill="#5b6577">earliest retirement now day 157</text>
|
||||
|
||||
<!-- row C -->
|
||||
<rect x="24" y="324" width="932" height="88" rx="8" fill="#ffffff" stroke="#e1e5ec"/>
|
||||
<text x="36" y="360" font-size="12" font-weight="600" fill="#1a2030">A renewal ends</text>
|
||||
<text x="36" y="376" font-size="12" font-weight="600" fill="#1a2030">the run</text>
|
||||
<circle cx="218.2" cy="368" r="6" fill="#ffffff" stroke="#3a3fa6" stroke-width="2"/>
|
||||
<g fill="#334155">
|
||||
<circle cx="250.4" cy="368" r="3.5"/><circle cx="282.6" cy="368" r="3.5"/><circle cx="314.8" cy="368" r="3.5"/>
|
||||
</g>
|
||||
<path d="M328.6,361 l7,7 l-7,7 l-7,-7 z" fill="#13804a"/>
|
||||
<text x="340" y="356" font-size="11.5" fill="#13804a">renewal: the ledger covers now, both columns cleared</text>
|
||||
<circle cx="471.2" cy="368" r="6" fill="#ffffff" stroke="#3a3fa6" stroke-width="2"/>
|
||||
<circle cx="503.4" cy="368" r="3.5" fill="#334155"/>
|
||||
<circle cx="535.6" cy="368" r="3.5" fill="#334155"/>
|
||||
<text x="471.2" y="396" font-size="11.5" fill="#1a2030">past the new paidThrough, the next unrenewed answer starts a new run</text>
|
||||
|
||||
<!-- row D -->
|
||||
<rect x="24" y="424" width="932" height="88" rx="8" fill="#ffffff" stroke="#e1e5ec"/>
|
||||
<text x="36" y="460" font-size="12" font-weight="600" fill="#1a2030">The service</text>
|
||||
<text x="36" y="476" font-size="12" font-weight="600" fill="#1a2030">never replies</text>
|
||||
<circle cx="218.2" cy="468" r="6" fill="#ffffff" stroke="#3a3fa6" stroke-width="2"/>
|
||||
<g stroke="#8a93a6" stroke-width="1.6">
|
||||
<path d="M246.4,464 l8,8 m0,-8 l-8,8"/><path d="M283.2,464 l8,8 m0,-8 l-8,8"/><path d="M320,464 l8,8 m0,-8 l-8,8"/>
|
||||
<path d="M356.8,464 l8,8 m0,-8 l-8,8"/><path d="M393.6,464 l8,8 m0,-8 l-8,8"/><path d="M430.4,464 l8,8 m0,-8 l-8,8"/>
|
||||
<path d="M467.2,464 l8,8 m0,-8 l-8,8"/><path d="M504,464 l8,8 m0,-8 l-8,8"/><path d="M540.8,464 l8,8 m0,-8 l-8,8"/>
|
||||
<path d="M577.6,464 l8,8 m0,-8 l-8,8"/><path d="M614.4,464 l8,8 m0,-8 l-8,8"/><path d="M651.2,464 l8,8 m0,-8 l-8,8"/>
|
||||
<path d="M688,464 l8,8 m0,-8 l-8,8"/><path d="M724.8,464 l8,8 m0,-8 l-8,8"/><path d="M761.6,464 l8,8 m0,-8 l-8,8"/>
|
||||
</g>
|
||||
<circle cx="811.6" cy="468" r="6" fill="#ffffff" stroke="#3a3fa6" stroke-width="2"/>
|
||||
<text x="250" y="452" font-size="11.5" fill="#5b6577">no reply: the run unchanged, nothing retired</text>
|
||||
<text x="218.2" y="496" font-size="11.5" fill="#1a2030">a service that never replies keeps the badge</text>
|
||||
<text x="826" y="472" font-size="11.5" fill="#3a3fa6">next answer:</text>
|
||||
<text x="826" y="488" font-size="11.5" fill="#3a3fa6">fresh run</text>
|
||||
|
||||
<!-- the rule -->
|
||||
<rect x="24" y="524" width="932" height="74" rx="8" fill="#eef1f5"/>
|
||||
<g font-size="11.5" fill="#1a2030">
|
||||
<text x="40" y="545">Library/Commands.hs:5700-5712. On any pass after which the ledger covers now, the run is cleared. After an answered request</text>
|
||||
<text x="40" y="562">that left nothing covered, last_unrenewed_at moves to now if it is under 67 days old, and otherwise a fresh run starts.</text>
|
||||
<text x="40" y="579">Retire if the answer is ended, or 67 days have passed since unrenewed_since. Dots are illustrative: an open app asks daily.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,74 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="980" height="600" viewBox="0 0 980 600" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Subscription states: Apple status 1 and Play active or paused map to renewing or cancelled by auto-renew; Apple 3 and 4 and Play grace and hold to billingRetry; Apple 2 and 5 and Play expired, pending purchase canceled or HTTP 410 to ended. Each state sets the Your badge row and a chat-list banner: renews soon for annual plans, subscription cancelled, payment problem, and subscription ended once retired.">
|
||||
<defs>
|
||||
<marker id="s-arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" markerUnits="userSpaceOnUse" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#5b6577"/></marker>
|
||||
</defs>
|
||||
<rect width="980" height="600" fill="#f7f8fb"/>
|
||||
|
||||
<text x="24" y="36" font-size="18" font-weight="600" fill="#1a2030">Subscription states and what each shows</text>
|
||||
<text x="24" y="57" font-size="12.5" fill="#5b6577">The client keeps the store's state as last observed (badge_purchases.subscription_status); the alert comes from it and paidThrough.</text>
|
||||
|
||||
<g font-size="12" font-weight="600" fill="#334155">
|
||||
<text x="24" y="90">The store says</text>
|
||||
<text x="474" y="90" text-anchor="middle">State on the client</text>
|
||||
<text x="588" y="90">What the user sees</text>
|
||||
</g>
|
||||
|
||||
<!-- row 1: renewing -->
|
||||
<rect x="24" y="102" width="932" height="74" rx="8" fill="#ffffff" stroke="#d9dde5"/>
|
||||
<text x="38" y="128" font-size="11.5" fill="#0b5cbf">Apple: status 1, auto-renew on</text>
|
||||
<text x="38" y="148" font-size="11.5" fill="#11713f">Play: active or paused, auto-renew on</text>
|
||||
<line x1="372" y1="139" x2="402" y2="139" stroke="#5b6577" stroke-width="1.4" marker-end="url(#s-arr)"/>
|
||||
<rect x="404" y="124" width="140" height="30" rx="15" fill="#e7f4ec" stroke="#8cc6a3"/>
|
||||
<text x="474" y="144" font-size="12.5" font-weight="600" fill="#11713f" text-anchor="middle">renewing</text>
|
||||
<line x1="546" y1="139" x2="576" y2="139" stroke="#5b6577" stroke-width="1.4" marker-end="url(#s-arr)"/>
|
||||
<text x="588" y="123" font-size="11.5" fill="#1a2030">Your badge: “Renews {date} · {price}”</text>
|
||||
<text x="588" y="141" font-size="11.5" fill="#1a2030">Banner, annual plans only, 3 days before:</text>
|
||||
<text x="588" y="159" font-size="11.5" fill="#1a2030">“Your badge renews soon”</text>
|
||||
|
||||
<!-- row 2: cancelled -->
|
||||
<rect x="24" y="186" width="932" height="74" rx="8" fill="#ffffff" stroke="#d9dde5"/>
|
||||
<text x="38" y="212" font-size="11.5" fill="#0b5cbf">Apple: status 1, auto-renew off</text>
|
||||
<text x="38" y="232" font-size="11.5" fill="#11713f">Play: active or paused, auto-renew off; canceled</text>
|
||||
<line x1="372" y1="223" x2="402" y2="223" stroke="#5b6577" stroke-width="1.4" marker-end="url(#s-arr)"/>
|
||||
<rect x="404" y="208" width="140" height="30" rx="15" fill="#eef1f5" stroke="#b9c1cf"/>
|
||||
<text x="474" y="228" font-size="12.5" font-weight="600" fill="#334155" text-anchor="middle">cancelled</text>
|
||||
<line x1="546" y1="223" x2="576" y2="223" stroke="#5b6577" stroke-width="1.4" marker-end="url(#s-arr)"/>
|
||||
<text x="588" y="207" font-size="11.5" fill="#1a2030">Your badge: “Ends {date}”, then “Ended”</text>
|
||||
<text x="588" y="225" font-size="11.5" fill="#1a2030">Banner while paid: “Subscription cancelled”,</text>
|
||||
<text x="588" y="243" font-size="11.5" fill="#1a2030">“Your badge stays until {date}”</text>
|
||||
|
||||
<!-- row 3: billingRetry -->
|
||||
<rect x="24" y="270" width="932" height="74" rx="8" fill="#ffffff" stroke="#d9dde5"/>
|
||||
<text x="38" y="296" font-size="11.5" fill="#0b5cbf">Apple: status 3 (billing retry), 4 (grace)</text>
|
||||
<text x="38" y="316" font-size="11.5" fill="#11713f">Play: in grace period, on hold</text>
|
||||
<line x1="372" y1="307" x2="402" y2="307" stroke="#5b6577" stroke-width="1.4" marker-end="url(#s-arr)"/>
|
||||
<rect x="404" y="292" width="140" height="30" rx="15" fill="#fff4e0" stroke="#e7c48a"/>
|
||||
<text x="474" y="312" font-size="12.5" font-weight="600" fill="#9a5400" text-anchor="middle">billingRetry</text>
|
||||
<line x1="546" y1="307" x2="576" y2="307" stroke="#5b6577" stroke-width="1.4" marker-end="url(#s-arr)"/>
|
||||
<text x="588" y="291" font-size="11.5" fill="#1a2030">Your badge: “Payment problem”</text>
|
||||
<text x="588" y="309" font-size="11.5" fill="#1a2030">Banner, as a warning: “Badge payment problem”</text>
|
||||
<text x="588" y="327" font-size="11.5" fill="#5b6577">badge kept, no credential, asked again daily</text>
|
||||
|
||||
<!-- row 4: ended -->
|
||||
<rect x="24" y="354" width="932" height="74" rx="8" fill="#ffffff" stroke="#d9dde5"/>
|
||||
<text x="38" y="380" font-size="11.5" fill="#0b5cbf">Apple: status 2 (expired), 5 (revoked)</text>
|
||||
<text x="38" y="400" font-size="11.5" fill="#11713f">Play: expired, pending purchase canceled, HTTP 410</text>
|
||||
<line x1="372" y1="391" x2="402" y2="391" stroke="#5b6577" stroke-width="1.4" marker-end="url(#s-arr)"/>
|
||||
<rect x="404" y="376" width="140" height="30" rx="15" fill="#fdecea" stroke="#e8a59f"/>
|
||||
<text x="474" y="396" font-size="12.5" font-weight="600" fill="#b3261e" text-anchor="middle">ended</text>
|
||||
<line x1="546" y1="391" x2="576" y2="391" stroke="#5b6577" stroke-width="1.4" marker-end="url(#s-arr)"/>
|
||||
<text x="588" y="375" font-size="11.5" fill="#1a2030">From the service: badge retired, banner</text>
|
||||
<text x="588" y="393" font-size="11.5" fill="#1a2030">“Your subscription ended”. From the device</text>
|
||||
<text x="588" y="411" font-size="11.5" fill="#1a2030">only the row changes: “Ended”</text>
|
||||
|
||||
<!-- notes -->
|
||||
<rect x="24" y="446" width="932" height="134" rx="8" fill="#eef1f5"/>
|
||||
<g font-size="12" fill="#1a2030">
|
||||
<text x="40" y="468">Writers: the service, at the credit and in any answer past the coverage the client asserted; and the device's report after each</text>
|
||||
<text x="40" y="485">sweep, which never replaces billingRetry or ended with renewing (Play lists a subscription in its grace period as auto-renewing).</text>
|
||||
<text x="40" y="508">No state yet, or one this version does not know: shown as renewing, no banner. Play's pending subscription: payment_pending, no state.</text>
|
||||
<text x="40" y="531">Also retired without ended: once 67 days have passed since the first answer that renewed nothing, again “Your subscription ended”.</text>
|
||||
<text x="40" y="554">One banner at a time, first that applies: subscription ended, payment problem, renewal failed, cancelled, renews soon.</text>
|
||||
<text x="40" y="571" fill="#5b6577">“Badge renewal failed” (issue_failed) shows a failure that cannot clear, or any once the shown credential has expired.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.6 KiB |
@@ -0,0 +1,207 @@
|
||||
# Badge subscriptions: PR #7716 for reviewers
|
||||
|
||||
PR #7716, `f/badge-subscriptions` into `master`, draft: head `ffb036680`, merge base `0eeac1bb9` (master on 2026-10-19), 76 files, +5,566 −759, of which about 2,050 changed lines are Haskell, 1,250 app code and 2,800 tests. Written on 2026-10-19 from the code at the head and the run reports. Where the code and the plan [`2026-10-13-badge-subscription-renewals.md`](../2026-10-13-badge-subscription-renewals.md) differ, this follows the code, and §13 lists the differences. Core paths are under `src/Simplex/Chat/`, service paths under `apps/simplex-badge-service/src/`; line numbers are at `ffb036680`.
|
||||
|
||||
## 1. What it adds and why
|
||||
|
||||
On master a badge is prepaid: a code bought on the web, or a one-month store purchase that is built but hidden in the apps since 2026-10-16. This PR sells badges as App Store and Google Play subscriptions (supporter or legend, monthly or annual) that keep renewing without the app handing renewals over. When the paid months run out, the app sends its usual signed `issueBadge` at the shared weekly moment; the badge service asks the store whether it charged again and grants that charge's months from the store's billing-period start. The app keeps the badge until the service answers `ended`, or until 67 days of answers that renewed nothing have passed (§4).
|
||||
|
||||
It also adds the screens to sell, show and manage a subscription on iOS, Android and desktop, the subscription alerts, and the store-load bounds the reviews required before sale: three request lanes, and per-claim, per-minute and per-subscription store limits (§5). Renewals are found when the app asks, not from store notifications, so no background ledger writer is needed (plan §4, alternatives).
|
||||
|
||||

|
||||
|
||||
## 2. Buying a subscription
|
||||
|
||||
The app first creates the purchase record in core (`/_badge invoice`, refused while the profile shows a badge or holds a store receipt that may still be credited, `Library/Commands.hs:3627-3628`), buys with that id echoed by the store, and hands the receipt over with `/_badge purchase`. Core's receipt worker sends `purchaseBadge` under the record's keys, and the app finishes the store transaction only once core reports it credited or refused.
|
||||
|
||||
| | iOS (StoreKit 2) | Android (Play Billing) |
|
||||
|---|---|---|
|
||||
| Offered to | an Apple ID with no earlier subscription in the group (`BadgeStore.swift:142`, `:442`), since Apple would fold a new one into the old original id | everyone; the base plan's own offer, never a promotional one (`PlayStore.kt:229`) |
|
||||
| App sends | the signed transaction (JWS) | the product id and purchase token |
|
||||
| Claimed by | `originalTransactionId` (`PaymentService.hs:91-99`) | the token's SHA-256 |
|
||||
| Service checks | the JWS offline against the pinned root (Sandbox: `test_purchase`, no API call), then the App Store Server API (`StoreReceipts/Apple.hs:240-251`) | `purchases.subscriptionsv2.get` by token, without the one-time `verifyReceipt` (`StoreReceipts/Google.hs:129-134`) |
|
||||
| Acknowledgement | none exists | by the service, after the grant check and before signing and writing (`BadgeService/Service.hs:574`); never by the app (`BadgeStore.kt:281`) |
|
||||
|
||||
The service credits the store's latest charge if `grantableCharge` and `fundsMonthNow` allow it (`BadgeService/Service.hs:619-643`): a plan it sells (on a renewal, of the ledger's tier), a well-formed period with `periodStart <= now < periodEnd`, not revoked, not in an offer (Apple: any `offerType`; Play: a free-trial or introductory phase), a state other than `ended`, and months from the period start not yet run out, which Play's clipped start can cause. `chargeEntries` (`Badges/Ledger.hs:122-128`) dates the grant to the period start, with a lapse before and after it where months ran out; the month is then issued at now, in the transaction that claims the payment.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
**First-purchase answers** (`BadgeService/Service.hs:543-655`, `docs/protocol/badges-rpc.md:37`). The claim asked about within the hour: `payment_pending` with the time left. No Play read left this minute: `rate_limited`, 1-60 s, claim not held. Store unreachable: `provider_unavailable`, an hour. Period not started, or the store says pending: `payment_pending`, an hour. Sandbox or licence tester: `test_purchase`. Already credited to another key: `receipt_used` (for Apple, before any claim check). Play subscription ended, or Play's HTTP 410: `receipt_invalid`, as a resubscription gets a new token. Revoked, in an offer, no charge yet, period over, months run out, a plan not sold, or an Apple subscription ended: `payment_pending`, a day. Malformed period: `internal`. No API key, or no `[google]`: `provider_not_configured`. Any shorter `retryAfter` is raised to the hour (`:807-810`).
|
||||
|
||||
**Two acknowledge paths.** `acknowledgeReceipt` needs the `VerifiedStoreTransaction` that only `verifyReceipt` produces (`BadgeService/StoreReceipts.hs:80-88`), so it serves one-time purchases. A Play subscription is routed to `purchaseSubscription` at `BadgeService/Service.hs:506`, before `verifyReceipt` at `:508`, so it never has one; `acknowledgeSubscription` reads the subscription again and acknowledges it unless Play reports it already is (`StoreReceipts/Google.hs:104-111`). Apple sets neither. Both paths acknowledge before signing and writing (`:574-584`), so a failure after the acknowledgement leaves a paid subscription that Play will no longer refund, credited only by a later retry (§14).
|
||||
|
||||
**Apple claims by original transaction id.** `applePayloadRef` (`PaymentService.hs:91-99`) names an auto-renewable transaction by `originalTransactionId` and any other by `transactionId`. The client's claim, the service's claim and the verifier (`StoreReceipts/Apple.hs:138-141`) all use it, so every renewal resolves to the subscription's record. A held Apple record adopts a later, non-revoked transaction and is due at once (`Store/Badges.hs:141-150`, `:187-194`). Because a store lists a purchase on every device of the account, hand-overs from that list are marked `listed` and join only a record core already has (`:153-164`).
|
||||
|
||||
## 3. Renewing, failing and ending
|
||||
|
||||
**Client** (`Library/Commands.hs:5672-5739`). A purchase whose replica holds a charge credit is a subscription (`Store/Badges.hs:468-471`), due at zero months too (`:5878-5885`). A request that issued nothing waits for the next daily moment of the shown credential's expiry, whatever `retryAfter` said (`:5905-5909`, `:5990-5993`), and a due request waits for the wake the last pass stored (`:5689-5693`), so activations do not each reach the service.
|
||||
|
||||
**Service** (`BadgeService/Service.hs:703-790`). While coverage runs, issuing is unchanged and asks no store. After it, the store is asked at most once an hour per subscription, stamped before asking (`:797-800`). A grantable new charge is written with the month in one transaction that re-reads the tip (`:760-772`); otherwise the charge and the state are recorded and the answer carries no credential.
|
||||
|
||||
**Failing and ending.** Billing retry (Apple status 3 or 4, Play grace or hold) gets no credential: the badge stays, Your badge says "Payment problem", the app asks daily, and contacts show the badge active until 7 days past the shown credential's expiry. An `ended` answer retires the badge in the same pass (`:6034-6041`) with the `subscription_ended` alert.
|
||||
|
||||
**The client's copy of the state**, `badge_purchases.subscription_status`, is written by the service's answers (at credit, `:6070`; any answer past the coverage the client asserted, `:5977`) and by the device's report, `/_badge subscription` (`:5386-5407`), which never replaces `billingRetry` or `ended` with `renewing`. The report exists because the service never sees a cancellation inside a paid period.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 4. The retirement window
|
||||
|
||||
The run of unrenewed answers is two columns, `unrenewed_since` and `last_unrenewed_at` (`M20261017_badge_subscriptions`), read as one `Maybe UnrenewedRun` (`Store/Badges.hs:432-433`). There is no timer: the rule runs in the badge worker's pass (`Library/Commands.hs:5700-5712`).
|
||||
|
||||
- On any pass after which the ledger covers now, the run is cleared ("covers nothing" is `L.paidThrough balance' <= now`, 007 D3).
|
||||
- After an answered request that left nothing covered, a run whose `last_unrenewed_at` is under 67 days old moves it to now; otherwise a fresh run starts. This is the fresh-run rule: a gap that long (the app closed, the service silent) may hide a renewal (007 D4, D8).
|
||||
- The badge retires on `ended`, or once the window has passed since `unrenewed_since` (`windowPassedSince . unrenewedSince`, `:5710`).
|
||||
|
||||
**Why `provider_unavailable` can retire and a timeout never does.** A `BSPError` is an answer: `requestBadgeIssue` returns it as `Nothing` and the pass goes on with the old balance (`:5696`, `:5988-5993`). A timeout throws, recording the failure and the next wake, and the pass stops before the rule (`:5530-5533`), so neither the run nor the ledger changes. A service that answers but never reaches its store therefore retires the badge after 67 days (007 D2), while one that never replies keeps it (005 D15): retiring without an answer would retire a returning subscriber on a network failure at launch (006 R4-1).
|
||||
|
||||
**Where 67 days comes from.** `60 * nominalDay + badgeGraceInterval` (`:5847-5848`). 60 days is the longest a store retries a failed renewal with Play's pause off: Apple's billing retry, and Play's grace period plus account hold by default. 7 days is the contacts' display grace (`Badges.hs:172-173`), which also covers Play's period ending days after the ledger's. Both come from store documentation, not observation.
|
||||
|
||||
**Accepted costs** (007 plan §8). An app opened less than once per 67 days never retires on a persistent fault. A recovery inside a shorter gap, followed by one answer that renews nothing, can retire a payer. Sixty-seven days of such answers retire a payer whose store may still be charging, and a resumed charge is never credited.
|
||||
|
||||

|
||||
|
||||
## 5. Lanes and limits
|
||||
|
||||
`requestLane` (`BadgeService/Service.hs:329-352`) puts each request on one of three serial lanes, where a store call can take up to 10 s. The purchase lane takes Play purchases and App Store purchases whose unverified payload says auto-renewable. The renewal lane takes an `issueBadge` signed by its purchase key whose subscription is due a store check (judged a minute ahead), or whose routing read failed. The main lane takes everything else and never waits on a store, and no purchase, real or made up, can delay a renewal (007 slice 2).
|
||||
|
||||
- **Renewal:** one store call per subscription per hour, failures included, which only a purchase-key holder can trigger. No per-minute cap.
|
||||
- **Subscription purchases, per claim** (run 005 D6; Play in slice 6): App Store claims by original id after the offline check, Play claims by token hash, each asked once an hour whoever presents it; at most 10,000 per store, and a full table asks without recording (`:815-839`); a claim no store was asked about is released (`:553`, `:565`). Rejected: a column on the payment row, a global rate limit, keying by purchase key or unverified text, keying by transaction id. Cost: a first purchase answered "not started" or "unreachable" waits an hour, not 300 s.
|
||||
- **Play subscription purchases, per minute** (run 007 D6): at most 300 subscription checks a minute on the purchase path, a tenth of Play's default quota of 3,000 that renewals share, counted after the claim check (the acknowledgement's read is not counted); over it, `rate_limited` and the claim is not held (`:841-850`). It uses the web limiter's fixed-window rule, `takeFrom` (`BadgeService/Web/Server.hs:136-140`). Rejected: counting from the claim map, a bucket per purchase key, refusing once the map is full, a pre-filter on the token.
|
||||
- **Play one-time purchases** also wait on the purchase lane, with neither limit: each made-up token is a `purchases.products.get` call. This predates the PR and run 007 ticketed it separately.
|
||||
- **Concurrency:** the main and renewal lanes both append to a ledger, guarded by a tip re-read while transactions run one at a time (SQLite, or Postgres at `--pool-size 1`).
|
||||
|
||||

|
||||
|
||||
## 6. Badge RPC and chat API
|
||||
|
||||
**Badge RPC** (`docs/protocol/badges-rpc.md`, `.schema.json`; version stays 1). `badgeCredential` gains an optional `subscription` (`renewing`, `cancelled`, `billingRetry`, `ended`; anything else reads as not ended). The `charge {chargeId}` credit is now written, dated to the store's period start. `purchaseBadge` and `issueBadge` gain the answers of §2 and §3. No error code is new.
|
||||
|
||||
**Chat API** (`Controller.hs`, `Library/Commands.hs:6517-6518`). `/_badge purchase` gains `listed`; `/_badge subscription <state> <payment>` is new (left out of the bot API docs); `CRStoreReceiptNotRecorded` and `BREStorePurchaseOpen` are new; `BadgeState.subscription {planMonths, status}` is new and fills `renewsAt` and `willRenew`; alert kind `subscription_cancelled` is new. The apps decode an unknown alert kind or state instead of failing the badge state, which keeps a newer core readable by an older app over remote desktop.
|
||||
|
||||
## 7. Types by module
|
||||
|
||||
- **Core.** `Badges/Types.hs`: `SubscriptionState`, `BadgeSubscription`, `StoreReceiptOrigin`, `BASubscriptionCancelled`. `Badges/Ledger.hs`: `chargeEntries`. `PaymentService.hs`: `appleTransactionId` renamed `appleTransactionRef`, plus `applePayloadRef` and `AppleTransactionFacts`. `Store/Badges.hs`: `UnrenewedRun`, `PurchaseKind = PKPrepaid | PKSubscription BadgeSubscription`.
|
||||
- **Service.** `StoreReceipts.hs`: `SubscriptionRef`, `SubscriptionCheck`, `StoreCharge`, the verifier's `checkSubscription` and `acknowledgeGoogleSubscription`, the receipt's `subscriptionRef` and `acknowledgeSubscription`. `Catalog.hs`: `StoreProduct = StoreOneTime | StoreSubscription`, `storePlan`. `Config.hs`: `AppleServerApi`. `Store.hs`: `PurchaseSubscription`. `Service.hs`: `RequestLane`, `PurchaseChecks`, `NotGranted`.
|
||||
|
||||
## 8. Database changes
|
||||
|
||||
| Database | Table | Change | Migration |
|
||||
|---|---|---|---|
|
||||
| Core | `badge_purchases` | `subscription_status`, `unrenewed_since`, `last_unrenewed_at` (SQLite `TEXT`; Postgres `TEXT`, `TIMESTAMPTZ`); the down step first clears `subscription_cancelled` acks, which master cannot decode | `M20261017_badge_subscriptions`, both backends |
|
||||
| Core | `badge_ledger` | read only: the purchase kind and plan months come from the latest charge credit | none |
|
||||
| Service | `sx_badge_service_subscription_charges` | new: charge id, payment, store charge ref (Apple transaction id, hashed Play order id), plan, period, amount, currency; unique `(payment_id, provider_charge_ref)` | `20261012_badge_subscriptions`, both backends |
|
||||
| Service | `sx_badge_service_badge_ledger` | `charge_id`, unique, so the schema grants a charge at most once | same |
|
||||
| Service | `sx_badge_service_payments` | `subscription_state`, `subscription_checked_at`, `provider_token` (a Play subscription's token, a bearer secret, never logged or returned); drops the never-written `subscription_renews_at`, `grace_until`, `cancelled` | same |
|
||||
|
||||
`M20261017_badge_subscriptions` exists because `M20261016_badge_store_receipts` shipped in master (#7587): run 008 restored that file to master's bytes and moved this branch's columns into a new migration, which also lets a downgrade to master run. The SQLite `chat_schema.sql` is regenerated; the Postgres one was regenerated on the host in `ffb036680`.
|
||||
|
||||
## 9. The store checks
|
||||
|
||||
The state mapping is in the states diagram above.
|
||||
|
||||
- **App Store** (`StoreReceipts/Apple.hs:223-397`). An ES256 token for the in-app purchase key, reused for 15 minutes; `GET /inApps/v1/subscriptions/{originalTransactionId}`. Everything Apple signed is checked against the pinned roots, and a failure is `internal`, never `receipt_invalid`. Family-shared: `receipt_invalid`; Sandbox: `test_purchase`; 401 and 403: `internal`; 404, 429, 5xx and timeouts: unreachable. The three key settings go together (`BadgeService/Config.hs:364-370`).
|
||||
- **Google Play** (`StoreReceipts/Google.hs:129-205`). `subscriptionsv2.get` by token; paused read as active (005 D14); exactly one line item; the charge ref is the SHA-256 of the order id; the period starts the plan's months before `expiryTime`; the offer flag comes from the current `offerPhase`, not `offerDetails`.
|
||||
- **Dev mock** (`StoreReceipts/Mock.hs`). Renews the plan the product or test token names, kept in memory, so a restart ends its subscriptions; it acknowledges nothing.
|
||||
|
||||
## 10. The apps
|
||||
|
||||
- **Both.** Subscriptions are handed to core instead of finished uncredited; the sweep and store listener hand over as `listed`, Purchase and Restore as the device's own. After the sweep, a device with a subscription reports its store's state. Your badge shows "Renews {date} · {price}", "Renewing", "Payment problem", "Ends {date}" or "Ended", with Manage subscription; Check your order adds the auto-renewal disclosure, policy links and Restore, which run 005 removed from Your badge.
|
||||
- **iOS** (`BadgeStore.swift`). Subscriptions are sold only while `Transaction.all` shows none earlier, checked again in `purchase()` (`:195-231`, recheck `:200-206`); a revoked subscription transaction is finished without handing over (`:357`); Manage opens `AppStore.showManageSubscriptions`; Restore runs `AppStore.sync()`.
|
||||
- **Kotlin** (`BadgeStore.kt`, `PlayStore.kt`). The sweep queries subscriptions too, skipping them only where Play does not support them (`PlayStore.kt:108-116`); a held one-time product is acknowledged, a subscription never (`PlayStore.kt:252-259`); Manage opens Play's page; desktop and remote hosts show the state without store rows.
|
||||
- Run 003 plan §4 lists the 15 deliberate platform differences.
|
||||
|
||||
## 11. Alerts
|
||||
|
||||
Core derives one alert per purchase, the first row that applies (`derivedBadgeAlert`, `Library/Commands.hs:5743-5759`); both apps show it in the single chat-list banner slot, and it can be dismissed or snoozed for a day.
|
||||
|
||||
| Kind | Raised for a subscription when | Banner |
|
||||
|---|---|---|
|
||||
| `subscription_ended` | past paidThrough and no longer shown (retired by `ended` or the window) | "Your subscription ended" |
|
||||
| `payment_issue` | state `billingRetry` | "Badge payment problem", warning |
|
||||
| `issue_failed` | a renewal failure that cannot clear, or any once the shown credential has expired | "Badge renewal failed", warning |
|
||||
| `subscription_cancelled` | state `cancelled`, paidThrough still ahead | "Subscription cancelled" |
|
||||
| `renewal_approaching` | annual plan, state `renewing`, the last 3 days before paidThrough | "Your badge renews soon", with the price |
|
||||
|
||||
A subscription still shown past paidThrough raises no `support_ended`: it is waiting for its store. Monthly plans get no reminder, as reminders cannot be turned off (003 P9).
|
||||
|
||||
## 12. Tests
|
||||
|
||||
**Added:** 76 examples. `tests/BadgeTests.hs`, 6 (charge rows on every renewal path, JSON, badge state, alert derivation, Apple refs). `tests/Bots/BadgeService/BotTests.hs`, 49, mostly end to end (purchase, renewal, grant once, the hourly bound, billing retry, ending, the retirement window in 8 tests, lanes and limits, adoption, listed hand-overs, device reports, alerts). `StoreVerifierTests.hs`, 17, against a new fake App Store Server API and FakePlay's subscription routes. `ConfigTests.hs`, 2. `WebTests.hs`, 2 (the migration's down and up, a charge credit's round trip), plus one changed.
|
||||
|
||||
**Last recorded results.**
|
||||
|
||||
- GitHub CI `build` on `d50488460`, the last commit with Haskell code, was **cancelled**. GHC 9.6.3 on Ubuntu 24.04: 1,805 examples, 0 failures, 46 pending; GHC 8.10.7: 1,788 and 0; Ubuntu 22.04 with 9.6.3: 1 failure, in a profile test this PR does not touch (the optional "subscribed 2 connections" line). On macOS arm64 (`macos-latest`) the suite had 7 failures (the schema down-migration and strict-tables checks, three existing badge-service tests, a directory-bot test and a chat-expiry test), then printed nothing for 73 minutes until a maintainer cancelled it; master passed 1,729 examples with 0 failures on that runner, and the cause is not known. Intel macOS and Windows only built. CI skips "Save query plans".
|
||||
- Container, run 009: build clean; the 682 badge examples had 6 failures under load, all passing rerun alone.
|
||||
- The last full local suite that finished was run 006's, at `d479e5871`, before run 007 and both master merges: 1,675 examples, 1 failure (the agent query plans' XFTP line).
|
||||
|
||||
**Never built or run.** No full local run has regenerated `chat_query_plans.txt` since `d479e5871`; run 007 hand-edited it and run 009 merged it as a union. Postgres: the host regenerated the core's Postgres `chat_schema.sql` (`ffb036680`), but no report records the `+client_postgres` badge-service build or its suites, and the deployed service is Postgres-only. No report or CI run records a Swift or Kotlin build: `ios-sim` and `desktop-linux` were skipped on push, and CI's Desktop step did not run. No real store was used; the fakes follow the documentation.
|
||||
|
||||
## 13. What the reviews changed, and where the code differs from the plan
|
||||
|
||||
**Reviews.** Run 001: an App Store renewal tested through `issueBadge`, the unique charge index. Run 003: the iOS "subscribed before" guard kept fresh and rechecked at purchase (a P0), store reports only from devices with a subscription. Run 004: Play's 410 read as ended, Play order ids hashed; it escalated unbounded App Store calls on the main lane and a request at every activation, both fixed in run 005 (lanes, the claim table, the stored wake), whose own review released a claim no store was asked about (C2-R1-1, a P1). Run 006: retirement moved after an answer (R4-1, a P0), the clipped-period guard; it escalated R5-1, one answer after months away retiring a payer, which run 007 fixed with the first-unrenewed-answer rule. Runs 007 and 008: wording, two tests, the migration split. Run 009 merged master with no review loop.
|
||||
|
||||
**Against the plan.** The plan ruled out app screens, load bounds and a client column; the PR has all three. The purchase kind is the latest charge's plan, not an `EXISTS` flag. A stalled request waits for the next daily moment of the shown expiry, not "a day later". The plan retired only on `ended`; the code adds the 67-day window. A first purchase not started or pending waits an hour, not 300 s. Play's pause is read as active, Play's 410 as ended, and order ids are hashed.
|
||||
|
||||
## 14. Open items and accepted risks
|
||||
|
||||
Each was checked against the code at `ffb036680`.
|
||||
|
||||
1. **macOS CI** (above): rerun `build` on `macos-latest` before merging; the failures and the stall are unexplained.
|
||||
2. **Acknowledged but not credited.** The service acknowledges a Play purchase before it signs and writes (`BadgeService/Service.hs:574-584`; one-time purchases at `:534`). If signing or the write fails, the subscription is paid and no longer refundable, and only a later retry, after the claim's hour, credits it; a persistent failure leaves the buyer with nothing.
|
||||
3. **A Postgres pool above one** needs a row lock on the purchase (005 §9). Still true: `renewSubscription` only re-reads the tip (`:760-772`). Keep `--pool-size 1`.
|
||||
4. **Store behaviour from documentation only:** Apple's 60-day retry and `purchaseDate` as period start; Play's 60 days of grace and hold, pause report, 410 and `offerPhase`; the 3,000-a-minute quota.
|
||||
5. **A Play pause longer than 67 days** retires the badge early, and the resumed charge is never credited (run 005 plan §8).
|
||||
6. **Run 005 D6's cost:** a first purchase answered "not started" or "unreachable" waits an hour, not 300 s, and a held record adopting a later transaction waits out its hour. Still true (`:543-556`).
|
||||
7. **C2-R1-3:** the held answer's `retryAfter` tells another holder of the same JWS when it was last presented. Still true (`:546`).
|
||||
8. **Run 007's costs:** the two residuals of §4; "a service that never answers" read as `provider_unavailable` only; and under a sustained flood of made-up Play tokens, genuine Play buyers get `rate_limited`, and past Play's three-day acknowledgement window the purchase is refunded automatically, so the buyer keeps the money and loses the badge. Made-up Play one-time tokens are not limited at all (§5).
|
||||
9. **A held receipt behind a shown badge** (open since 2026-10-15). Still in the code: the worker selects held receipts only for profiles with no shown badge (`Store/Badges.hs:260`), while Android acknowledges a held one-time product anyway (`BadgeStore.kt:281`), so such a purchase waits until that badge retires and loses Play's automatic refund. The PR narrows the ways in (codes and invoices refused while a held receipt may be credited, `Library/Commands.hs:5440-5449`; listed hand-overs record nothing) and never acknowledges a subscription on the device, so a parked Play subscription is refunded after three days. An Ask to Buy approved after a code was redeemed still waits (003 P7); for an Apple subscription, Apple charges every period meanwhile and only the charge current at crediting is granted (`:619-634` reads the latest charge only). Deferred with top-ups (007 U4).
|
||||
10. **Service timeouts** keep their 30 s to one hour backoff after activations (007 U3, accepted for the MVP).
|
||||
11. **Test devices** that ran an earlier build of this branch or of `f/badge-in-app-dev-service` fail the new migration ("duplicate column name: subscription_status"); reset their data (008). Run 007's merge-order risk is gone with the split.
|
||||
12. **Before sale** (plan §11, not built): resubscription and restore on a new device (iOS refuses to sell to an Apple ID that subscribed before), tier changes (another tier's charge is never granted), refunds (a refunded annual keeps issuing), spreading the weekly request moment (`Library/Commands.hs:5890-5892`). Store setup: Apple's Billing Grace Period off; Play's grace at its minimum, account hold, and subscription pausing off, which the 67-day bound assumes; no trial or introductory offers; Family Sharing off; the App Store Server API key and a Play service account with order permissions; quotas; store review. Store notifications are listed there too, but not as blocking sale.
|
||||
13. **One `ended` is final:** a store briefly reporting an expiry around a renewal loses the renewals after it (plan §8).
|
||||
|
||||
## 15. Merging as it stands
|
||||
|
||||
- **`a2e9456e6` "use the dev badge page for testing (don't merge)"** reverts master's `c998eb1fe`: `badgePageUrl` on iOS (`BadgeStore.swift:33-35`) and Kotlin (`BadgeStore.kt:52-57`) points at the dev deployment `smp7.simplex.im`, marked TEST ONLY, so buy-in-browser would open the dev service, for every desktop and foss buyer among others. Revert it before merging.
|
||||
- **`ea359edb6` "choose a badge in the app"** exactly reverts master's `898c6f126`. Where a store is available, Support SimpleX leads to Choose your badge, How long and Check your order again, so in-app buying, one-time and subscription, is reachable; desktop and foss show Buy in browser and Redeem code. Master hides in-app buying because badges were released without in-app purchases (2026-10-16). It is not marked, but merging it ships in-app sale; revert it unless that is intended.
|
||||
- **Seven `fixup!` commits** are not folded (`93dd1c159`, `d2c36aaa6`, `823a17267`, `908922060`, `bceaf06a4`, `e2249c64a`, `ebc0c532b`). The PR is squash-merged, so their changes reach master inside the squash; folding them first would mean rebasing across up to three merges.
|
||||
|
||||
## 16. Review map
|
||||
|
||||
★ marks files to read carefully: money first, then state, then concurrency. Line counts are `+added −removed` against the merge base.
|
||||
|
||||
| Layer | File | Lines | What it does | Read |
|
||||
|---|---|---|---|---|
|
||||
| Service | `BadgeService/Service.hs` | +353 −85 | lanes; subscription purchase path; grant rule; renewal; per-claim and per-minute limits | ★ money, concurrency |
|
||||
| Service | `BadgeService/Store.hs` | +105 −24 | payment rows with state and token; charges; ledger `charge_id` | ★ money, state |
|
||||
| Service | `BadgeService/StoreReceipts/Google.hs` | +139 −47 | `subscriptionsv2` read and mapping; period; acknowledgement; 410 | ★ money |
|
||||
| Service | `BadgeService/StoreReceipts.hs` | +118 −20 | receipt shape and the two acknowledge paths; store calls, timeouts, answer classes | ★ money |
|
||||
| Service | `BadgeService/StoreReceipts/Apple.hs` | +223 −15 | App Store Server API client; token; signed status checks and mapping | ★ trust |
|
||||
| Service | `BadgeService/Store/{SQLite,Postgres}/Migrations.hs` | +62 −1 each | `20261012_badge_subscriptions` | ★ schema |
|
||||
| Service | `BadgeService/Catalog.hs` | +38 −9 | subscription products and plans | check the ids |
|
||||
| Service | `StoreReceipts/Mock.hs`, `Config.hs`, `Web/Server.hs` | +51 −7, +28 −4, +10 −10 | dev mock; `[apple]` API key; `takeFrom` | skim |
|
||||
| Core | `Library/Commands.hs` | +209 −68 | pass, retirement window, held wake and stall, alerts, hand-over, device reports, purchase guards | ★ state, money |
|
||||
| Core | `Store/Badges.hs` | +81 −19 | receipt adoption, listed, purchase kind, unrenewed run | ★ state, concurrency |
|
||||
| Core | `Badges/Ledger.hs` | +18 −5 | `chargeEntries`; charge column read | ★ money |
|
||||
| Core | `PaymentService.hs` | +50 −5 | Apple claim ref; unverified facts | ★ claims |
|
||||
| Core | `Store/{SQLite,Postgres}/Migrations/M20261017_badge_subscriptions.hs` | +28, +29 | client columns; down step | ★ schema |
|
||||
| Core | `Badges/Types.hs`, `Badges/Service.hs`, `Badges.hs`, `Controller.hs`, `View.hs` | +60 −4, +3 −1, +1, +5 −2, +6 −2 | types, wire field, API, terminal view | read |
|
||||
| Core | `Migrations.hs` (2), `simplex-chat.cabal`, `M20260915_user_badges.hs` (2) | small | registration; draft comment | skim |
|
||||
| Core | `chat_query_plans.txt`, `chat_schema.sql` (2) | +50 −3, +3, +4 −1 | generated and hand-edited | check the plans |
|
||||
| iOS | `BadgeStore.swift` | +146 −38 | sale guard, hand-over, revoked finish, reports, Restore, Manage; dev URL | ★ money |
|
||||
| iOS | `ChatTypes.swift` | +72 −1 | decoders for unknown alert kinds and states | read: decoding compatibility |
|
||||
| iOS | `BadgesYourBadgeView.swift`, `BadgesCheckOrderView.swift`, `ChatListView.swift`, `BadgesHowLongView.swift` | +54 −8, +68 −1, +48 −30, +17 −3 | Subscription section; disclosure, links, Restore; banner; period | read |
|
||||
| iOS | `BadgesSupportSimplexView.swift` | +6 −26 | in-app choose restored (`ea359edb6`) | read for merging |
|
||||
| iOS | `AppAPITypes.swift`, `SimpleXAPI.swift`, `ChatModel.swift`, `APITypes.swift`, `spec/client/chat-list.md` | small | API; spec | skim |
|
||||
| Kotlin | `BadgeStore.kt` | +122 −47 | acknowledges one-time only; reconcile; reports; Restore; dev URL | ★ money |
|
||||
| Kotlin | `android/src/google/…/PlayStore.kt` | +34 −11 | subscription query; base-plan offer; finish; Manage | ★ money |
|
||||
| Kotlin | `ChatModel.kt` | +82 −10 | serializers for unknown alert kinds and states | read: decoding compatibility |
|
||||
| Kotlin | `BadgesYourBadgeView.kt`, `BadgesCheckOrderView.kt`, `ChatListView.kt`, `OnboardingCards.kt`, `BadgesHowLongView.kt` | +81 −4, +67, +42 −23, +13 −8, +8 −5 | as on iOS | read |
|
||||
| Kotlin | `BadgesSupportSimplexView.kt` | +11 −36 | in-app choose restored (`ea359edb6`) | read for merging |
|
||||
| Kotlin | `SimpleXAPI.kt`, `strings.xml`, foss `PlayStore.kt`, `SimplexApp.kt`, `Platform.kt`, specs (2) | small to +39 | API, strings, stubs | skim |
|
||||
| Docs | `badges-rpc.md`, `badges-rpc.schema.json`, service `README.md`, two `.ini.example`, bot API docs, TS and Python types | small | wire and config docs; generated types | read `badges-rpc.md` |
|
||||
| Tests | `BotTests.hs`, `StoreVerifierTests.hs`, `BadgeTests.hs`, fakes, `ConfigTests.hs`, `WebTests.hs` | +1,652 −35, +486 −15, +241 −12, +264 −15, +35 −1, +45 −3 | §12 | spot-check |
|
||||
@@ -1,5 +1,7 @@
|
||||
# Plan: store subscription renewals for supporter badges
|
||||
|
||||
For reviewers: a summary of the code as built in PR #7716, with diagrams, is in [`2026-10-13-badge-subscription-renewals-sketches/summary.md`](2026-10-13-badge-subscription-renewals-sketches/summary.md). Where it and this plan differ, the summary describes the code.
|
||||
|
||||
Run: 001-badge-subscriptions · Base branch: `f/badge-subscriptions`, cut from `badge-in-app-checkout` once `f/badge-store-verifiers` is merged into it · Repos: simplex-chat-2 · Estimated diff: ~1700 changed lines (~900 code and docs, ~800 tests), one PR in eight slices
|
||||
|
||||
This run makes App Store and Google Play subscriptions renew badges: in the badge service, including its real connections to Apple's App Store Server API and Google Play's subscriptions API, and in the shared app core. It changes no iOS or Android screens, so nothing here puts a subscription on sale.
|
||||
|
||||