第拾壱話
In-app purchases
Consumables, non-consumables and subscriptions through the App Store, Google Play and the Microsoft Store, transactions, restoring, verifying on a server, and testing.
asuka/sdk/purchase sells in the app, through each platform's own store:
consumables (coins, a hint), non-consumables (a level, "Pro" for life),
subscriptions that renew, and those that don't. It covers what
react-native-iap, expo-iap and RevenueCat's SDK do on the device, with
the stores' current APIs:
| Platform | Store | API |
|---|---|---|
| iOS, macOS | the App Store | StoreKit 2 |
| Android | Google Play | the Play Billing Library 9 |
| Windows | the Microsoft Store | Windows.Services.Store, for an app installed from the Store |
| The web | Google Play | the Digital Goods API and Payment Request, in a Trusted Web Activity |
| Linux | none |
Where there's no store (Linux, a browser without the Digital Goods API, a
Windows app that wasn't installed from the Store, the player in
asuka dev), purchase.Available() is false and the rest fail with
purchase.ErrNotAvailable.
Products
An Item is a product the app sells: its ID in the store and its kind.
Play doesn't know a consumable from a non-consumable, so the app says.
var (
coins = purchase.Item{ID: "coins_100", Kind: purchase.Consumable}
pro = purchase.Item{ID: "pro", Kind: purchase.NonConsumable}
plus = purchase.Item{ID: "plus_monthly", Kind: purchase.AutoRenewable}
)
products, err := purchase.Products(coins, pro, plus)The calls wait for the store, so make them off the main thread (in a
goroutine, with ui.Post for what they change). Products answers
those the store sells, in the user's country and language; the others
are left out. Each Product has its Title, Description, Price for
display ("$4.99", "4,99 €") and PriceMicros and Currency for
arithmetic. A subscription's Subscription has its group, its level in
the group, its period and its offers: Apple's introductory, promotional
and win-back offers, Play's base plans and the offers on them (each
with its phases: a free trial, a lower price for some periods, the
regular price). IntroEligible says whether the user can still have
the introductory offer.
The IDs are App Store Connect's product IDs, Play Console's, and on
Windows each add-on's product ID in Partner Center (its
InAppOfferToken) or its Store ID.
Buying
r, err := purchase.Buy(coins, purchase.Options{AccountToken: userUUID})
switch {
case err != nil:
// purchase.ErrNotAllowed, ErrUnknownProduct, ErrAlreadyOwned...
case r.Status == purchase.Bought:
deliver(r.Transaction)
r.Transaction.Finish()
case r.Status == purchase.PendingApproval:
// Ask to Buy, a payment Play waits for: Observe gives it later
case r.Status == purchase.Cancelled:
}Buy shows the store's sheet and answers once the user has bought,
cancelled, or the purchase waits for approval. Options has:
Quantity: a consumable's (Apple's; on Play the user picks it, when multi-quantity is on in Play Console).AccountToken: ties the purchase to the app's user. Use a UUID: Apple'sappAccountTokenmust be one, Play takes it as itsobfuscatedAccountId. It comes back in the transaction and in the stores' server notifications. Never an email or a name.ProfileTokenis Play'sobfuscatedProfileId.Offer: one of the product's offers. Play needs one for a subscription; without it,Buytakes the first base plan. An Apple promotional offer also needsSignature, which the app's server signs with its In-App Purchase key (the key's ID, a nonce, the signature and its time; or, from iOS 18.4, a JWS).Replace: the Play subscription this one replaces, an upgrade or a downgrade, and how (WithTimeProration,ChargeProratedPrice,ChargeFullPrice,WithoutProration,Deferred). Apple moves a subscriber within a subscription group by itself.Personalized: Play's flag for a personalized price, which the EU asks apps to show.
Transactions
A Transaction is a purchase: its ID, ProductID, Kind, State
(Purchased, Pending, Revoked), Quantity, Date, a
subscription's Expires, and when one was revoked, Revoked and why.
Deliver what a transaction sold, then Finish it. Until it's finished,
the store gives it again at each start. StoreKit keeps it unfinished,
and Play refunds a purchase that isn't acknowledged within three days.
Finish consumes a Consumable (Play's consumeAsync, the Microsoft
Store's fulfillment), so the user can buy it again; for the others it's
StoreKit's finish and Play's acknowledgement. A Pending transaction
can't be finished.
Observe gives everything that happens outside a Buy:
stop := purchase.Observe(func(e purchase.Event) {
switch e.Type {
case purchase.TransactionUpdated:
t := e.Transaction
switch t.State {
case purchase.Purchased:
deliver(t)
go t.Finish()
case purchase.Revoked:
takeBack(t)
}
case purchase.StorefrontChanged:
// prices are another country's: ask for the products again
}
})That's a pending purchase approved or paid, a purchase made outside the
app (an offer code redeemed in the store, a purchase on another device),
a subscription renewed, a purchase refunded or revoked, and those left
unfinished from before. The host listens from the app's start (StoreKit's
Transaction.updates, Play's PurchasesUpdatedListener and the
unacknowledged purchases it finds as the app starts and comes back to
the front), and keeps what comes before the first observer for it, so
none is lost. Observe as the app starts, in its root component.
Call purchase.Declare(coins, pro, plus) before you observe, so that a
consumable left unfinished from before is consumed when you finish it:
Play doesn't say which kind it is. Products and Buy declare their
items too.
What each store tells the device differs:
- Apple's says everything: renewals are transactions, refunds are
transactions with
Revokedset. - Play's client sees no renewals (a subscription keeps its purchase
token), no refunds and no expiries: they're in
CurrentEntitlementsas they happen, and your server hears them through Play's Real-time developer notifications. - The Microsoft Store says when the user's licenses change: a new one is a transaction, a durable's that's gone is revoked.
Entitlements and restoring
CurrentEntitlements() is what the user owns now: non-consumables,
subscriptions in their period or grace period, and Apple's non-renewing
subscriptions once finished (the app decides how long those last).
Entitlements.Has(id) checks one.
In a component, purchase.Use() is the entitlements as a signal. It's
asked for as the component mounts, and again after each Buy, Finish
and Restore and each transaction the app's Observe gets:
owned := purchase.Use()
ui.Show(signal.Func(func() bool { return owned.Get().Has(pro.ID) }), proFeatures)Restore() asks the store again and answers the entitlements. On iOS
it's AppStore.sync, which asks the user to sign in, so call it only
from a "Restore purchases" button, which App Review expects an app with
non-consumables or subscriptions to have.
Subscriptions
SubscriptionStatuses(group) has each subscription of a group (Apple's
subscription group ID; on Play, the product ID): Subscribed,
Expired, BillingRetry (the payment failed and the store retries;
Play's account hold), GracePeriod (still entitled while it retries) or
SubscriptionRevoked. It also says whether it renews (WillRenew), as
what (RenewsTo, after an upgrade or a downgrade), when, and why it
expired. RenewalJWS is Apple's signed renewal info.
ManageSubscriptions(productID) shows the store's page of the user's
subscriptions, where they cancel or change one: iOS's sheet, Play's
subscription center (at that product), the App Store's or Microsoft's
account page on the desktop. RedeemCode() shows Apple's offer code
sheet (iOS 16, macOS 15) or opens Play's redeem page; what the code buys
comes through Observe. RequestRefund(t) shows Apple's refund request
sheet. Play has no such sheet: refunds are the developer's, from Play
Console or its server.
CurrentStorefront() is the store's country, which prices and products
follow.
Verifying on a server
A server checks a purchase with the store before it grants what was
sold. Transaction.Verification has what each store's API takes:
- Apple:
JWS, the transaction signed by the App Store. Verify it with the App Store Server Library (itsSignedDataVerifier), or check its certificate chain up to Apple's root yourself.Verifiedsays whether StoreKit already checked it on the device. - Google Play:
Token, the purchase token, withPackageName, for the Google Play Developer API (purchases.productsv2,purchases.subscriptionsv2).OriginalJSONandSignatureare the purchase signed with the app's license key. - Windows:
Token, the add-on's Store ID, for the Microsoft Store collections API.
With a server that grants purchases, the app can deliver what the server says, and finish the transaction once it has.
Testing your purchases
The stores sell only to an app they know. On iOS, asuka dev builds an
app that imports asuka/sdk/purchase as itself, without hot reload's
player, so a save reinstalls it. On Android, Play sells only to a build
installed from one of its tracks, so purchases are unavailable in
asuka dev, and it says so.
The iOS simulator. Make a StoreKit configuration file in Xcode (File ›
New › File › StoreKit Configuration File; or sync it from App Store
Connect), with the products, and name it in asuka.json:
{
"ios": { "storekit": "Products.storekit" }
}asuka dev's build for the simulator carries it, and as asuka dev
launches it, sells from it, as an app run from Xcode with the file in
its scheme does. Prices, Ask to Buy, failed transactions and faster
renewals come from the file:
asuka dev --platform ios examples/store # built, rebuilt on save
asuka dev --platform ios --once examples/store # built and launched onceLaunched another way (from the home screen), the app can't load the
file itself (its session needs Xcode's XCTest, which asuka dev gives
it as a test run has it); StoreKit goes on with the configuration
asuka dev last gave it for the app. Other builds never
have the file: they buy from the App Store's sandbox. Sign in with a sandbox account (Settings › Developer on the
device, or as you buy), or install a TestFlight build.
Android. Play sells only to a build it has. Upload one to the
internal track (asuka publish --platform android --track internal),
add the products in Play Console, add your Google account to the
license testers (Play Console › Setup › License testing), and install
the build from the track's link. License testers are never charged, and
renewals come faster.
The Mac. As on iOS, with a sandbox account; a Mac build from
asuka build has StoreKit 2 in its Swift library.
Windows. The Store sells only to the app it installed: associate the
app with the Store in Partner Center and install it from there (a
package flight for testing). An app run with asuka dev has no store.
In go test. host/fake's Store sells what a test gives it, and
keeps the transactions as StoreKit does: unfinished until the app
finishes them, given again until then.
var store *fake.Store
a := uitest.Mount(t, Store, uitest.Setup(func(h *fake.Host) {
store = h.Store()
store.Products = []fake.StoreProduct{
{ID: "coins_100", Kind: "consumable", Title: "100 Coins", Price: "$0.99", PriceMicros: 990000},
{ID: "plus_monthly", Kind: "autoRenewable", Title: "Plus", Price: "$4.99", Period: "P1M", Trial: "P1W"},
}
}))
a.Tap("$0.99")
a.WaitFor("100 coins")
store.Outcome = fake.PurchasePending // Ask to Buy
a.Tap("$0.99")
store.Approve("coins_100") // the parent approves: Observe gets it
store.Renew("plus_monthly") // a renewal
store.Refund(id) // a refund
store.Deliver("pro") // bought on another deviceOutcome also has PurchaseCancelled and PurchaseFailed;
Unavailable and NotAllowed are a store that isn't there and payments
turned off; Unfinished lists what the app hasn't finished.
Setting up the app
- iOS and macOS. The In-App Purchase capability is on for every App ID, and it needs no entitlement: nothing to add. Agree to the Paid Apps Agreement in App Store Connect, and add the products there.
- Android. An app that imports
asuka/sdk/purchaseis built with Play Billing and thecom.android.vending.BILLINGpermission; others aren't. - Windows. Add the add-ons in Partner Center, to an app that's packaged (MSIX) and associated with the Store.
- The web. Package the site as a Trusted Web Activity with Play
Billing enabled (Bubblewrap's
playBilling), and add the products in Play Console. The Digital Goods API acknowledges nothing for a non-consumable: acknowledge it from your server, with the Play Developer API, within three days.
examples/store sells 100 coins, Pro and a monthly
subscription with a free week, with a StoreKit configuration file and
tests on fake.Store.