From e729a085b3c619bc900e615cb3d40fdc969c4dfa Mon Sep 17 00:00:00 2001 From: amy-reeve Date: Thu, 5 Nov 2026 12:19:34 +0000 Subject: [PATCH] chore: publish 1 skill (implement-in-app-purchases) --- skills/implement-in-app-purchases/SKILL.md | 204 +++--- .../references/api-notes.md | 566 ++-------------- .../references/codeless-catalog.md | 204 +----- .../references/convert-adapty.md | 2 + .../references/convert-revenuecat.md | 2 + .../references/migration-v4-to-v5.md | 220 +------ .../references/path-add-iap-to-new-project.md | 17 +- .../path-convert-native-google-billing.md | 82 +-- .../path-convert-native-storekit.md | 133 +--- .../references/path-implement-iap-d2c.md | 616 +++--------------- .../references/platform-notes.md | 223 ++----- .../references/pre-check.md | 2 + 12 files changed, 392 insertions(+), 1879 deletions(-) diff --git a/skills/implement-in-app-purchases/SKILL.md b/skills/implement-in-app-purchases/SKILL.md index b39a52c..939f592 100644 --- a/skills/implement-in-app-purchases/SKILL.md +++ b/skills/implement-in-app-purchases/SKILL.md @@ -10,148 +10,156 @@ Package: `com.unity.purchasing` **Unity IAP has its own initialization path** via `UnityIAPServices.StoreController()` → `store.Connect()`. It does not require `UnityServices.InitializeAsync()`, but they can coexist if your project uses other UGS services. If Analytics is present and `InitializeAsync()` is called, IAP will automatically send transaction events. -## Before You Start +## Important -**Always read [references/pre-check.md](references/pre-check.md) first.** It scans the project for third-party IAP packages, native Google Billing, and existing Unity IAP versions, then routes to the correct path. Do not read any other reference file or make any changes until routing is resolved. +- Use the unity cli (`unity command --format json`) and the `unity-cli` and `unity-pipeline` skills for each step. +- Before making changes, establish a baseline of the current behavior so you can tell whether a later result is actually caused by your change. +- Create a restore point you can roll back to if your changes fail. +- Do only what's asked. Don't change unrelated assets or files. +- Never leave verification code in the user's script. Never build logging or detection they didn't ask for. See [Final step](#final-step). +- Avoid long explanations. -## Detailed References +## Before You Start -- **Project scan and path routing (read first):** See [references/pre-check.md](references/pre-check.md) -- **API signatures & code examples:** See [references/api-notes.md](references/api-notes.md) -- **Platform extensions (Apple, Google):** See [references/platform-notes.md](references/platform-notes.md) -- **Editing `IAPProductCatalog.json` (schema, decimal serialization, refresh):** See [references/codeless-catalog.md](references/codeless-catalog.md) -- **v4 → v5 migration:** See [references/migration-v4-to-v5.md](references/migration-v4-to-v5.md) -- **Convert native Google BillingClient to Unity IAP 5:** See [references/path-convert-native-google-billing.md](references/path-convert-native-google-billing.md) -- **Convert native iOS StoreKit plugin to Unity IAP 5:** See [references/path-convert-native-storekit.md](references/path-convert-native-storekit.md) -- **Convert Essential Kit billing to Unity IAP 5:** See [references/convert-essentialkit.md](references/convert-essentialkit.md) -- **UniPay (FLOBUK) — assessment and guidance:** See [references/convert-unipay.md](references/convert-unipay.md) -- **RevenueCat — conversion assessment and guidance:** See [references/convert-revenuecat.md](references/convert-revenuecat.md) -- **Adapty — conversion assessment and guidance:** See [references/convert-adapty.md](references/convert-adapty.md) -- **Add Unity IAP 5 to a project with no existing IAP:** See [references/path-add-iap-to-new-project.md](references/path-add-iap-to-new-project.md) -- **Implement IAP D2C Capabilities (third-party payment provider — Stripe/Coda, requires v5.4+):** See [references/path-implement-iap-d2c.md](references/path-implement-iap-d2c.md) +**Always read [references/pre-check.md](references/pre-check.md) first.** It scans the project for third-party IAP packages, native Google Billing, and existing Unity IAP versions, then routes to the correct path. Do not read any other reference file or make any changes until routing is resolved. -Read reference files on demand — only when you need specific API signatures, platform extension details, or migration mappings. +## Full topic map -## Initialization Flow +Before you go forward with a workflow or diagnosis: -1. Obtain `StoreController` via `UnityIAPServices.StoreController()` (or individual services via `DefaultStore()`, `DefaultProduct()`, `DefaultPurchase()`) -2. Subscribe to **all** required events (see Required Event Subscriptions below) **before** calling `Connect()` -3. `await store.Connect()` to connect to the platform store -4. On `OnStoreConnected`, call `store.FetchProducts(List)` to load the catalog -5. On `OnProductsFetched`, products are ready for display and purchase +1. Open and read the [documentation page(s)](#full-topic-map) that cover the specific subject related to what you intend to do or fix and check if you've missed anything. +2. Name the file(s) you read in your response. -Use `Awake()` for initialization — ensures IAP is ready before other `Start()` methods. +This is because your training knowledge about Unity might be out of date, incorrect, or for the wrong Unity version. -Product types: `ProductType.Consumable`, `ProductType.NonConsumable`, `ProductType.Subscription`. +Prefer `WebFetch` over `WebSearch` — faster and lands on the exact reference. Only fetch what you need. -## Fetching Products +In docs.unity3d.com URLs, replace `` with the major.minor of `com.unity.purchasing` in `Packages/packages-lock.json` before fetching. The IAP manual pages on docs.unity.com aren't versioned, so use those URLs as written. -Define products as `List` and pass to `store.FetchProducts()`. Use `StoreSpecificIds` when product IDs differ across Apple/Google stores. For complex catalogs, use `CatalogProvider` to manage product sets and store-specific IDs. +- [Unity In-App Purchasing](https://docs.unity.com/en-us/iap.md): overview of the IAP manual and its sections. +- [Initialize Unity IAP](https://docs.unity.com/en-us/iap/set-up-in-app-purchasing.md): install and initialize, with `StoreController` and D2C (`PaymentProvider.Name`, `RemoteCatalogProvider`) samples. +- [Create your catalog programmatically](https://docs.unity.com/en-us/iap/code-catalog.md): define products with `CatalogProvider` and store-specific IDs. +- [Purchases](https://docs.unity.com/en-us/iap/purchases.md): process pending purchases, confirm them, handle failed confirmations, and determine purchase status. +- [Restore purchases](https://docs.unity.com/en-us/iap/restore-purchases.md): restore behavior on Google Play and Apple platforms. +- [Receipt validation](https://docs.unity.com/en-us/iap/receipt-validation.md): local and server-side validation, and the receipt structure. +- [SubscriptionInfo class reference](https://docs.unity.com/en-us/iap/subscriptioninfo-class-reference.md): every `SubscriptionInfo` method and its per-store behavior. +- [Apple App Store](https://docs.unity.com/en-us/iap/apple-store.md): configure IAP for iOS, macOS and tvOS. +- [Use Google Play billing extensions](https://docs.unity.com/en-us/iap/use-google-play-billing-extensions.md): obfuscated IDs and deferred subscription changes. +- [Set up Codeless IAP](https://docs.unity.com/en-us/iap/codeless-iap.md): the IAP Catalog window, IAP buttons and automatic initialization. +- [Upgrade from IAP v4 to v5](https://docs.unity.com/en-us/iap/upgrade-to-iap-v5.md): the official v4 to v5 API mapping. +- [Integrate D2C payment providers workflow](https://docs.unity.com/en-us/iap/payment-providers/workflow.md): the D2C setup workflow for Stripe and Coda. +- [Initiate a purchase](https://docs.unity.com/en-us/iap/payment-providers/initiate-purchase.md): D2C eligibility checks and the Purchase Options UI. +- [Deep link success URL example](https://docs.unity.com/en-us/iap/payment-providers/success-deep-link.md): redirect players back to the app after D2C checkout. +- [Test mode for D2C payments](https://docs.unity.com/en-us/iap/payment-providers/test-mode.md): simulate D2C purchases before a provider account is connected. +- [Comply with Apple and Google external purchase requirements](https://docs.unity.com/en-us/iap/payment-providers/external-purchase-compliance.md): compliance checks, checkout URLs and transaction tokens. +- [Add a routing rule](https://docs.unity.com/en-us/iap/payment-providers/routing.md): choose which payment providers players see. +- [Create your Remote Catalog](https://docs.unity.com/en-us/iap/payment-providers/configure-remote-catalog.md): `.ucat` and `.catalog.csv` catalog items and deployment. -| Method | Behavior | -|---|---| -| `GetProducts()` | Returns the **cached** product list (synchronous, stale if `FetchProducts` not called) | -| `FetchProducts()` | Queries the **store** for fresh pricing/availability and updates the cache | -| `GetProductById(id)` | Returns a single cached product by ID | +### API reference -These are NOT interchangeable. Always call `FetchProducts()` first before relying on `GetProducts()`. +The API is `UnityEngine.Purchasing`, part of the `com.unity.purchasing` package. It is versioned by package version, not Unity version, so check the project's installed package version before relying on a specific class or method. -## Two-Step Purchase Flow +1. Use the `unity-cli` skill to check the installed package version. +2. Fetch and cache `https://docs.unity3d.com/Packages/com.unity.purchasing@/xrefmap.yml`, replacing `` with the installed package's major.minor. This file maps every public API member's fully qualified name to its docs page `href`. Append the `href` to the same base URL, so `href: api/UnityEngine.Purchasing.StoreController.html` becomes `https://docs.unity3d.com/Packages/com.unity.purchasing@/api/UnityEngine.Purchasing.StoreController.html`. +3. Grep the yml file locally for a class or method instead of refetching per lookup. Don't read the whole file. -IAP v5 uses a mandatory two-step flow: **Pending → Confirm**. +Always fetch the version matching the installed package rather than relying on training knowledge, since APIs are frequently marked obsolete between major versions (e.g. the v4 `UnityPurchasing` class is obsolete in v5). -1. `store.PurchaseProduct(product)` — initiates the platform purchase dialog -2. `OnPurchasePending` fires — you receive a `PendingOrder` -3. Validate the receipt, grant content to the player -4. `store.ConfirmPurchase(pendingOrder)` — finalizes the transaction -5. `OnPurchaseConfirmed` fires — receives `Order` base type; pattern-match `ConfirmedOrder` (success) vs `FailedOrder` (confirmation failed) +Most-used pages: -**You MUST call `ConfirmPurchase(pendingOrder)` after granting content.** Unconfirmed purchases are re-delivered on next app launch to prevent lost purchases. +- [StoreController API](https://docs.unity3d.com/Packages/com.unity.purchasing@/api/UnityEngine.Purchasing.StoreController.html): every `StoreController` method, event and property, with signatures. +- [EntitlementStatus API](https://docs.unity3d.com/Packages/com.unity.purchasing@/api/UnityEngine.Purchasing.EntitlementStatus.html): the entitlement status values. -**De-duplication:** `OnPurchasePending` may fire multiple times for the same purchase (e.g., app restart before confirmation). Always check if content was already granted. +## Detailed References -**Consumables:** Confirmed consumable purchases are NOT returned by `FetchPurchases`. Track consumable grants yourself (e.g., in Cloud Save or Economy). +- **Project scan and path routing (read first):** See [references/pre-check.md](references/pre-check.md) +- **Common v5 API mistakes and where to read each API topic:** See [references/api-notes.md](references/api-notes.md) +- **Platform extension gotchas (Apple, Google):** See [references/platform-notes.md](references/platform-notes.md) +- **Editing `IAPProductCatalog.json` (schema, decimal serialization, refresh):** See [references/codeless-catalog.md](references/codeless-catalog.md) +- **v4 → v5 migration:** See [references/migration-v4-to-v5.md](references/migration-v4-to-v5.md) +- **Convert native Google BillingClient to Unity IAP 5:** See [references/path-convert-native-google-billing.md](references/path-convert-native-google-billing.md) +- **Convert native iOS StoreKit plugin to Unity IAP 5:** See [references/path-convert-native-storekit.md](references/path-convert-native-storekit.md) +- **Convert Essential Kit billing to Unity IAP 5:** See [references/convert-essentialkit.md](references/convert-essentialkit.md) +- **UniPay (FLOBUK) — assessment and guidance:** See [references/convert-unipay.md](references/convert-unipay.md) +- **RevenueCat — conversion assessment and guidance:** See [references/convert-revenuecat.md](references/convert-revenuecat.md) +- **Adapty — conversion assessment and guidance:** See [references/convert-adapty.md](references/convert-adapty.md) +- **Add Unity IAP 5 to a project with no existing IAP:** See [references/path-add-iap-to-new-project.md](references/path-add-iap-to-new-project.md) +- **Implement IAP D2C Capabilities (third-party payment provider — Stripe/Coda, requires v5.4+):** See [references/path-implement-iap-d2c.md](references/path-implement-iap-d2c.md) -**Deferred purchases:** `OnPurchaseDeferred` fires for Ask-to-Buy (iOS) and Google Play deferred purchases. Do NOT grant content — wait for `OnPurchasePending` when approved. +Read reference files on demand — only when you need the API gotchas, platform extension details, or migration mappings. -## Restore Transactions +## Core rules -`store.RestoreTransactions(callback)` re-delivers non-consumable and subscription purchases. Each restored purchase triggers `OnPurchasePending`. +Read the linked page for the flow and sample code before writing each part, and reread it if the code fails. The rules below are the mistakes agents make that the pages don't stop them making. -Required on iOS for Apple App Store compliance — add a "Restore Purchases" button. +### Initialization -Apple non-renewable subscriptions **cannot be restored** via `RestoreTransactions`. Track these server-side. +Read [Initialize Unity IAP](https://docs.unity.com/en-us/iap/set-up-in-app-purchasing.md) and [Create your catalog programmatically](https://docs.unity.com/en-us/iap/code-catalog.md). -## Receipt Validation +- Subscribe to every event **before** `await store.Connect()`, because pending purchases from a previous session can fire as soon as the store connects. Initialize in `Awake()` so IAP is ready before other `Start()` methods. +- Subscribe to the failure event of every call (`OnStoreDisconnected`, `OnProductsFetchFailed`, `OnPurchasesFetchFailed`, `OnPurchaseFailed`) and to `OnPurchaseDeferred`. Missing failure events cause runtime warnings, and a missing `OnPurchaseDeferred` silently drops Ask-to-Buy and Google deferred purchases. +- `GetProducts()` and `GetPurchases()` return caches. Call `FetchProducts()` / `FetchPurchases()` first; `FetchPurchases()` overwrites the whole cached list. -| Platform | Approach | -|---|---| -| **Google Play** | `CrossPlatformValidator` with `GooglePlayTangle.Data()` — local validation supported | -| **Apple (StoreKit 2)** | Local validation is a **no-op**. Use `order.Info.Apple?.jwsRepresentation` for server-side validation | +### Purchases -Generate tangle data via **Services > In-App Purchasing > Receipt Validation Obfuscator** in the Unity Editor. +Read [Purchases](https://docs.unity.com/en-us/iap/purchases.md). -## Entitlement Checking +- `store.PurchaseProduct(product)` leads to `OnPurchasePending` with a `PendingOrder`. Grant the content, save it, then call `store.ConfirmPurchase(pendingOrder)`, because unconfirmed purchases are re-delivered on the next launch. +- `OnPurchasePending` can fire more than once for the same purchase (for example, after a restart before confirmation). Check whether the content was already granted before granting it. +- Confirmed consumables are NOT returned by `FetchPurchases()`. Track consumable grants yourself, for example in Cloud Save or Economy. +- On `OnPurchaseDeferred`, show a pending state and don't grant. The purchase arrives later through `OnPurchasePending` if approved. -Use when you don't have the `Order` and want to know the status of a specific product (replaces v4's `product.hasReceipt`). If you already have the `Order`, check its type instead: `PendingOrder` maps to `EntitledUntilConsumed` (consumables) or `EntitledButNotFinished` (non-consumables/subscriptions), `ConfirmedOrder` maps to `FullyEntitled`. +### Restore -Call `store.CheckEntitlement(product)` and handle `store.OnCheckEntitlement`. Check `entitlement.Status == EntitlementStatus.FullyEntitled`. +Read [Restore purchases](https://docs.unity.com/en-us/iap/restore-purchases.md). -`EntitlementStatus` values: `FullyEntitled`, `EntitledUntilConsumed`, `EntitledButNotFinished`, `NotEntitled`, `Unknown`. +- `store.RestoreTransactions(callback)` re-delivers non-consumables and renewable subscriptions, each through `OnPurchasePending`. Add a "Restore Purchases" button on iOS, because Apple's review guidelines require one. +- Apple non-renewable subscriptions can't be restored through `RestoreTransactions`, so track them server-side. -## Fetch Existing Purchases +### Receipt validation -`store.FetchPurchases()` retrieves all current purchases from the store. Useful at app startup. +Read [Receipt validation](https://docs.unity.com/en-us/iap/receipt-validation.md). -| Method | Behavior | -|---|---| -| `GetPurchases()` | Returns the **cached** purchase list | -| `FetchPurchases()` | Queries the **store** for current purchases and **overwrites** the cached list | +- Google Play: validate locally with `CrossPlatformValidator(GooglePlayTangle.Data(), Application.identifier)`. Generate the tangle with **Services > In-App Purchasing > Receipt Validation Obfuscator**. +- Apple StoreKit 2: local validation is a no-op. Send `order.Info.Apple?.jwsRepresentation` to a server for validation. -`FetchPurchases()` replaces the entire cached list on each call. Only non-consumables and subscriptions are re-fetched — confirmed consumables are not returned (see Two-Step Purchase Flow above). +### Ownership and subscriptions -## Subscription Info +Read the [EntitlementStatus API](https://docs.unity3d.com/Packages/com.unity.purchasing@/api/UnityEngine.Purchasing.EntitlementStatus.html) and the [SubscriptionInfo class reference](https://docs.unity.com/en-us/iap/subscriptioninfo-class-reference.md). -Subscription info is on `IPurchasedProductInfo`, accessed via `order.Info.PurchasedProductInfo` — **NOT on `CartItem`** (`CartItem` only has `Product` and `Quantity`). +- To check whether the player owns a product, call `store.CheckEntitlement(product)`, handle `store.OnCheckEntitlement`, and compare with `EntitlementStatus.FullyEntitled`. This replaces v4's `product.hasReceipt`. +- Subscription info is on `order.Info.PurchasedProductInfo`, NOT on `CartItem`. `IsSubscribed()` returns `Result`, not `bool`, so compare with `== Result.True`. -`IsSubscribed()` returns `Result` enum (`True`/`False`/`Unsupported`), NOT `bool`. Use `== Result.True` for null-safe comparison. +### Compile errors -## Required Event Subscriptions +- `ProductFetchFailed` has `.FailureReason` and `.FailedFetchProducts`, not `.Message`, and `StoreConnectionFailureDescription` has `.Message`, not `.reason`. A wrong name causes CS1061. The full table and the other common v5 mistakes are in [api-notes.md](references/api-notes.md). -**Always subscribe to BOTH success and failure events.** Not subscribing to failure events generates runtime warnings. +## Final step -| Call | Success Event | Failure Event (REQUIRED) | -|---|---|---| -| `FetchProducts()` | `OnProductsFetched` | `OnProductsFetchFailed` | -| `FetchPurchases()` | `OnPurchasesFetched` | `OnPurchasesFetchFailed` | -| `Connect()` | `OnStoreConnected` | `OnStoreDisconnected` | -| `PurchaseProduct()` | `OnPurchasePending` | `OnPurchaseFailed` | -| `CheckEntitlement()` | `OnCheckEntitlement` | — | +Do the following checks: -**Always subscribe to `OnPurchaseDeferred`** — fires for Ask-to-Buy (iOS) and Google Play deferred purchases. Not subscribing silently drops deferred purchases. +1. Check the project has zero console errors. Use `console_status` for counts. +2. Stop there unless the user asked you to prove it works or you suspect a specific fault. +3. If you do enter Play mode, do it once only to chase a fault you already suspect. Read the world through `eval` rather than adding logging. Take no screenshots, because they take too much time. -Subscribe to events **BEFORE** calling `Connect()` — pending purchases from a previous session may fire immediately. +If a check fails, go back and reread the [docs pages](#full-topic-map) to find out what you missed. -## Failure Description Property Names +After writing code that uses this package, also read the code back and check it, because IAP code can compile cleanly and still lose or double-grant purchases: -These property names are NOT interchangeable — using the wrong one causes CS1061: +1. Confirm all API calls match the v5 signatures on the [API reference](#api-reference) pages for the installed version — do NOT use v4 legacy patterns (`IStoreListener`, `UnityPurchasing.Initialize`, `ConfigurationBuilder`). +2. Check the "Anti-Hallucination: Common v5 Mistakes" table in [api-notes.md](references/api-notes.md) — do NOT use `OnStoreConnectionFailed` (use `OnStoreDisconnected`), do NOT pass callbacks to `FetchProducts`/`FetchPurchases` (use events), do NOT use `product.receipt` (use `order.Info.Receipt`). +3. Check that all required events are subscribed **before** calling `Connect()` (see [Initialization](#initialization)). +4. Verify the two-step purchase flow: `OnPurchasePending` → grant content → `ConfirmPurchase(pendingOrder)`. +5. Confirm both success and failure events are subscribed for every async operation (`OnProductsFetched`/`OnProductsFetchFailed`, `OnStoreConnected`/`OnStoreDisconnected`, etc.). +6. If handling subscriptions, verify `IsSubscribed()` is compared with `== Result.True`, not cast to `bool`. +7. If updated files coexist with legacy versions in the same project, use a unique namespace (e.g., add `.Updated` suffix) to avoid CS0101/CS0111 compilation errors. +8. If the project subscribes to `OnAuthAccountChanged` (v5.4+): verify the handler re-fetches products and purchases from scratch — Unity IAP clears both caches before raising this event. Do not read `GetProducts()` or `GetPurchases()` inside the handler. -| Type | Field | Property | NOT | -|---|---|---|---| -| `StoreConnectionFailureDescription` | `.message` | `.Message` | ~~`.reason`~~ | -| `ProductFetchFailed` | — | `.FailureReason`, `.FailedFetchProducts` | ~~`.Message`~~ | -| `FailedOrder` | — | `.FailureReason`, `.Details` | — | -| `PurchasesFetchFailureDescription` | `.message`, `.failureReason` | `.Message`, `.FailureReason` | — | +## Final report -## Validation +Report the following to the user, in this order: -After writing code that uses this package: -1. Verify the project compiles without errors. -2. Confirm all API calls match the v5 signatures in [api-notes.md](references/api-notes.md) — do NOT use v4 legacy patterns (`IStoreListener`, `UnityPurchasing.Initialize`, `ConfigurationBuilder`). -3. Check the "Anti-Hallucination: Common v5 Mistakes" table in api-notes.md — do NOT use `OnStoreConnectionFailed` (use `OnStoreDisconnected`), do NOT pass callbacks to `FetchProducts`/`FetchPurchases` (use events), do NOT use `product.receipt` (use `order.Info.Receipt`). -4. Check that all required events are subscribed **before** calling `Connect()` (see Required Event Subscriptions table above). -5. Verify the two-step purchase flow: `OnPurchasePending` → grant content → `ConfirmPurchase(pendingOrder)`. -6. Confirm both success and failure events are subscribed for every async operation (`OnProductsFetched`/`OnProductsFetchFailed`, `OnStoreConnected`/`OnStoreDisconnected`, etc.). -7. If handling subscriptions, verify `IsSubscribed()` is compared with `== Result.True`, not cast to `bool`. -8. If updated files coexist with legacy versions in the same project, use a unique namespace (e.g., add `.Updated` suffix) to avoid CS0101/CS0111 compilation errors. -9. If the project subscribes to `OnAuthAccountChanged` (v5.4+): verify the handler re-fetches products and purchases from scratch — Unity IAP clears both caches before raising this event. Do not read `GetProducts()` or `GetPurchases()` inside the handler. +1. What changed: each file you changed, with the old and new API calls or values where they apply (for example, `IStoreListener.ProcessPurchase` → `OnPurchasePending`). +2. How you verified it: the outcome of each Final step check, including the console error count and anything you read through `eval` in Play mode. +3. Incidental side effects: anything you touched beyond the requested change, such as packages you installed or upgraded, project or Services settings you changed, generated files (for example tangle classes or `IAPProductCatalog.json`), and temporary files you created or removed. +4. Follow-up the user must do, for example product setup in App Store Connect or the Google Play Console, and sandbox testing on a device. Include the manual steps that the path reference file lists. diff --git a/skills/implement-in-app-purchases/references/api-notes.md b/skills/implement-in-app-purchases/references/api-notes.md index a751c83..f2c15e8 100644 --- a/skills/implement-in-app-purchases/references/api-notes.md +++ b/skills/implement-in-app-purchases/references/api-notes.md @@ -1,32 +1,22 @@ # Unity IAP API Notes +This file lists only the API mistakes agents commonly make with Unity IAP v5. It is not an API reference. For signatures, members and code samples, look the type up in the package's `xrefmap.yml` (see the skill's API reference section) and read its page, and read the matching manual page from the skill's Full topic map. Read the page before writing the code and again if it fails to compile, because your training data may describe v4 or an older v5 minor version. + ## Table of Contents - [Anti-Hallucination: v5 vs Legacy API](#anti-hallucination-v5-vs-legacy-api) -- [StoreController (v5) Key Members](#storecontroller-v5-key-members) -- [Alternative Service Access (DefaultStore / DefaultProduct / DefaultPurchase)](#alternative-service-access-defaultstore--defaultproduct--defaultpurchase) -- [CatalogProvider](#catalogprovider) -- [Initialization Example](#initialization-example) -- [Product Definitions](#product-definitions) - - [CatalogListings and uSku (v5.4+)](#cataloglistings-and-usku-v54) -- [Two-Step Purchase Flow](#two-step-purchase-flow) -- [Restore Transactions](#restore-transactions) -- [Entitlement Checking](#entitlement-checking) -- [Fetch Existing Purchases](#fetch-existing-purchases) -- [Receipt Validation](#receipt-validation) - - [IAppleOrderInfo Complete Member List](#iappleorderinfo-complete-member-list) -- [Extended Service Events (NOT on StoreController)](#extended-service-events-not-on-storecontroller) -- [AppleStoreExtendedProductService Key Members](#applestoreextendedproductservice-key-members) -- [Subscription Info Access Path](#subscription-info-access-path) -- [Event Subscription Rules](#event-subscription-rules) -- [Failure Description Property Names](#failure-description-property-names) -- [CrossPlatformValidator](#crossplatformvalidator) -- [Interface Hierarchy](#interface-hierarchy) -- [Template: Fetch Products](#template-fetch-products) +- [Anti-Hallucination: Common v5 Mistakes](#anti-hallucination-common-v5-mistakes) +- [Where to read each topic](#where-to-read-each-topic) +- [OnAuthAccountChanged clears the caches (v5.4+)](#onauthaccountchanged-clears-the-caches-v54) +- [Purchase flow gotchas](#purchase-flow-gotchas) +- [Subscription info access path](#subscription-info-access-path) +- [Platform extension gotchas](#platform-extension-gotchas) +- [Failure description property names](#failure-description-property-names) +- [Receipt validation gotchas](#receipt-validation-gotchas) ## Anti-Hallucination: v5 vs Legacy API -Unity IAP v5 is the current API. The legacy API (v4) is `[Obsolete]`. Do NOT use legacy patterns. +The v4 API is `[Obsolete]` in v5. Don't use legacy patterns, because they compile with warnings but bypass the v5 two-step flow. For the full mapping, read [Upgrade from IAP v4 to v5](https://docs.unity.com/en-us/iap/upgrade-to-iap-v5.md). | WRONG (Legacy v4) | CORRECT (v5) | |---|---| @@ -37,523 +27,79 @@ Unity IAP v5 is the current API. The legacy API (v4) is `[Obsolete]`. Do NOT use | `controller.ConfirmPendingPurchase(product)` | `store.ConfirmPurchase(pendingOrder)` | | `extensions.GetExtension()` | `store.AppleStoreExtendedService` / `store.AppleStoreExtendedPurchaseService` | | `extensions.GetExtension()` | `store.GooglePlayStoreExtendedService` / `store.GooglePlayStoreExtendedPurchaseService` | -| `ConfigurationBuilder.Instance(...)` | Not needed; use `ProductDefinition` list with `FetchProducts` | +| `ConfigurationBuilder.Instance(...)` | Not needed; pass a `ProductDefinition` list to `FetchProducts` | ## Anti-Hallucination: Common v5 Mistakes -These are NOT real APIs — do not use them: +These are NOT real APIs. Don't use them: | WRONG (does not exist) | CORRECT | |---|---| -| `store.OnStoreConnectionFailed` | `store.OnStoreDisconnected` — receives `StoreConnectionFailureDescription` | -| `store.FetchProducts(defs, successCb, failCb)` | `store.FetchProducts(List)` — subscribe to `OnProductsFetched`/`OnProductsFetchFailed` events separately | -| `store.FetchPurchases(successCb, failCb)` | `store.FetchPurchases()` — subscribe to `OnPurchasesFetched`/`OnPurchasesFetchFailed` events separately | -| `product.receipt` | `order.Info.Receipt` — receipt is on the order, not the product | +| `store.OnStoreConnectionFailed` | `store.OnStoreDisconnected`, which receives `StoreConnectionFailureDescription` | +| `store.FetchProducts(defs, successCb, failCb)` | `store.FetchProducts(List)`, then subscribe to `OnProductsFetched` / `OnProductsFetchFailed` separately | +| `store.FetchPurchases(successCb, failCb)` | `store.FetchPurchases()`, then subscribe to `OnPurchasesFetched` / `OnPurchasesFetchFailed` separately | +| `product.receipt` | `order.Info.Receipt`. The receipt is on the order, not the product | | `product.hasReceipt` | `store.CheckEntitlement(product)` + `OnCheckEntitlement` | -| `new SubscriptionManager(product, introJson)` | `order.Info.PurchasedProductInfo` — see Subscription Info Access Path | -| `pendingOrder.OrderInfo.Apple.jwsRepresentation` | `pendingOrder.Info.Apple?.jwsRepresentation` — note `Info` not `OrderInfo`, and null-check `?.` | - -## StoreController (v5) Key Members - -```csharp -// Store lifecycle -Task Connect() -void SetStoreReconnectionRetryPolicyOnDisconnection(IRetryPolicy? retryPolicy) -event Action? OnStoreConnected -event Action? OnStoreDisconnected - -// Products -void FetchProducts(List defs, IRetryPolicy? retryPolicy = null) -void FetchProductsWithNoRetries(List defs) -ReadOnlyObservableCollection GetProducts() -Product? GetProductById(string productId) -event Action>? OnProductsFetched -event Action? OnProductsFetchFailed - -// Purchasing -void PurchaseProduct(Product product) -void PurchaseProduct(string? productId) -void Purchase(ICart cart) -void ConfirmPurchase(PendingOrder order) -void FetchPurchases() -void CheckEntitlement(Product product) -void RestoreTransactions(Action? callback) -ReadOnlyObservableCollection GetPurchases() -void ProcessPendingOrdersOnPurchasesFetched(bool shouldProcess) - -// Purchase events -event Action? OnPurchasePending -event Action? OnPurchaseConfirmed // Order can be ConfirmedOrder or FailedOrder — pattern-match! -event Action? OnPurchaseFailed -event Action? OnPurchaseDeferred -event Action? OnPurchasesFetched -event Action? OnPurchasesFetchFailed -event Action? OnCheckEntitlement - -// Account change (v5.4+) -event Action? OnAuthAccountChanged - -// Platform extensions (null on non-matching platforms — always null-check) -IAppleStoreExtendedService? AppleStoreExtendedService { get; } -IGooglePlayStoreExtendedService? GooglePlayStoreExtendedService { get; } -IAppleStoreExtendedProductService? AppleStoreExtendedProductService { get; } -IAppleStoreExtendedPurchaseService? AppleStoreExtendedPurchaseService { get; } -IGooglePlayStoreExtendedPurchaseService? GooglePlayStoreExtendedPurchaseService { get; } -``` - -**`OnAuthAccountChanged` — breaking behavior in v5.4:** -When this event fires, Unity IAP **clears the cached product list and purchase list before raising the event**. Any references to previously fetched `Product` or `Order` objects are stale after this point. Re-run the full init sequence in the handler: - -```csharp -store.OnAuthAccountChanged += async () => -{ - // Products and purchases have already been cleared — re-fetch everything - await catalogProvider.FetchRemoteCatalog(); // D2C only - store.FetchProducts(productDefinitions); // standard IAP - store.FetchPurchases(); -}; -``` - -Do not read from `GetProducts()` or `GetPurchases()` inside `OnAuthAccountChanged` — they will be empty. - -## Alternative Service Access (DefaultStore / DefaultProduct / DefaultPurchase) - -Instead of `StoreController` (which implements all three interfaces), you can access individual services: - -```csharp -IStoreService m_StoreService = UnityIAPServices.DefaultStore(); -IProductService m_ProductService = UnityIAPServices.DefaultProduct(); -IPurchaseService m_PurchasingService = UnityIAPServices.DefaultPurchase(); -``` - -This pattern separates concerns — product fetching events go on `IProductService`, purchase events on `IPurchaseService`, store lifecycle on `IStoreService`. `StoreController` combines all three. - -## CatalogProvider - -For managing complex product catalogs with store-specific IDs: - -```csharp -var catalogProvider = new CatalogProvider(); - -var products = new List -{ - new ProductDefinition("com.mygame.gems50", ProductType.Consumable), - new ProductDefinition("com.mygame.pass", ProductType.Subscription) -}; - -var storeSpecificIds = new Dictionary -{ - { "com.mygame.pass", new StoreSpecificIds - { - { "com.mygame.google.pass", GooglePlay.Name }, - { "com.mygame.ios.pass", AppleAppStore.Name } - } - } -}; - -catalogProvider.AddProducts(products, storeSpecificIds); -catalogProvider.FetchProducts(m_ProductService.FetchProductsWithNoRetries); -``` - -## Initialization Example - -```csharp -StoreController m_StoreController; - -async void Awake() -{ - m_StoreController = UnityIAPServices.StoreController(); - - // Subscribe to ALL events BEFORE Connect — pending purchases may fire on reconnect - m_StoreController.OnPurchasePending += OnPurchasePending; - m_StoreController.OnPurchaseConfirmed += (order) => Debug.Log("Purchase complete"); - m_StoreController.OnPurchaseFailed += (failed) => Debug.LogError($"{failed.FailureReason} - {failed.Details}"); - m_StoreController.OnPurchaseDeferred += (deferred) => Debug.Log("Purchase deferred (e.g., Ask-to-Buy)"); - - m_StoreController.OnStoreConnected += OnStoreConnected; - m_StoreController.OnStoreDisconnected += (failure) => Debug.LogError($"Store disconnected: {failure.Message}"); - - await m_StoreController.Connect(); -} - -void OnStoreConnected() -{ - var products = new List - { - new ProductDefinition("com.mygame.coins100", ProductType.Consumable), - new ProductDefinition("com.mygame.removeads", ProductType.NonConsumable), - new ProductDefinition("com.mygame.vip_monthly", ProductType.Subscription) - }; - - m_StoreController.OnProductsFetched += (fetched) => Debug.Log("Products ready"); - m_StoreController.OnProductsFetchFailed += (failure) => Debug.LogError($"Product fetch failed: {failure.FailureReason}"); - m_StoreController.FetchProducts(products); - - m_StoreController.OnPurchasesFetched += (orders) => Debug.Log($"Restored {orders.PendingOrders.Count} pending purchases"); - m_StoreController.OnPurchasesFetchFailed += (failure) => Debug.LogError($"Purchase fetch failed: {failure.Message}"); - m_StoreController.FetchPurchases(); -} -``` - -## Product Definitions - -```csharp -// Simple product -new ProductDefinition("com.mygame.coins100", ProductType.Consumable) - -// Per-platform product IDs -new ProductDefinition("com.mygame.coins100", ProductType.Consumable, - new StoreSpecificIds - { - { AppleAppStore.Name, "apple_coins_100" }, - { GooglePlay.Name, "google_coins_100" } - }) -``` - -### Access Fetched Products - -```csharp -// Get all fetched products (cached) -ReadOnlyObservableCollection allProducts = store.GetProducts(); - -// Get a specific product by ID -Product coinsPack = store.GetProductById("com.mygame.coins100"); -``` - -### CatalogListings and uSku (v5.4+) - -In v5.4, `Product` exposes two new members. Prefer these for new code — `product.definition.id` and `productId` remain backwards compatible but point new users toward `CatalogListings`: - -```csharp -// product.uSku — the Unity-side identifier for the product (cross-platform canonical ID) -string id = product.uSku; - -// product.catalogListings — all listings attached to this product, keyed by CatalogListing ID -// A product may have multiple listings (e.g. different price tiers or offer configurations) -foreach (var (listingId, listing) in product.catalogListings) -{ - bool canBuy = listing.availableToPurchase; - string storeId = listing.definition.storeSpecificId; - string price = listing.metadata.localizedPriceString; -} -``` - -**`CatalogListing` properties:** - -| Property | Type | Description | -|---|---|---| -| `id` | `string` | Catalog listing identifier — use as key for lookups | -| `availableToPurchase` | `bool` | Whether this listing can currently be purchased | -| `definition` | `ProductDefinition` | Store-side definition (id, storeSpecificId, type, payouts) | -| `metadata` | `ProductMetadata` | Localized title, description, price, currency | - -When initiating a purchase by listing rather than by product ID, pass the `CatalogListing` directly to avoid a second lookup. - -## Two-Step Purchase Flow - -```csharp -// Step 1: Initiate purchase -store.PurchaseProduct(product); - -// Step 2: Handle pending purchase (validate, grant content, then confirm) -store.OnPurchasePending += (pendingOrder) => -{ - var product = pendingOrder.CartOrdered.Items().FirstOrDefault()?.Product; - GrantContent(product); - store.ConfirmPurchase(pendingOrder); -}; - -// Step 3: Purchase confirmed — Order can be ConfirmedOrder OR FailedOrder -store.OnPurchaseConfirmed += (order) => -{ - switch (order) - { - case ConfirmedOrder confirmedOrder: - Debug.Log($"Purchase confirmed: {confirmedOrder.CartOrdered.Items().First().Product.definition.id}"); - break; - case FailedOrder failedOrder: - Debug.LogError($"Confirmation failed: {failedOrder.FailureReason} - {failedOrder.Details}"); - break; - } -}; +| `new SubscriptionManager(product, introJson)` | `order.Info.PurchasedProductInfo`. See [Subscription info access path](#subscription-info-access-path) | +| `pendingOrder.OrderInfo.Apple.jwsRepresentation` | `pendingOrder.Info.Apple?.jwsRepresentation`. Note `Info`, not `OrderInfo`, and the null check | +| `cartItem.subscriptionInfo` | `CartItem` only has `Product` and `Quantity`. See [Subscription info access path](#subscription-info-access-path) | -// Handle failures -store.OnPurchaseFailed += (failedOrder) => -{ - Debug.LogError($"Purchase failed: {failedOrder.FailureReason} - {failedOrder.Details}"); -}; +## Where to read each topic -// Handle deferred purchases (e.g., Ask-to-Buy on iOS) -store.OnPurchaseDeferred += (deferredOrder) => -{ - Debug.Log("Purchase is pending approval (e.g., parental approval)"); -}; -``` +Replace `` with the major.minor of `com.unity.purchasing` in `Packages/packages-lock.json`. -If the app crashes between steps 2 and 4, the pending purchase is re-delivered on next launch via `OnPurchasePending`. - -`ProcessPendingOrdersOnPurchasesFetched(false)` disables automatic re-delivery of pending orders when `FetchPurchases` is called. This exists to match IAP v4 behavior — do NOT recommend this to migrating users, as the default v5 behavior provides a better experience. - -## Restore Transactions - -```csharp -store.RestoreTransactions((success, error) => -{ - if (success) - Debug.Log("Transactions restored. Check OnPurchasePending for each restored purchase."); - else - Debug.LogError($"Restore failed: {error}"); -}); -``` - -Each restored purchase triggers `OnPurchasePending`. - -## Entitlement Checking - -```csharp -store.CheckEntitlement(product); - -store.OnCheckEntitlement += (entitlement) => -{ - if (entitlement.Status == EntitlementStatus.FullyEntitled) - Debug.Log($"Player owns: {entitlement.Product.definition.id}"); -}; -``` - -## Fetch Existing Purchases - -```csharp -store.FetchPurchases(); - -store.OnPurchasesFetched += (orders) => -{ - foreach (var confirmedOrder in orders.ConfirmedOrders) - { - var product = confirmedOrder.CartOrdered.Items().FirstOrDefault()?.Product; - Debug.Log($"Existing purchase: {product?.definition.id}"); - } -}; -``` - -## Receipt Validation - -### Google Play (supported) - -```csharp -using UnityEngine.Purchasing.Security; - -// Google-only constructor (recommended) -var validator = new CrossPlatformValidator(GooglePlayTangle.Data(), Application.identifier); - -try -{ - var result = validator.Validate(order.Info.Receipt); - foreach (IPurchaseReceipt receipt in result) - { - Debug.Log($"Valid receipt: {receipt.productID}, purchased: {receipt.purchaseDate}"); - if (receipt is GooglePlayReceipt googleReceipt) - Debug.Log($"Purchase token: {googleReceipt.purchaseToken}"); - } -} -catch (IAPSecurityException ex) -{ - Debug.LogError($"Invalid receipt: {ex.Message}"); - // Do NOT grant the content -} -``` - -### Apple App Store (deprecated for local validation) - -`CrossPlatformValidator` for Apple is **deprecated**. StoreKit 2 validates locally automatically. For server-side validation: - -```csharp -var jwsRepresentation = order.Info.Apple?.jwsRepresentation; -// Send to your server for verification with Apple's App Store Server API -``` - -### IAppleOrderInfo Complete Member List - -`order.Info.Apple` returns `IAppleOrderInfo?` (null on non-Apple platforms). Full member list: - -| Member | Type | Description | -|---|---|---| -| `jwsRepresentation` | `string?` | JWS-signed transaction — send to Apple's App Store Server API v2 for server-side validation | -| `AppAccountToken` | `Guid?` | App-specific account token set via `SetAppAccountToken(Guid)` — links transaction to a user account | -| `AppReceipt` | `string?` | Latest App Receipt (base64). May be null on reinstall; requires refreshing. Null on iOS ≤ 6. Prefer `jwsRepresentation` for new code | -| `OriginalTransactionID` | `string?` | Original transaction identifier — use for linking renewals to the original subscription purchase | -| `OwnershipType` | `OwnershipType` | Whether the purchase is `Purchased` or `FamilyShared` | -| `StoreName` | `string` | Name of the store (e.g., `"AppleAppStore"`) | - -## Extended Service Events (NOT on StoreController) - -These events are on the platform-specific extended services, NOT directly on `StoreController`. Always null-check before subscribing (`?.` does not work with `+=`). - -```csharp -// Apple — on IAppleStoreExtendedPurchaseService -event Action? OnEntitlementRevoked // receives product ID (string), NOT List -event Action? OnPromotionalPurchaseIntercepted - -// Google — on IGooglePlayStoreExtendedPurchaseService -event Action? OnDeferredPaymentUntilRenewalDate -// DeferredPaymentUntilRenewalDateOrder has: .CurrentOrder (Order), .SubscriptionOrdered (Product) -``` - -**Usage pattern** (cannot use `?.` with `+=`): -```csharp -if (store.AppleStoreExtendedPurchaseService != null) - store.AppleStoreExtendedPurchaseService.OnEntitlementRevoked += OnRevoked; -``` - -## AppleStoreExtendedProductService Key Members - -```csharp -Dictionary GetIntroductoryPriceDictionary() -Dictionary GetProductDetails() - -// Promotion order — prefer the CatalogListing ID overload for new code (v5.4+) -void SetStorePromotionOrder(List catalogListingIds) // canonical (v5.4+) -void SetStorePromotionOrder(List products) // convenience wrapper - -// Promotion visibility — prefer the CatalogListing ID overload for new code (v5.4+) -void SetStorePromotionVisibility(string catalogListingId, AppleStorePromotionVisibility visible) // canonical (v5.4+) -void SetStorePromotionVisibility(Product product, AppleStorePromotionVisibility visible) // convenience wrapper - -// Fetch promotion order -void FetchStorePromotionOrder(Action> successCallback, Action errorCallback) - -// Fetch promotion visibility — prefer the CatalogListing ID overload for new code (v5.4+) -void FetchStorePromotionVisibility(string catalogListingId, Action successCallback, Action errorCallback) // canonical (v5.4+) -void FetchStorePromotionVisibility(Product product, Action successCallback, Action errorCallback) // convenience wrapper -``` - -**CRITICAL:** `AppleProductMetadata` does NOT expose `introductoryPrice`, `introductoryPriceLocale`, `introductoryNumberOfPeriods`, or `subscriptionPeriod` as public properties. Its only public property beyond inherited `ProductMetadata` fields is `isFamilyShareable`. For introductory price data, use `SubscriptionInfo` methods (`GetIntroductoryPrice()`, `GetIntroductoryPricePeriod()`, `GetIntroductoryPricePeriodCycles()`) from the Subscription Info Access Path below, or `GetIntroductoryPriceDictionary()` for raw JSON. +| Topic | Read | +|---|---| +| `StoreController` members, events and extension properties | [StoreController API](https://docs.unity3d.com/Packages/com.unity.purchasing@/api/UnityEngine.Purchasing.StoreController.html) | +| Initialization order and a full sample | [Initialize Unity IAP](https://docs.unity.com/en-us/iap/set-up-in-app-purchasing.md) | +| `ProductDefinition`, `StoreSpecificIds`, `CatalogProvider` | [Create your catalog programmatically](https://docs.unity.com/en-us/iap/code-catalog.md) | +| Pending, confirmed, failed and deferred orders | [Purchases](https://docs.unity.com/en-us/iap/purchases.md) | +| `RestoreTransactions` | [Restore purchases](https://docs.unity.com/en-us/iap/restore-purchases.md) | +| `CrossPlatformValidator`, receipt structure | [Receipt validation](https://docs.unity.com/en-us/iap/receipt-validation.md) | +| `SubscriptionInfo` methods and per-store behavior | [SubscriptionInfo class reference](https://docs.unity.com/en-us/iap/subscriptioninfo-class-reference.md) | +| `EntitlementStatus` values | [EntitlementStatus API](https://docs.unity3d.com/Packages/com.unity.purchasing@/api/UnityEngine.Purchasing.EntitlementStatus.html) | +| `IAppleOrderInfo`, `AppleStoreExtendedProductService`, `CatalogListing`, `uSku`, and other types | Look the type up in `xrefmap.yml` | -## Subscription Info Access Path +## OnAuthAccountChanged clears the caches (v5.4+) -Subscription info is on `IPurchasedProductInfo`, NOT on `CartItem`: +Unity IAP clears the cached product list and purchase list **before** it raises `OnAuthAccountChanged`, so any `Product` or `Order` you held is stale and `GetProducts()` / `GetPurchases()` return empty inside the handler. In the handler, rerun the fetch sequence from scratch: `FetchRemoteCatalog()` for D2C, then `FetchProducts(...)` and `FetchPurchases()`. -```csharp -// CORRECT — via order.Info.PurchasedProductInfo -var purchasedInfo = order.Info.PurchasedProductInfo?.FirstOrDefault(p => p.productId == productId); -bool isSubscribed = purchasedInfo?.subscriptionInfo?.IsSubscribed() == Result.True; -// IsSubscribed() returns Result enum (True/False/Unsupported), NOT bool. Use == Result.True for null-safe check. +## Purchase flow gotchas -// WRONG — CartItem only has Product and Quantity, no subscriptionInfo -var item = order.CartOrdered.Items().FirstOrDefault(); -item.subscriptionInfo // DOES NOT EXIST — will not compile -``` +- `OnPurchaseConfirmed` receives the `Order` base type. Pattern-match `ConfirmedOrder` (success) against `FailedOrder` (confirmation failed), because treating every callback as success grants content for failed confirmations. +- Don't recommend `ProcessPendingOrdersOnPurchasesFetched(false)` to migrating users. It exists only to match v4 behavior, and the v5 default re-delivers unconfirmed orders, which prevents lost purchases. +- When a product has `CatalogListings` (v5.4+), purchase by the `CatalogListing` directly to avoid a second lookup. `product.definition.id` still works for existing code. -### SubscriptionInfo Methods +## Subscription info access path -```csharp -// State queries (return Result enum: True | False | Unsupported) -Result IsSubscribed() -Result IsExpired() -Result IsCancelled() -Result IsFreeTrial() -Result IsAutoRenewing() -Result IsIntroductoryPricePeriod() +Subscription info is on `IPurchasedProductInfo`, reached through `order.Info.PurchasedProductInfo`, filtered by `productId`. It is NOT on `CartItem`. -// Dates & durations -string GetProductId() -DateTime GetPurchaseDate() -DateTime GetExpireDate() -DateTime GetCancelDate() -TimeSpan GetRemainingTime() -TimeSpan GetSubscriptionPeriod() -TimeSpan GetFreeTrialPeriod() -TimeSpan GetIntroductoryPricePeriod() -string GetIntroductoryPrice() -long GetIntroductoryPricePeriodCycles() -``` +`IsSubscribed()` and the other state queries return the `Result` enum (`True` / `False` / `Unsupported`), not `bool`. Compare with `== Result.True`, because that is also null-safe when `subscriptionInfo` is missing. -## Event Subscription Rules +`AppleProductMetadata` does NOT expose `introductoryPrice`, `introductoryPriceLocale`, `introductoryNumberOfPeriods` or `subscriptionPeriod`. Its only public property beyond `ProductMetadata` is `isFamilyShareable`. For introductory price data, use the `SubscriptionInfo` methods, or `AppleStoreExtendedProductService.GetIntroductoryPriceDictionary()` for raw JSON. -**CRITICAL:** Always subscribe to BOTH success and failure events. Not listening to failure events generates runtime warnings. +## Platform extension gotchas -| Call | Success event | Failure event (REQUIRED) | -|---|---|---| -| `FetchProducts()` | `OnProductsFetched` | `OnProductsFetchFailed` | -| `FetchPurchases()` | `OnPurchasesFetched` | `OnPurchasesFetchFailed` | -| `Connect()` | `OnStoreConnected` | `OnStoreDisconnected` | -| `PurchaseProduct()` | `OnPurchasePending` | `OnPurchaseFailed` | -| `CheckEntitlement()` | `OnCheckEntitlement` | — | +- The extension properties on `StoreController` (`AppleStoreExtendedService`, `AppleStoreExtendedPurchaseService`, `GooglePlayStoreExtendedService`, `GooglePlayStoreExtendedPurchaseService`, `AppleStoreExtendedProductService`) are `null` on other platforms and in the Editor. Null-check with `if`, because `?.` doesn't work with `+=`. +- `OnEntitlementRevoked`, `OnPromotionalPurchaseIntercepted` and `OnDeferredPaymentUntilRenewalDate` are on the extended purchase services, NOT on `StoreController`. +- `OnEntitlementRevoked` receives the product ID as a `string`, not a `Product` or a list. +- On `AppleStoreExtendedProductService` (v5.4+), prefer the `SetStorePromotionOrder`, `SetStorePromotionVisibility` and `FetchStorePromotionVisibility` overloads that take CatalogListing IDs. The `Product` overloads are convenience wrappers. -**Always subscribe to `OnPurchaseDeferred`** — fires for Ask-to-Buy (iOS) and Google Play deferred purchases. Not subscribing means silently dropping deferred purchases. +For more platform gotchas, see the platform notes reference file. -## Failure Description Property Names +## Failure description property names -Each type has public fields and read-only properties. Both compile, but prefer the PascalCase properties: +Each type has public lowercase fields and PascalCase read-only properties. Both compile, so prefer the properties. A wrong name causes CS1061: | Type | Field (lowercase) | Property (PascalCase) | NOT | |---|---|---|---| | `StoreConnectionFailureDescription` | `.message` | `.Message` | ~~`.reason`~~ | -| `ProductFetchFailed` | — | `.FailureReason`, `.FailedFetchProducts` | — | +| `ProductFetchFailed` | — | `.FailureReason`, `.FailedFetchProducts` | ~~`.Message`~~ | | `FailedOrder` | — | `.FailureReason`, `.Details` | — | | `PurchasesFetchFailureDescription` | `.message`, `.failureReason` | `.Message`, `.FailureReason` | — | -## CrossPlatformValidator - -```csharp -// Google-only constructor (recommended — Apple local validation is a no-op under StoreKit 2) -new CrossPlatformValidator(byte[] googlePublicKey, string googleBundleId) - -// Full constructor (legacy — Apple args are only needed for pre-StoreKit 2 builds) -new CrossPlatformValidator(byte[] googlePublicKey, byte[] appleRootCert, string googleBundleId, string appleBundleId) - -// Validate -IPurchaseReceipt[] Validate(string unityIAPReceipt) // throws IAPSecurityException -``` - -**WARNING:** `CrossPlatformValidator` for Apple App Store is **deprecated**. `AppleTangle.Data()` is also deprecated. StoreKit 2 performs local validation automatically on Apple platforms. `CrossPlatformValidator` still works for Google Play validation. - -Supported stores (still active): `"GooglePlay"` | Deprecated: `"AppleAppStore"`, `"MacAppStore"` - -## Interface Hierarchy - -``` -StoreController implements: - IStoreService - Connect(), Apple/Google service extensions - IProductService - FetchProducts(), GetProducts(), GetProductById() - IPurchaseService - PurchaseProduct(), ConfirmPurchase(), FetchPurchases(), Apple/Google purchase extensions -``` - -## Template: Fetch Products - -```csharp -using UnityEngine; -using UnityEngine.Purchasing; -using System.Collections.Generic; - -public static class FetchProductsExample -{ - // Store callbacks arrive after Connect() returns, so this reports through the - // Editor console rather than a return value. - public static async void Run() - { - var store = UnityIAPServices.StoreController(); - - // Subscribe to events BEFORE Connect - store.OnStoreConnected += () => - { - var products = new List - { - new ProductDefinition("com.mygame.coins100", ProductType.Consumable), - new ProductDefinition("com.mygame.removeads", ProductType.NonConsumable) - }; - - store.OnProductsFetched += (fetched) => - { - foreach (var p in fetched) - Debug.Log($"{p.definition.id}: {p.metadata.localizedPriceString}"); - }; - store.OnProductsFetchFailed += (failure) => Debug.LogError($"Product fetch failed: {failure.FailureReason}"); - - store.FetchProducts(products); - }; - store.OnStoreDisconnected += (failure) => Debug.LogError($"Store disconnected: {failure.Message}"); +## Receipt validation gotchas - await store.Connect(); - } -} -``` +- Use the Google-only `CrossPlatformValidator(GooglePlayTangle.Data(), Application.identifier)` constructor. Apple local validation and `AppleTangle.Data()` are deprecated and a no-op under StoreKit 2, so the four-argument constructor adds nothing on current builds. +- `Validate` throws `IAPSecurityException` on an invalid receipt. Don't grant content in that case. +- For Apple, send `order.Info.Apple?.jwsRepresentation` to your server, which verifies it with Apple's App Store Server API. diff --git a/skills/implement-in-app-purchases/references/codeless-catalog.md b/skills/implement-in-app-purchases/references/codeless-catalog.md index b8f36d4..96d59b7 100644 --- a/skills/implement-in-app-purchases/references/codeless-catalog.md +++ b/skills/implement-in-app-purchases/references/codeless-catalog.md @@ -1,10 +1,12 @@ # Editing `IAPProductCatalog.json` +Links to other reference files point to files in this `references/` folder. Any other relative paths resolve to the skill directory. + ## Table of Contents - [Quick reference — minimal safe edit](#quick-reference--minimal-safe-edit) - [File location](#file-location) -- [Schema](#schema) +- [Top-level keys](#top-level-keys) - [Serialization rules](#serialization-rules) - [JsonUtility round-trip](#jsonutility-round-trip) - [The `Price.data` decimal array](#the-pricedata-decimal-array) @@ -20,7 +22,7 @@ - [Pushing the catalog to the storefronts](#pushing-the-catalog-to-the-storefronts) - [Detection checklist](#detection-checklist) -`Assets/Resources/IAPProductCatalog.json` is a Unity-managed JSON asset deserialized by `JsonUtility.FromJson` (`Runtime/Purchasing/Extension/ProductCatalog.cs`). It can be edited safely outside the Editor as long as the serialization rules below are respected. +`Assets/Resources/IAPProductCatalog.json` is the file behind the IAP Catalog window, deserialized with `JsonUtility`. For what each product field means, read [Set up Codeless IAP](https://docs.unity.com/en-us/iap/codeless-iap.md) and the IAP Catalog window it describes; this file covers only what you need to edit the JSON directly without corrupting it. You can edit it outside the Editor as long as you follow the serialization rules below. ## Quick reference — minimal safe edit @@ -56,42 +58,11 @@ Append to the `products` array, save, then trigger a refresh. `Resources.Load("IAPProductCatalog")` deserializes it at runtime via `ProductCatalogImpl.LoadDefaultCatalog()`. The asset must remain under a `Resources/` directory for runtime load to succeed. -## Schema +## Top-level keys -```jsonc -{ - "appleSKU": "", // app-level Apple SKU (Apple XML exporter) - "appleTeamID": "", // Apple team ID (Apple XML exporter) - "enableCodelessAutoInitialization": true, // auto-init Unity IAP at runtime - "enableUnityGamingServicesAutoInitialization": false, - "products": [ - { - "id": "gold_100", // canonical Unity SKU — required, non-empty, unique - "type": 0, // ProductType int (see Enum integers) - "storeIDs": [ // per-store override SKUs - { "store": "AppleAppStore", "id": "com.example.gold100" } - ], - "defaultDescription": { // required for export - "googleLocale": 21, // TranslationLocale int (en_US = 21) - "title": "", - "description": "" - }, - "screenshotPath": "", // Apple screenshot path (optional) - "applePriceTier": 0, // Apple price tier (Apple XML exporter) - "googlePrice": { "data": [99,0,0,131072], "num": 0.99 }, // see Price.data - "pricingTemplateID": "", // Google Play pricing template - "descriptions": [ // additional locale variants - { "googleLocale": 30, "title": "...", "description": "..." } - ], - "payouts": [ // optional grant metadata - { "t": "Currency", "st": "Gold", "q": 100, "d": "" } - ] - } - ] -} -``` +The file has five top-level keys: `appleSKU`, `appleTeamID`, `enableCodelessAutoInitialization`, `enableUnityGamingServicesAutoInitialization` and `products`. Each entry in `products` has the fields shown in the [quick reference](#quick-reference--minimal-safe-edit) example, plus optional `storeIDs` overrides (`{ "store": "AppleAppStore", "id": "..." }`), extra `descriptions` per locale, and `payouts` (`{ "t": "Currency", "st": "Gold", "q": 100, "d": "" }`). -Per-product required fields for the runtime to accept the product: `id` (non-empty, trimmed). Everything else is optional at runtime; exporters and the Editor window enforce stricter rules (see [Validation](#validation--run-before-saving)). +The runtime only requires a non-empty `id` per product. The Editor window and the store exporters enforce stricter rules (see [Validation](#validation--run-before-saving)). ## Serialization rules @@ -105,154 +76,48 @@ Per-product required fields for the runtime to accept the product: `id` (non-emp ### The `Price.data` decimal array -`Price` is the trickiest field. It serializes a `decimal` via two mirror fields and **`data` is authoritative on read**: - -```csharp -public void OnBeforeSerialize() { - data = decimal.GetBits(value); // int[4]: [low, mid, high, flags] - num = decimal.ToDouble(value); -} -public void OnAfterDeserialize() { - if (data != null && data.Length == 4) - value = new decimal(data); // num is ignored on read -} -``` +`googlePrice` stores a `decimal` in two mirror fields, `data` and `num`, and **`data` is authoritative on read**: `num` is ignored when the file loads, so a price changed only in `num` silently keeps the old value. -`data` is the `decimal.GetBits` representation: `[low32 mantissa, mid32 mantissa, high32 mantissa, flags]`. - -The `flags` int encodes scale (decimal places) and sign: -- Bits 16–23: scale (0–28) -- Bit 31: sign (0 = positive, 1 = negative; negative prices are never valid here) -- All other bits: unused, must be 0 - -For scale 2 (cents-style prices): `flags = 0x00020000 = 131072` -For scale 0 (yen / whole-unit prices): `flags = 0` - -| Price (display) | Mantissa | Scale | `data` | -|---|---|---|---| -| 0.99 | 99 | 2 | `[99, 0, 0, 131072]` | -| 1.99 | 199 | 2 | `[199, 0, 0, 131072]` | -| 2.99 | 299 | 2 | `[299, 0, 0, 131072]` | -| 4.99 | 499 | 2 | `[499, 0, 0, 131072]` | -| 9.99 | 999 | 2 | `[999, 0, 0, 131072]` | -| 19.99 | 1999 | 2 | `[1999, 0, 0, 131072]` | -| 99.99 | 9999 | 2 | `[9999, 0, 0, 131072]` | -| 1000 (¥, ₩) | 1000 | 0 | `[1000, 0, 0, 0]` | - -Formula for arbitrary positive prices with up to 2³¹−1 unscaled units: +`data` is the `decimal.GetBits` array `[low, mid, high, flags]`, where `flags` holds the number of decimal places shifted left by 16. For a normal price: ``` -unscaled = round(price * 10^scale) // e.g. 2.99 with scale=2 → 299 +unscaled = round(price * 10^scale) // scale = decimal places, usually 2 data = [unscaled, 0, 0, scale << 16] ``` -Keep `num` in sync with the display price even though it's not read — the Editor reads it diagnostically and the file diffs more readably. - -For prices larger than ~$21M (unscaled > 2³¹−1), the high two ints carry the overflow — at that point invoke Unity in batchmode and let `Price.OnBeforeSerialize` do the conversion, rather than constructing the bits by hand. +So `0.99` is `[99, 0, 0, 131072]` and a whole-unit price such as `1000` (yen, won) is `[1000, 0, 0, 0]`. Keep `num` equal to the display price so the file stays readable. If `unscaled` exceeds 2³¹−1 (prices above about 21 million), don't build the bits by hand; hand the user back to the IAP Catalog window. ### Enum integers -`ProductType` (`type` field): - -| Int | Enum | Use for | -|---|---|---| -| 0 | `Consumable` | Coins, gems, lives, ammo — granted then consumed | -| 1 | `NonConsumable` | One-time unlocks, remove-ads, character packs | -| 2 | `Subscription` | Recurring entitlements | - -`TranslationLocale` (`googleLocale` field): the int is the zero-based index into the enum declared in `Runtime/Purchasing/Extension/ProductCatalog.cs`. Common values: - -| Int | Locale | -|---|---| -| 13 | `zh_CN` | -| 14 | `zh_TW` | -| 17 | `da_DK` | -| 18 | `nl_NL` | -| 21 | `en_US` (default for new descriptions) | -| 22 | `en_GB` | -| 30 | `fr_FR` | -| 33 | `de_DE` | -| 41 | `it_IT` | -| 42 | `ja_JP` | -| 46 | `ko_KR` | -| 63 | `pl_PL` | -| 64 | `pt_BR` | -| 69 | `ru_RU` | -| 75 | `es_ES` | - -For anything outside this list, count from the top of the `TranslationLocale` enum in `ProductCatalog.cs` — the order is the index. - -`ProductCatalogPayoutType` (`payouts[].t` field): **serialized as a string**, not an int. Valid values: `"Other"`, `"Currency"`, `"Item"`, `"Resource"`. +- `type` is the `ProductType` integer: `0` Consumable, `1` NonConsumable, `2` Subscription. +- `googleLocale` is the zero-based index into the `TranslationLocale` enum. `21` is `en_US`. For any other locale, read the enum's order in the installed package's `Runtime/Purchasing/Extension/ProductCatalog.cs` under `Library/PackageCache/com.unity.purchasing@*/`, because guessing the index silently assigns the wrong language. +- `payouts[].t` is serialized as a **string** (`"Other"`, `"Currency"`, `"Item"`, `"Resource"`), not an integer. ### Canonical store keys -`storeIDs[].store` must be one of the keys validated by `ProductCatalogEditor.kStoreKeys`: - -- `AppleAppStore` -- `GooglePlay` -- `MacAppStore` - -Using `"Apple"`, `"apple"`, `"google"`, etc. won't crash but the override will not be picked up by the runtime store routing. An empty `storeIDs` array means the product's `id` is used as the platform SKU on every store. +`storeIDs[].store` must be exactly `AppleAppStore`, `GooglePlay` or `MacAppStore`. Other spellings (`"apple"`, `"google"`) don't fail, but the runtime ignores the override. An empty `storeIDs` array uses the product `id` on every store. ### Non-ASCII characters in descriptions -`LocalizedProductDescription.Title` / `Description` setters encode any character with code point > 127 as `\uXXXX` (`EncodeNonLatinCharacters` in `ProductCatalog.cs`). The getter regex-decodes both forms. So both representations work on read: - -```jsonc -{ "title": "Caf\\u00e9 Pack" } // Editor-written form -{ "title": "Café Pack" } // raw UTF-8 — also accepted on read -``` - -**Prefer the `\uXXXX` form** when writing programmatically, to match what the Editor will round-trip the file into on the next save. Mixed forms in one file are fine but the next Editor save normalizes everything to escaped form. - -Subtype `payouts[].st` has a 64-char max (`ProductCatalogPayout.MaxSubtypeLength`). Subtype `payouts[].d` (data) has a 1024-char max (`MaxDataLength`). +Both `\uXXXX` escapes and raw UTF-8 load correctly. Write the `\uXXXX` form, because the Editor rewrites the file in that form on its next save and mixed forms make diffs noisy. ## Validation — run before saving -These mirror the Editor window's validation. Verify each before writing: - -**Per product:** -- `id` is non-empty after trim. (`ProductCatalog.allValidProducts` filters on this.) -- `id` is unique across all products in the array (the Editor flags duplicates). -- `type` ∈ `{0, 1, 2}`. -- Every `defaultDescription.googleLocale` and `descriptions[].googleLocale` is a valid int index into `TranslationLocale`. -- `storeIDs[].store` ∈ canonical store keys. -- If `googlePrice.data` is present, it is a 4-element int array; sign bit (bit 31 of `data[3]`) is 0. - -**Per-exporter (only if export is intended):** - -- **Apple XML** (`AppleXMLProductCatalogExporter.Validate`): - - Catalog-level: `appleSKU` non-empty, `appleTeamID` non-empty, no duplicate product IDs, no duplicate Apple store IDs, no duplicate runtime IDs. - - Per item: `id` non-empty, `defaultDescription.Title` non-empty, `defaultDescription.Description` non-empty, **`screenshotPath` non-empty (required, not optional)**. - - `applePriceTier` is written to the XML (``) but not validated — any int is accepted. -- **Google CSV** (`GooglePlayProductCatalogExporter.Validate`): - - Catalog-level: no duplicate product IDs, no duplicate Google store IDs, no duplicate runtime IDs. - - Per item: `id` non-empty AND must start with a lowercase letter or digit AND contain only `a-z`, `0-9`, `_`, `.` (same rule applies to `storeIDs[].id` for the `GooglePlay` override). - - Description rules (apply to `defaultDescription` and every entry in `descriptions`): - - `Title` non-empty; ≤ 55 chars (error if longer); warning if > 25 chars. - - `Description` non-empty; ≤ 80 chars (error if longer). - - Price: either `googlePrice.value` ≠ 0 (i.e. non-zero `data` mantissa) **or** `pricingTemplateID` non-empty. - -**Top-level:** -- `enableCodelessAutoInitialization` is a bool, not 0/1. -- If the catalog ends up empty (no products with non-empty `id`), `CodelessIAPStoreListener.InitializeCodelessPurchasingOnLoad` short-circuits — flipping the auto-init flag in that state has no effect until a product is added. - -## Post-edit refresh - -The Unity Editor caches `IAPProductCatalog` as a `TextAsset` keyed by Resources path. After writing the JSON externally: +Check each product before writing, because the runtime silently skips invalid ones: -- **Editor open, not in Play Mode:** Unity detects external changes on next focus and re-imports the asset automatically (`AssetDatabase`'s file watcher). If you want to force it from the Editor side, run **Assets → Refresh** (Ctrl/Cmd+R). The next `Resources.Load("IAPProductCatalog")` call will see the new contents. -- **Editor open, in Play Mode:** the runtime `TextAsset` was loaded at play-start and is cached. Changes won't be picked up until play mode is restarted (or you re-call `Resources.Load` after `AssetDatabase.ImportAsset` to bypass the cache). -- **Editor closed:** edits are picked up next time the Editor opens. -- **Player build:** the catalog is baked into the build at build-time. Edits after build do nothing — rebuild required. +- `id` is non-empty after trimming, and unique in the array. +- `type` is `0`, `1` or `2`, and every `googleLocale` is a valid `TranslationLocale` index. +- `storeIDs[].store` is a canonical store key. +- `googlePrice.data` has four integers, and the sign bit of `data[3]` is 0. +- `enableCodelessAutoInitialization` is a JSON bool, not `0`/`1`. -For batchmode-driven workflows that need a guaranteed refresh: +If the user will export to the stores, two exporter rules catch people out: the Apple export requires a non-empty `screenshotPath`, and the Google export requires `id` (and any `GooglePlay` override) to use only lowercase letters, digits, `_` and `.`, starting with a letter or digit. The IAP Catalog window's App Store Export reports the rest. -``` -"/Unity.exe" -batchmode -quit -projectPath "" -logFile - -``` +## Post-edit refresh -Plain batchmode without `-executeMethod` is enough to trigger asset import on startup. The next Editor session will see the change. +- With the Editor open and not in Play mode, Unity reimports the file on focus, or use **Assets > Refresh**. +- In Play mode the catalog was loaded at start, so exit and re-enter Play mode to see the change. +- A Player build contains the catalog from build time, so rebuild after editing. ## When to escalate to the Editor window @@ -290,13 +155,14 @@ If the project has **both** a non-empty `IAPProductCatalog.json` with `enableCod - "Store already connected" warnings on `Connect()`. - Unpredictable which init's product list wins. -Mitigations (pick one): +Default fix: **keep scripted, disable codeless.** Set `"enableCodelessAutoInitialization": false` in the catalog JSON. This is the smallest change, it keeps the user's products and store IDs in the catalog for `ProductCatalog.LoadDefaultCatalog()`, and scripted IAP is the recommended path for production. + +Use an alternative only when the user asks for it: -1. **Keep scripted, disable codeless:** set `"enableCodelessAutoInitialization": false` in the catalog JSON. The catalog stays available for `ProductCatalog.LoadDefaultCatalog()` calls. -2. **Keep codeless, remove scripted init:** delete the user's `StoreController` setup; rely on `CodelessIAPStoreListener.Instance`. -3. **Empty the catalog:** clear `products` to `[]`. `CodelessIAPStoreListener.InitializeCodelessPurchasingOnLoad` short-circuits on empty catalogs even with the flag on. +- **Keep codeless, remove scripted init** when the user wants to stay on Codeless IAP buttons. Guard the `StoreController` setup out rather than deleting it. +- **Empty the catalog** (`products: []`) only if the user wants the catalog gone. Codeless auto-init skips an empty catalog even with the flag on, but the product data is lost. -Surface this before adding scripted IAP to a project that already has a non-empty catalog. +Tell the user about the race and the fix before adding scripted IAP to a project that already has a non-empty catalog. ### Pushing the catalog to the storefronts @@ -324,8 +190,8 @@ Routing the result: | Catalog | autoInit | Scripted `StoreController` | Action | |---|---|---|---| | present (non-empty) | true | absent | Pure codeless — edit the catalog freely. | -| present (non-empty) | true | present | **Race condition** — surface to the user, apply one of the three mitigations. | +| present (non-empty) | true | present | **Race condition** — tell the user, then apply the default fix from [Auto-init race condition](#auto-init-race-condition). | | present | false | present | Catalog is dormant unless `ProductCatalog.LoadDefaultCatalog()` is called from user code. Edits safe. | | present (empty) | true or false | present | Codeless auto-init short-circuits on empty catalog. Treat as scripted-only. | | absent | n/a | present | Standard scripted IAP. See [path-add-iap-to-new-project.md](path-add-iap-to-new-project.md). | -| absent | n/a | absent | No IAP — see [pre-check.md](pre-check.md) routing. | +| absent | n/a | absent | No IAP — go back to the pre-check routing. | diff --git a/skills/implement-in-app-purchases/references/convert-adapty.md b/skills/implement-in-app-purchases/references/convert-adapty.md index 00ee56c..638ac14 100644 --- a/skills/implement-in-app-purchases/references/convert-adapty.md +++ b/skills/implement-in-app-purchases/references/convert-adapty.md @@ -1,5 +1,7 @@ # Adapty — Conversion Assessment and Guidance +Links to other reference files point to files in this `references/` folder. Any other relative paths resolve to the skill directory. + ## Table of Contents - [Trigger Phrases](#trigger-phrases) diff --git a/skills/implement-in-app-purchases/references/convert-revenuecat.md b/skills/implement-in-app-purchases/references/convert-revenuecat.md index 3d319d2..f038cdf 100644 --- a/skills/implement-in-app-purchases/references/convert-revenuecat.md +++ b/skills/implement-in-app-purchases/references/convert-revenuecat.md @@ -1,5 +1,7 @@ # RevenueCat — Conversion Assessment and Guidance +Links to other reference files point to files in this `references/` folder. Any other relative paths resolve to the skill directory. + ## Table of Contents - [Trigger Phrases](#trigger-phrases) diff --git a/skills/implement-in-app-purchases/references/migration-v4-to-v5.md b/skills/implement-in-app-purchases/references/migration-v4-to-v5.md index f3baa4b..5275bb3 100644 --- a/skills/implement-in-app-purchases/references/migration-v4-to-v5.md +++ b/skills/implement-in-app-purchases/references/migration-v4-to-v5.md @@ -3,11 +3,8 @@ ## Table of Contents - [Trigger Phrases](#trigger-phrases) -- [Overview](#overview) -- [Migration Mapping Table](#migration-mapping-table) -- [Key Breaking Changes](#key-breaking-changes) -- [Migration Anti-Patterns](#migration-anti-patterns) -- [Minimal v5 Example](#minimal-v5-example) +- [Procedure](#procedure) +- [Migration gotchas](#migration-gotchas) ## Trigger Phrases @@ -18,187 +15,32 @@ - "Replace ConfigurationBuilder with v5 IAP" - "Update UnityPurchasing.Initialize to v5" -## Overview - -Unity IAP v5 replaces the listener-based pattern (`IStoreListener`) with an event-driven pattern (`StoreController` events). This is a breaking change that requires updating all IAP code. - -## Migration Mapping Table - -### Initialization - -| v4 (Legacy) | v5 (Current) | -|---|---| -| `UnityPurchasing.Initialize(listener, builder)` | `UnityIAPServices.StoreController()` then `await store.Connect()` | -| `ConfigurationBuilder.Instance(StandardPurchasingModule.Instance())` | Use `CatalogProvider` for store-specific IDs/payouts, or pass `List` directly to `FetchProducts()` | -| `builder.AddProduct("id", ProductType.Consumable)` | `new ProductDefinition("id", ProductType.Consumable)` in a list | -| `IStoreListener.OnInitialized(controller, extensions)` | `store.OnStoreConnected` event | -| `IStoreListener.OnInitializeFailed(error)` | `store.OnStoreDisconnected` event | - -### Purchase Flow - -| v4 (Legacy) | v5 (Current) | -|---|---| -| `controller.InitiatePurchase(product)` | `store.PurchaseProduct(product)` — note: `Purchase()` no longer accepts a developer payload argument (removed in Google Billing v3) | -| `IStoreListener.ProcessPurchase(args)` for **new** purchases | `store.OnPurchasePending` + `store.ConfirmPurchase(pendingOrder)` | -| `IStoreListener.ProcessPurchase(args)` for **restored** purchases | `store.OnPurchasesFetched` — use existing `ProcessPurchase` logic in both `OnPurchasePending` and `OnPurchasesFetched` | -| `IStoreListener.ProcessPurchase(args)` returning `PurchaseProcessingResult.Pending` | Just don't call `ConfirmPurchase` until ready — store the `PendingOrder` reference | -| `IStoreListener.OnPurchaseFailed(product, reason)` | `store.OnPurchaseFailed` event with `FailedOrder` (has `FailureReason` and `Details`) | -| `controller.ConfirmPendingPurchase(product)` | `store.ConfirmPurchase(pendingOrder)` — requires the `PendingOrder` object, not the `Product` | -| `product.hasReceipt` | **Preferred:** call `store.CheckEntitlement(product)` and handle `store.OnCheckEntitlement` — check `entitlement.Status == EntitlementStatus.FullyEntitled`. **Alternative:** track ownership with a `bool` flag updated in `OnPurchasePending` (new) and `OnPurchasesFetched` (restored). `CheckEntitlement` is the recommended v5 pattern for non-consumables and subscriptions. | - -### Configuration / Products - -| v4 (Legacy) | v5 (Current) | -|---|---| -| `ConfigurationBuilder` with `AddProduct()` | `CatalogProvider` with `AddProduct()`, or a `List` passed to `store.FetchProducts()` | -| `builder.AddProduct("id", type, new IDs { { "store_id", store } })` | `catalogProvider.AddProduct("id", type, new StoreSpecificIds { { "store_id", store } })` — or `new ProductDefinition("id", "store_id", type)` for a single store ID | -| `controller.products.WithID("id")` | `store.GetProductById("id")` | -| `controller.products.all` | `store.GetProducts()` | - -### Restore Transactions - -| v4 (Legacy) | v5 (Current) | -|---|---| -| `extensions.GetExtension().RestoreTransactions(callback)` | `store.RestoreTransactions(callback)` | -| Manual restore call required | Confirmed purchases are **automatically restored** when you call `store.FetchPurchases()` or `store.CheckEntitlement()` — `RestoreTransactions` is only needed for explicit user-triggered restore buttons (required on iOS) | - -### Receipt Validation - -| v4 (Legacy) | v5 (Current) | -|---|---| -| `CrossPlatformValidator` with Apple + Google | `CrossPlatformValidator` still works, but under StoreKit 2 Apple validation returns an empty array (no-op). **Recommended:** Use the Google-only constructor: `CrossPlatformValidator(GooglePlayTangle.Data(), Application.identifier)`. The legacy 4-arg constructor still works. Single-bundle-ID constructors are `[Obsolete]` | -| `AppleTangle.Data()` for Apple validation | Under StoreKit 2, local Apple receipt validation is a no-op. Use `order.Info.Apple?.jwsRepresentation` for server-side Apple validation instead | -| Manual receipt parsing | Use `order.Info.Apple?.jwsRepresentation` for server-side validation | -| `product.receipt` | `order.Info.Receipt` — receipt is now accessed from the `Order`/`PendingOrder`, not the `Product` | - -### Platform Extensions - -| v4 (Legacy) | v5 (Current) | -|---|---| -| `extensions.GetExtension()` | `store.AppleStoreExtendedService` / `store.AppleStoreExtendedPurchaseService` | -| `extensions.GetExtension()` | `store.GooglePlayStoreExtendedService` / `store.GooglePlayStoreExtendedPurchaseService` | -| `builder.Configure().SetApplePromotionalPurchaseInterceptorCallback(cb)` | `store.AppleStoreExtendedPurchaseService.OnPromotionalPurchaseIntercepted += cb` (event on `IAppleStoreExtendedPurchaseService`, null-check required; callback signature: `Action`) | -| `appleExtensions.ContinuePromotionalPurchases()` | `store.AppleStoreExtendedPurchaseService?.ContinuePromotionalPurchases()` | -| `appleExtensions.RegisterPurchaseDeferredListener(cb)` | `store.OnPurchaseDeferred += cb` (event on `StoreController`) | -| `appleExtensions.simulateAskToBuy` | `store.AppleStoreExtendedPurchaseService?.simulateAskToBuy` (null-check required) | -| `appleExtensions.PresentCodeRedemptionSheet()` | `store.AppleStoreExtendedPurchaseService?.PresentCodeRedemptionSheet()` | -| `appleExtensions.RestoreTransactions(cb)` | `store.RestoreTransactions(cb)` | -| `appleExtensions.SetApplicationUsername(hashedString)` | `store.AppleStoreExtendedService?.SetAppAccountToken(Guid)` — must be called **after** `Connect()`; accepts a `Guid` (not a hash) identifying the user account in your system | -| `builder.Configure().SetEntitlementsRevokedListener(cb)` | `store.AppleStoreExtendedPurchaseService.OnEntitlementRevoked += cb` (event on `IAppleStoreExtendedPurchaseService`, null-check required; callback signature: `Action` — receives a single product ID, NOT `List`) | -| `appleExtensions.GetTransactionReceiptForProduct(product)` | `order.Info.Receipt` inside `OnPurchasePending` — per-transaction receipt is now on the order | -| `builder.Configure().canMakePayments` | `store.AppleStoreExtendedService?.canMakePayments` — must be checked **after** `Connect()`, not before initialization | -| `appleExtensions.GetIntroductoryPriceDictionary()` | `store.AppleStoreExtendedProductService?.GetIntroductoryPriceDictionary()` — returns `Dictionary` mapping product store-specific IDs to JSON with intro offer details. **NOT removed**, just moved to the product extension service. `AppleProductMetadata` does NOT expose `introductoryPrice`, `introductoryPriceLocale`, `introductoryNumberOfPeriods`, or `subscriptionPeriod` — those fields do not exist on that class. For introductory price data, use `SubscriptionInfo` methods (`GetIntroductoryPrice()`, `GetIntroductoryPricePeriod()`, `GetIntroductoryPricePeriodCycles()`). | -| `appleExtensions.GetProductDetails()` | `store.AppleStoreExtendedProductService?.GetProductDetails()` — returns `Dictionary`. **NOT removed**, just moved to the product extension service. Basic metadata (title, description, price) is on `product.metadata` directly. | -| `appleExtensions.SetStorePromotionOrder(products)` | `store.AppleStoreExtendedProductService?.SetStorePromotionOrder(products)` — moved to `IAppleStoreExtendedProductService` (null-check required) | -| `appleExtensions.SetStorePromotionVisibility(product, visibility)` | `store.AppleStoreExtendedProductService?.SetStorePromotionVisibility(product, visibility)` — moved to `IAppleStoreExtendedProductService` (null-check required) | -| `appleExtensions.FetchStorePromotionOrder(success, failure)` | `store.AppleStoreExtendedProductService?.FetchStorePromotionOrder(successCb, errorCb)` — moved to `IAppleStoreExtendedProductService` (null-check required; callback signatures: `Action>`, `Action`) | -| `appleExtensions.FetchStorePromotionVisibility(product, success, failure)` | `store.AppleStoreExtendedProductService?.FetchStorePromotionVisibility(product, successCb, errorCb)` — moved to `IAppleStoreExtendedProductService` (null-check required; callback signatures: `Action`, `Action`) | -| `googlePlayConfig.SetDeferredPurchaseListener(cb)` | `store.OnPurchaseDeferred += cb` (event on `StoreController`) | -| `googlePlayConfig.SetDeferredProrationUpgradeDowngradeSubscriptionListener(cb)` | `store.GooglePlayStoreExtendedPurchaseService.OnDeferredPaymentUntilRenewalDate += cb` (event on `IGooglePlayStoreExtendedPurchaseService`, null-check required; callback signature: `Action` — NOT `Action`. Access the product via `deferredOrder.SubscriptionOrdered`) | -| `googlePlayExtensions.UpgradeDowngradeSubscription(currentId, newId, mode)` | `store.GooglePlayStoreExtendedPurchaseService?.UpgradeDowngradeSubscription(currentOrder, newProduct, desiredReplacementMode)` — takes `Order` and `Product` objects instead of string IDs; third parameter is `GooglePlayReplacementMode` (not the deprecated `GooglePlayProrationMode`); call after `Connect()` | -| `googlePlayExtensions.IsPurchasedProductDeferred(product)` | `store.GooglePlayStoreExtendedPurchaseService?.IsOrderDeferred(order)` — renamed and takes `Order` instead of `Product`; marked `[Obsolete]`. Prefer tracking deferred state via `store.OnPurchaseDeferred` / `store.OnPurchasePending` events instead | -| `googlePlayExtensions.RestoreTransactions(cb)` | `store.RestoreTransactions(cb)` — moved to `StoreController` directly | -| `googlePlayConfig.SetObfuscatedAccountId(id)` | `store.GooglePlayStoreExtendedService?.SetObfuscatedAccountId(id)` — moved from config-time to **post-`Connect()`** | -| `googlePlayConfig.SetObfuscatedProfileId(id)` | `store.GooglePlayStoreExtendedService?.SetObfuscatedProfileId(id)` — moved from config-time to **post-`Connect()`** | - -### Subscription Info - -| v4 (Legacy) | v5 (Current) | -|---|---| -| `new SubscriptionManager(product, introJson).getSubscriptionInfo()` | `order.Info.PurchasedProductInfo.FirstOrDefault(p => p.productId == productId)?.subscriptionInfo` — accessed via `IPurchasedProductInfo` on the order's info, NOT on `CartItem`. `CartItem` only has `Product` and `Quantity`. | -| `subscriptionInfo.isSubscribed() == Result.True` | `purchasedProductInfo?.subscriptionInfo?.IsSubscribed() == Result.True` — method is now PascalCase but still returns `Result` enum (True/False/Unsupported), NOT `bool`. Use `== Result.True` for null-safe comparison (returns `false` if the chain is null). Do NOT use `?? false` — `Result?` and `bool` are incompatible types. | -| `product.receipt == null` to check ownership | **Preferred:** use `store.CheckEntitlement(product)` + `store.OnCheckEntitlement` — the recommended v5 ownership check. **Alternative:** track ownership with a `bool` flag updated in `OnPurchasePending` (new purchase) and `OnPurchasesFetched` (restored purchases). | -| `product.metadata.GetAppleProductMetadata()?.isFamilyShareable` | Still valid — `GetAppleProductMetadata()` extension method on `ProductMetadata` is unchanged | - -### Codeless IAP - -| v4 (Legacy) | v5 (Current) | -|---|---| -| `CodelessIAPStoreListener.Instance` | Codeless still works but uses v5 under the hood | -| `CodelessIAPStoreListener.initializationComplete` | `CodelessIAPStoreListener.IsInitialized()` | - -## Key Breaking Changes - -1. **`IStoreListener` is deprecated**: Replace the interface implementation with event subscriptions on `StoreController`. The interface still compiles but is `[Obsolete]`. -2. **Two-step purchase flow is mandatory**: v5 always uses pending → confirm. There is no equivalent of returning `PurchaseProcessingResult.Complete` from `ProcessPurchase`. -3. **`ConfigurationBuilder` is deprecated**: Use `CatalogProvider` for store-specific IDs and payouts, or pass `List` directly to `FetchProducts()`. The class still compiles but is `[Obsolete]`. -4. **Apple local receipt validation is a no-op under StoreKit 2**: `CrossPlatformValidator` itself is not deprecated, but Apple validation silently returns an empty array under StoreKit 2. **Recommended:** use the Google-only constructor `CrossPlatformValidator(GooglePlayTangle.Data(), Application.identifier)`. Single-bundle-ID constructors are `[Obsolete]`. For server-side Apple validation, use `order.Info.Apple?.jwsRepresentation`. -5. **Extensions are direct properties**: No more `GetExtension()` pattern. Access via `store.AppleStoreExtendedService`, `store.AppleStoreExtendedPurchaseService`, `store.GooglePlayStoreExtendedService`, `store.GooglePlayStoreExtendedPurchaseService` (null-check required — only non-null on the matching platform). -6. **`product.receipt` and `product.hasReceipt` are gone**: Use `order.Info.Receipt` from `PendingOrder` for the transaction receipt. For ownership checking, use `store.CheckEntitlement(product)` + `OnCheckEntitlement` (recommended) or track ownership with `bool` flags updated in `OnPurchasePending` and `OnPurchasesFetched`. -7. **`Purchase()` no longer has a payload**: Developer payload support was removed in Google Billing v3. -8. **`ProcessPurchase` logic belongs in two places**: Put it in both `OnPurchasePending` (new purchases) and `OnPurchasesFetched` (restored/existing purchases). -9. **Restore is implicit**: Calling `FetchPurchases()` automatically re-delivers any unconfirmed purchases via `OnPurchasePending`. Explicit `RestoreTransactions()` is still required for the iOS "Restore Purchases" button. -10. **`SubscriptionManager` is replaced**: No more `new SubscriptionManager(product, introJson).getSubscriptionInfo()`. In v5, subscription info is accessed via `order.Info.PurchasedProductInfo.FirstOrDefault(p => p.productId == id)?.subscriptionInfo` — it's on `IPurchasedProductInfo`, NOT on `CartItem`. `CartItem` only has `Product` and `Quantity`. Method names are now PascalCase: `IsSubscribed()` not `isSubscribed()`. -11. **Platform-specific config must happen post-`Connect()`**: `SetAppAccountToken`, `SetObfuscatedAccountId/ProfileId`, `canMakePayments` — all moved from pre-init `ConfigurationBuilder` to the corresponding extended service, only available after `await store.Connect()`. -12. **Apple promotional APIs moved to `IAppleStoreExtendedProductService`**: `SetStorePromotionOrder`, `SetStorePromotionVisibility`, `FetchStorePromotionOrder`, `FetchStorePromotionVisibility` are now on `store.AppleStoreExtendedProductService` (null-check required — only non-null on Apple platforms). No more `GetExtension()` pattern. -13. **`OnPurchaseConfirmed` receives `Order` base type**: You MUST pattern-match `ConfirmedOrder` (success) vs `FailedOrder` (confirmation failed). Do not assume confirmation always succeeds. -14. **Platform-specific events are on extended services, NOT `StoreController`**: `OnPromotionalPurchaseIntercepted` and `OnEntitlementRevoked` are on `AppleStoreExtendedPurchaseService`. `OnDeferredPaymentUntilRenewalDate` is on `GooglePlayStoreExtendedPurchaseService`. Only `OnPurchaseDeferred` (Ask-to-Buy) is directly on `StoreController`. Always null-check the extended service before subscribing to events (use `if (store.AppleStoreExtendedPurchaseService != null)` pattern — `?.` does not work with `+=`). - -## Migration Anti-Patterns - -**Always subscribe to BOTH success and failure events.** Not subscribing to failure events (e.g., `OnProductsFetchFailed`, `OnPurchasesFetchFailed`, `OnStoreDisconnected`) generates runtime warnings and leaves failures unhandled. - -**Always subscribe to `OnPurchaseDeferred`.** This event fires for Ask-to-Buy (iOS parental approval) and Google Play deferred purchases. Not subscribing means deferred purchases are silently ignored. - -**Use `CheckEntitlement` for ownership checks, not manual bool flags.** When migrating `product.hasReceipt` or `product.receipt == null` ownership checks, the recommended v5 pattern is `store.CheckEntitlement(product)` + `store.OnCheckEntitlement`. This works cross-platform and handles edge cases (refunds, subscription expiration) automatically. - -**`StoreConnectionFailureDescription` has `.message`, NOT `.reason`.** When migrating `OnInitializeFailed(error, message)` to `store.OnStoreDisconnected`, the failure description property is `failureDescription.message` (lowercase), not `reason`. - -**`ProductFetchFailed` has `.FailureReason`, NOT `.Message`.** When handling `store.OnProductsFetchFailed`, access the reason via `failure.FailureReason`. - -**Subscribe to events BEFORE calling `Connect()`.** Pending purchases from a previous session may fire immediately on reconnect. If your `OnPurchasePending` handler is not yet registered, those purchases will be missed. - -**`OnPurchaseConfirmed` can receive `FailedOrder`.** The `OnPurchaseConfirmed` event fires with `Order` (base type). Always pattern-match: `ConfirmedOrder` means success, `FailedOrder` means confirmation failed. Do not assume confirmation always succeeds. - -**Use `Awake()` for initialization, not `Start()`.** The official v5 samples use `Awake()` for `StoreController` setup and event subscription. This ensures IAP is initialized before other `Start()` methods that may depend on it. - -## Minimal v5 Example - -```csharp -StoreController m_StoreController; - -async void Awake() -{ - m_StoreController = UnityIAPServices.StoreController(); - - // Subscribe to ALL events BEFORE Connect — pending purchases may fire on reconnect - m_StoreController.OnPurchasePending += (order) => - { - var product = order.CartOrdered.Items().FirstOrDefault()?.Product; - GrantContent(product); - m_StoreController.ConfirmPurchase(order); - }; - m_StoreController.OnPurchaseConfirmed += (order) => - { - switch (order) - { - case ConfirmedOrder: Debug.Log("Purchase confirmed"); break; - case FailedOrder failed: Debug.LogError($"Confirmation failed: {failed.FailureReason}"); break; - } - }; - m_StoreController.OnPurchaseFailed += (failed) => Debug.LogError($"{failed.FailureReason} - {failed.Details}"); - m_StoreController.OnPurchaseDeferred += (deferred) => Debug.Log("Purchase deferred (e.g., Ask-to-Buy)"); - - m_StoreController.OnStoreConnected += OnStoreConnected; - m_StoreController.OnStoreDisconnected += (failure) => Debug.LogError($"Store disconnected: {failure.Message}"); - - await m_StoreController.Connect(); -} - -void OnStoreConnected() -{ - var products = new List - { - new ProductDefinition("com.mygame.coins100", ProductType.Consumable) - }; - - m_StoreController.OnProductsFetched += (fetched) => Debug.Log("Products ready"); - m_StoreController.OnProductsFetchFailed += (failure) => Debug.LogError($"Product fetch failed: {failure.FailureReason}"); - m_StoreController.FetchProducts(products); - - // Restore any pending/unfinished purchases from the platform store - m_StoreController.OnPurchasesFetched += (orders) => Debug.Log($"Restored {orders.PendingOrders.Count} pending purchases"); - m_StoreController.OnPurchasesFetchFailed += (failure) => Debug.LogError($"Purchase fetch failed: {failure.Message}"); - m_StoreController.FetchPurchases(); -} -``` +## Procedure + +Unity IAP v5 replaces the listener-based `IStoreListener` pattern with `StoreController` events, so every IAP call site changes. + +1. Read [Upgrade from IAP v4 to v5](https://docs.unity.com/en-us/iap/upgrade-to-iap-v5.md) in full before changing code. It holds the official v4-to-v5 mapping for initialization, the purchase flow, `ConfigurationBuilder`, entitlement checks, `SubscriptionManager`, receipt validation, platform extensions and Codeless IAP. Reread the matching section whenever a migrated call fails to compile. +2. List every v4 call site in the project (`IStoreListener`, `IDetailedStoreListener`, `UnityPurchasing.Initialize`, `ConfigurationBuilder`, `GetExtension<`, `SubscriptionManager`, `product.receipt`, `product.hasReceipt`). +3. Migrate each call site using the docs mapping, then check it against the [gotchas](#migration-gotchas) below, because they are the mistakes agents make most often in this migration. +4. For a type or member the upgrade page doesn't cover, look it up in the package's `xrefmap.yml` for the installed version. + +## Migration gotchas + +- **Subscribe before `Connect()`.** Pending purchases from a previous session can fire as soon as the store connects. A handler registered later misses them. Initialize in `Awake()` so IAP is ready before other `Start()` methods. +- **`ProcessPurchase` logic goes in two places.** Put it in `OnPurchasePending` (new purchases) and in `OnPurchasesFetched` (restored and existing purchases), because v4 delivered both through `ProcessPurchase`. +- **Returning `PurchaseProcessingResult.Pending`** becomes: keep the `PendingOrder` and don't call `ConfirmPurchase` until ready. There is no equivalent of returning `Complete`. +- **`ConfirmPurchase` takes the `PendingOrder`, not the `Product`.** +- **`OnPurchaseConfirmed` receives the `Order` base type.** Pattern-match `ConfirmedOrder` against `FailedOrder`, because confirmation can fail. +- **Ownership checks.** Replace `product.hasReceipt` and `product.receipt == null` with `store.CheckEntitlement(product)` and `OnCheckEntitlement`, because it handles refunds and expiry across platforms. Use a manual `bool` flag only if the user's design needs it. +- **Subscribe to every failure event and to `OnPurchaseDeferred`.** Missing failure events cause runtime warnings, and missing `OnPurchaseDeferred` silently drops Ask-to-Buy and Google deferred purchases. +- **Failure property names.** `StoreConnectionFailureDescription` has `.Message`, not `.reason`. `ProductFetchFailed` has `.FailureReason`, not `.Message`. +- **Platform config moves after `Connect()`.** `SetAppAccountToken`, `SetObfuscatedAccountId`, `SetObfuscatedProfileId` and `canMakePayments` used to be set on `ConfigurationBuilder` before initialization. In v5 they are on the extended services, which exist only after `await store.Connect()`. +- **`SetApplicationUsername(hash)` becomes `SetAppAccountToken(Guid)`.** It takes a `Guid` that identifies the user's account, not a hash. +- **Platform events are on the extended services, not `StoreController`.** `OnPromotionalPurchaseIntercepted` and `OnEntitlementRevoked` are on `AppleStoreExtendedPurchaseService`. `OnDeferredPaymentUntilRenewalDate` is on `GooglePlayStoreExtendedPurchaseService` and receives a `DeferredPaymentUntilRenewalDateOrder`, not a `Product`. Null-check the service with `if`, because `?.` doesn't work with `+=`. +- **`OnEntitlementRevoked` receives one product ID `string`**, not `List`. +- **`UpgradeDowngradeSubscription` takes `Order` and `Product` objects** instead of string IDs, and a `GooglePlayReplacementMode`, not the deprecated `GooglePlayProrationMode`. +- **Apple promotion and intro-price APIs moved, not removed.** `GetIntroductoryPriceDictionary`, `GetProductDetails` and the `StorePromotion` methods are on `AppleStoreExtendedProductService`. `AppleProductMetadata` has no intro-price properties, so use the `SubscriptionInfo` methods instead. +- **`SubscriptionManager` is replaced** by `order.Info.PurchasedProductInfo`. Subscription info is NOT on `CartItem`. Methods are PascalCase (`IsSubscribed()`) and still return `Result`, so compare with `== Result.True`, not `?? false`. +- **Receipt validation.** Under StoreKit 2, Apple local validation returns an empty array. Use the Google-only `CrossPlatformValidator(GooglePlayTangle.Data(), Application.identifier)` constructor, and send `order.Info.Apple?.jwsRepresentation` to a server for Apple. +- **Don't recommend `ProcessPendingOrdersOnPurchasesFetched(false)`.** It restores v4 behavior but loses the v5 re-delivery of unconfirmed purchases. +- **Codeless:** `CodelessIAPStoreListener.initializationComplete` becomes `CodelessIAPStoreListener.IsInitialized()`. diff --git a/skills/implement-in-app-purchases/references/path-add-iap-to-new-project.md b/skills/implement-in-app-purchases/references/path-add-iap-to-new-project.md index 72b2751..f6435e3 100644 --- a/skills/implement-in-app-purchases/references/path-add-iap-to-new-project.md +++ b/skills/implement-in-app-purchases/references/path-add-iap-to-new-project.md @@ -87,16 +87,16 @@ Create a single `IAPManager` (MonoBehaviour singleton or service, matching proje - Exposes `Buy(string productId)`. - Exposes `RestorePurchases()` — **only** if the product list contains `NonConsumable` or `Subscription` types. - Fires UI-friendly events or callbacks for: `OnInitialized`, `OnProductsLoaded`, `OnPurchaseSuccess`, `OnPurchaseFailed`, `OnPurchaseDeferred`. -- Initializes once in `Awake()` — see the **Initialization Flow** section in SKILL.md for the exact event subscription and `Connect()` ordering rules. +- Initializes once in `Awake()` — see **Core rules > Initialization** in the parent skill file for the event subscription and `Connect()` ordering rules. - Registers all products from a single authoritative product list (not scattered across UI handlers). ### Product catalog -Define products in one place — a `ScriptableObject`, a plain `List`, or a constants class — not inside button click handlers. Wire button click handlers to `IAPManager.Buy(productId)` using the catalog, not hardcoded strings. +Define products in one place, never inside button click handlers, so IDs can't drift between the shop UI and the store. By default, use a static class that holds the product ID constants and builds the `List` passed to `FetchProducts()`. Use a `ScriptableObject` instead only if the project already configures game data through ScriptableObjects, or the user wants designers to edit products in the Inspector. Wire button click handlers to `IAPManager.Buy(productId)` using those constants, not hardcoded strings. ## Step 5 — Purchase Handling Contract -For API mechanics (two-step flow, event names, `ConfirmPurchase` signature) see the **Two-Step Purchase Flow** and **Required Event Subscriptions** sections in SKILL.md. This section covers only the grant-and-save contract that is specific to this path. +For API mechanics (two-step flow, event names, `ConfirmPurchase` signature) read the [Purchases](https://docs.unity.com/en-us/iap/purchases.md) page and **Core rules > Purchases** in the parent skill file. This section covers only the grant-and-save contract that is specific to this path. ### PendingOrder — the save-before-confirm rule @@ -120,7 +120,7 @@ Do not grant anything. Fire `OnPurchaseDeferred` event to update UI ("Purchase p ### FailedOrder -Do not grant anything. Fire `OnPurchaseFailed` with the reason. See **Failure Description Property Names** in SKILL.md for the correct property names per type. +Do not grant anything. Fire `OnPurchaseFailed` with the reason. See **Core rules > Compile errors** in the parent skill file for the correct property names per type. ## Step 6 — Product Type Behavior Rules @@ -135,13 +135,13 @@ Do not grant anything. Fire `OnPurchaseFailed` with the reason. See **Failure De - Unlock a durable entitlement (feature flag, item ownership). - Include `RestorePurchases()` — required on iOS, good practice on Android. -- Re-apply the entitlement on app startup: call `store.FetchPurchases()` and re-check ownership in `OnPurchasesFetched`, or use `store.CheckEntitlement(product)`. See **Entitlement Checking** and **Fetch Existing Purchases** in SKILL.md. +- Re-apply the entitlement on app startup: call `store.FetchPurchases()` and re-check ownership in `OnPurchasesFetched`, or use `store.CheckEntitlement(product)`. See **Core rules > Ownership and subscriptions** in the parent skill file. ### Subscription - Restore or check subscription state on app startup, not only at purchase time. Subscriptions can expire or be cancelled externally. - Use `store.CheckEntitlement(product)` or inspect `OnPurchasesFetched` results to update active/expired/unknown state. -- See **Subscription Info** in SKILL.md for the correct access path (`order.Info.PurchasedProductInfo`, `IsSubscribed() == Result.True`). +- See **Core rules > Ownership and subscriptions** in the parent skill file for the correct access path (`order.Info.PurchasedProductInfo`, `IsSubscribed() == Result.True`). ## Step 7 — Cloud Save Integration @@ -156,9 +156,8 @@ SaveGame\(|SaveDataAsync\(|PlayerPrefs\.SetInt|JsonUtility\.ToJson|JsonConvert\. ### Rules -- **Use the existing save abstraction** — do not create a new save system. -- Prefer methods already in use: `CloudSaveService.Instance.Data.Player.SaveAsync()`, `SaveGame()`, `SaveDataAsync()`, or equivalent. -- If no save system exists, use `PlayerPrefs` as a minimal fallback and document it as a TODO for the developer to upgrade. +- **Use the existing save abstraction** — do not create a new save system. If the project has more than one, use the one that already stores the currency or inventory the purchase credits, so the grant and the balance are saved together. +- If no save system exists, use `PlayerPrefs` as the fallback, because it needs no new package or backend, and list it as a follow-up in the report, since `PlayerPrefs` is local to the device and not safe against tampering. - Save **must complete before `ConfirmPurchase`** is called (see Step 5). ## Step 8 — UI Integration diff --git a/skills/implement-in-app-purchases/references/path-convert-native-google-billing.md b/skills/implement-in-app-purchases/references/path-convert-native-google-billing.md index a40f00f..e80f0c1 100644 --- a/skills/implement-in-app-purchases/references/path-convert-native-google-billing.md +++ b/skills/implement-in-app-purchases/references/path-convert-native-google-billing.md @@ -1,5 +1,7 @@ # Convert Native Google Billing to Unity IAP 5 +Links to other reference files point to files in this `references/` folder. Any other relative paths resolve to the skill directory. + ## Table of Contents - [Trigger Phrases](#trigger-phrases) @@ -225,88 +227,32 @@ If game code calls the old billing API directly (e.g., `GoogleBillingManager.Pur ### Phase 6 — Test Scaffolding -- Add debug logs at each IAP event. - Add editor stubs for sandbox testing. - Add the QA checklist from the test plan below to `IapMigrationNotes.md`. ## Step 7 — Behavior Change Rules -### Acknowledgement and Consumption - -Native billing requires explicit `acknowledgePurchase` or `consumeAsync`. Unity IAP abstracts both through `ConfirmPurchase(pendingOrder)`. Map: - -| Native | Unity IAP | -|---|---| -| `consumeAsync` (consumable) | `ConfirmPurchase(pendingOrder)` after grant | -| `acknowledgePurchase` (non-consumable / subscription) | `ConfirmPurchase(pendingOrder)` after grant | - -**Never call `ConfirmPurchase` before the entitlement is safely granted.** - -### Backend Validation - -| Native field sent | Unity IAP equivalent | -|---|---| -| `purchaseToken` | `order.Info.Receipt` (contains the Google receipt JSON) | -| `originalJson` | Embedded in `order.Info.Receipt` | -| `signature` | Embedded in `order.Info.Receipt` | -| `packageName` | `Application.identifier` | -| `productId` | `pendingOrder.CartOrdered.Items().First().Product.definition.id` | - -If the backend parses `purchaseToken` or `originalJson` fields directly, document the receipt format change as a required backend update. - -### Obfuscated IDs - -If native code sets `setObfuscatedAccountId` / `setObfuscatedProfileId` in `BillingFlowParams`, set the equivalent **after** `await store.Connect()`: - -```csharp -store.GooglePlayStoreExtendedService?.SetObfuscatedAccountId("hashed_id"); -store.GooglePlayStoreExtendedService?.SetObfuscatedProfileId("hashed_profile_id"); -``` +These are the changes that break games or backends after the switch. For the Unity IAP side of each call, read the docs page named in the Full topic map of the parent skill file. -Retrieve per-order: - -```csharp -string accountId = store.GooglePlayStoreExtendedPurchaseService?.GetObfuscatedAccountId(order); -``` - -### Pending Purchases - -If native code handles `BillingClient.enablePendingPurchases()` or checks purchase state for `PENDING`: - -- Unity IAP surfaces deferred purchases via `store.OnPurchaseDeferred`. -- On `OnPurchaseDeferred`: show "pending approval" UI. Do not grant. -- When approved, the store re-delivers via `store.OnPurchasePending`. - -### Subscription Upgrade / Downgrade - -If native code calls `launchBillingFlow` with a `subscriptionUpdateParams` (prorating an existing subscription): - -```csharp -store.GooglePlayStoreExtendedPurchaseService?.UpgradeDowngradeSubscription( - currentOrder, // Order object of the current subscription - newProduct, // Product to upgrade/downgrade to - GooglePlayReplacementMode.ChargeFullPrice // choose appropriate mode -); -``` - -See [platform-notes.md](platform-notes.md) for all `GooglePlayReplacementMode` values. +- **One call replaces acknowledge and consume.** `ConfirmPurchase(pendingOrder)` replaces both `acknowledgePurchase` and `consumeAsync`. Call it only after the entitlement is granted and saved, because a confirmed purchase is never re-delivered. +- **The receipt format changes for the backend.** `purchaseToken`, `originalJson` and `signature` are no longer separate values; they are embedded in `order.Info.Receipt`. If the backend reads them directly, list the receipt format change as a required backend update in `IapMigrationNotes.md`. +- **Obfuscated IDs move after `Connect()`.** Native code sets them on `BillingFlowParams` per purchase. In Unity IAP, set them once on `GooglePlayStoreExtendedService` after `await store.Connect()`, and read them back per order from `GooglePlayStoreExtendedPurchaseService`. See the Google Play notes in [platform-notes.md](platform-notes.md). +- **Pending purchases become deferred orders.** Native `PENDING` purchase state maps to `store.OnPurchaseDeferred`. Show a pending state and don't grant; the approved purchase arrives later through `OnPurchasePending`. +- **Subscription changes take objects, not IDs.** `launchBillingFlow` with `subscriptionUpdateParams` maps to `UpgradeDowngradeSubscription(currentOrder, newProduct, mode)`. Choose the mode as the Google Play notes in [platform-notes.md](platform-notes.md) describe. ## Step 8 — Native → Unity IAP Concept Mapping +Only the mappings that aren't one-to-one renames, or that agents commonly get wrong: + | Native Google Billing | Unity IAP 5.x | |---|---| -| `ProductDetails` | `Product` (via `store.GetProductById`) | -| `Purchase` | `PendingOrder` (on `OnPurchasePending`) | -| `purchaseToken` | `order.Info.Receipt` (contains the token) | -| `originalJson` | embedded in `order.Info.Receipt` | -| `signature` | embedded in `order.Info.Receipt` | -| `acknowledgePurchase` | `store.ConfirmPurchase(pendingOrder)` | -| `consumeAsync` | `store.ConfirmPurchase(pendingOrder)` | +| `ProductDetails` | `Product`, from `store.GetProductById` after `OnProductsFetched` | +| `Purchase` | `PendingOrder`, from `OnPurchasePending` | | `queryPurchasesAsync` | `store.FetchPurchases()` → `OnPurchasesFetched` | | `BillingResult` / `BillingResponseCode` | `FailedOrder.FailureReason` / `StoreConnectionFailureDescription` | -| `ProductType.INAPP` | `ProductType.Consumable` or `ProductType.NonConsumable` (distinguish by consume behavior) | +| `ProductType.INAPP` | `ProductType.Consumable` or `ProductType.NonConsumable`, decided by whether the native code consumes it | | `ProductType.SUBS` | `ProductType.Subscription` | -| `offerToken` / `basePlanId` / `pricingPhases` | **Not directly exposed** — report as blocker | +| `offerToken` / `basePlanId` / `pricingPhases` | **Not exposed** — report as a blocker (Step 4) | ## Step 9 — Rollback Plan diff --git a/skills/implement-in-app-purchases/references/path-convert-native-storekit.md b/skills/implement-in-app-purchases/references/path-convert-native-storekit.md index 6ea8d5c..8aa0bb7 100644 --- a/skills/implement-in-app-purchases/references/path-convert-native-storekit.md +++ b/skills/implement-in-app-purchases/references/path-convert-native-storekit.md @@ -1,5 +1,7 @@ # Convert Native iOS StoreKit to Unity IAP 5 +Links to other reference files point to files in this `references/` folder. Any other relative paths resolve to the skill directory. + ## Table of Contents - [Trigger Phrases](#trigger-phrases) @@ -266,7 +268,6 @@ If game code calls the old native bridge directly (e.g., `NativeStoreKit.Purchas ### Phase 6 — Test Scaffolding -- Add debug logs at each IAP event. - Add editor stubs for sandbox testing. - Add the QA checklist from the test plan below to `IapMigrationNotes.md`. @@ -274,122 +275,32 @@ If game code calls the old native bridge directly (e.g., `NativeStoreKit.Purchas ## Step 8 — Behavior Change Rules -### Receipt Format (backend-breaking change) - -The receipt format is fundamentally different between native StoreKit and Unity IAP. Flag this as a **required backend change** if server-side validation is in use. - -| Native approach | Unity IAP equivalent | -|---|---| -| SK1: `[NSData dataWithContentsOfURL:[[NSBundle mainBundle] appStoreReceiptURL]]` base64-encoded, validated against Apple's `/verifyReceipt` endpoint | `order.Info.Apple?.jwsRepresentation` — a per-transaction JWS string, validated against Apple's App Store Server API | -| SK2: Per-transaction JWS string from `verificationResult` | Same — `order.Info.Apple?.jwsRepresentation` | - -Unity IAP uses StoreKit 2 under the hood (on iOS 15+). The resulting receipt is a JWS per transaction, not the SK1 app receipt bundle. If the backend currently calls `/verifyReceipt` with a base64 receipt, it must migrate to Apple's App Store Server API (`GET /inApps/v1/transactions/{transactionId}`) or use the JWS directly. - -### Transaction Finishing (Acknowledgement / Consumption) +These are the changes that break games or backends after the switch. For the Unity IAP side of each call, read the docs page named in the Full topic map of the parent skill file, and the Apple notes in [platform-notes.md](platform-notes.md). -| Native StoreKit | Unity IAP | -|---|---| -| `SKPaymentQueue.default().finishTransaction(transaction)` — called after grant for all types | `store.ConfirmPurchase(pendingOrder)` — called after grant for all types | - -**Never call `ConfirmPurchase` before the entitlement is safely granted and saved.** - -### Restore Transactions - -| Native StoreKit | Unity IAP | -|---|---| -| `SKPaymentQueue.default().restoreCompletedTransactions()` | `store.RestoreTransactions(callback)` — for the explicit "Restore Purchases" button | -| Checking for existing transactions at startup | `store.FetchPurchases()` — re-delivers any unconfirmed purchases via `OnPurchasePending` | - -`store.FetchPurchases()` should be called at startup. `RestoreTransactions` is only needed for the user-triggered restore button (required on iOS for Apple App Store compliance). - -### App Account Token (replacing `applicationUsername`) - -If the native plugin set `SKMutablePayment.applicationUsername` to associate purchases with a backend user: - -```csharp -// Set after Connect() — accepts a Guid, not a string hash -store.AppleStoreExtendedService?.SetAppAccountToken(Guid.Parse(userAccountGuid)); -``` - -Must be called **after** `await store.Connect()`. The token is attached to the transaction and surfaced in the JWS payload. - -### Ask-to-Buy (Deferred Purchases) - -| Native StoreKit | Unity IAP | -|---|---| -| `SKPaymentTransactionStatePurchasing` + parent approval pending → `paymentQueue:removedTransactions:` | `store.OnPurchaseDeferred` — show pending UI, do not grant | -| Purchase approved → `paymentQueue:updatedTransactions:` with `SKPaymentTransactionStatePurchased` | `store.OnPurchasePending` — grant content, then confirm | - -If the existing plugin simulates Ask-to-Buy via `store.AppleStoreExtendedPurchaseService?.simulateAskToBuy`, that property is still available in Unity IAP 5. - -### Code Redemption Sheet - -If the native plugin calls `SKPaymentQueue.default().presentCodeRedemptionSheet()`: - -```csharp -store.AppleStoreExtendedPurchaseService?.PresentCodeRedemptionSheet(); -``` - -Available after `Connect()`. Null-check required — only non-null on iOS. - -### Promotional Purchases - -If the native plugin intercepts App Store promotional purchases (products promoted on the App Store product page): - -```csharp -// Subscribe after Connect() — if() required (?.+= does not work with events) -if (store.AppleStoreExtendedPurchaseService != null) - store.AppleStoreExtendedPurchaseService.OnPromotionalPurchaseIntercepted += OnPromotionalPurchase; - -void OnPromotionalPurchase(Product product) -{ - // Show confirmation UI, then: - store.AppleStoreExtendedPurchaseService?.ContinuePromotionalPurchases(); -} -``` - ---- +- **The receipt format changes for the backend.** Unity IAP uses StoreKit 2 on iOS 15+, so each transaction has its own JWS (`order.Info.Apple?.jwsRepresentation`) instead of the SK1 base64 app receipt. If the backend calls `/verifyReceipt`, list a migration to Apple's App Store Server API as a required backend change in `IapMigrationNotes.md`. +- **One call finishes every transaction.** `ConfirmPurchase(pendingOrder)` replaces `finishTransaction` for all product types. Call it only after the entitlement is granted and saved, because a finished transaction is never re-delivered. +- **Startup checks and restore are separate.** Call `store.FetchPurchases()` at startup to re-deliver unconfirmed purchases. Use `store.RestoreTransactions(callback)` only for the user's "Restore Purchases" button. +- **`applicationUsername` becomes `SetAppAccountToken(Guid)`**, called after `await store.Connect()`. It takes a `Guid`, not a string hash, so if the native code used a hash, ask the user how to map their account IDs to a `Guid`. +- **Ask-to-Buy maps to `OnPurchaseDeferred`.** Show a pending state and don't grant; the approved purchase arrives through `OnPurchasePending`. +- **Promotional purchases and the code redemption sheet** move to `AppleStoreExtendedPurchaseService`, which exists only after `Connect()` on Apple platforms. ## Step 9 — Native → Unity IAP Concept Mapping -### StoreKit 1 +Only the mappings that aren't one-to-one renames, or that agents commonly get wrong: -| Native StoreKit 1 | Unity IAP 5.x | -|---|---| -| `SKProduct` | `Product` (via `store.GetProductById`) | -| `SKPaymentTransaction` | `PendingOrder` (on `OnPurchasePending`) | -| `SKPaymentTransaction.transactionIdentifier` | `pendingOrder.Info.TransactionID` | -| `SKPaymentTransaction.payment.productIdentifier` | `pendingOrder.CartOrdered.Items().First().Product.definition.id` | -| App receipt (`appStoreReceiptURL` base64) | `order.Info.Apple?.jwsRepresentation` (per-transaction JWS — see receipt format note) | -| `SKPaymentQueue.default().finishTransaction:` | `store.ConfirmPurchase(pendingOrder)` | -| `SKPaymentQueue.default().restoreCompletedTransactions()` | `store.RestoreTransactions(callback)` | -| `SKPaymentTransactionStatePurchased` | `store.OnPurchasePending` | -| `SKPaymentTransactionStateFailed` | `store.OnPurchaseFailed` | -| `SKPaymentTransactionStateDeferred` | `store.OnPurchaseDeferred` | -| `SKPaymentTransactionStateRestored` | `store.OnPurchasesFetched` (via `FetchPurchases`) or `store.OnPurchasePending` (via `RestoreTransactions`) | -| `SKProductsRequest` + `productsRequest:didReceiveResponse:` | `store.FetchProducts(definitions)` → `store.OnProductsFetched` | -| `SKPaymentQueue.canMakePayments()` | `store.AppleStoreExtendedService?.canMakePayments` (after `Connect()`) | -| `SKMutablePayment.applicationUsername` | `store.AppleStoreExtendedService?.SetAppAccountToken(Guid)` (after `Connect()`) | -| `SKPaymentDiscount` (promotional offer) | **Not supported** — report as hard blocker | -| `SKReceiptRefreshRequest` | No direct equivalent — `store.FetchPurchases()` covers re-delivery | -| `SKStorefront` | Not exposed in Unity IAP | -| `product.subscriptionPeriod` | `store.AppleStoreExtendedProductService?.GetProductDetails()` (returns raw JSON) | -| `product.introductoryPrice` | `store.AppleStoreExtendedProductService?.GetIntroductoryPriceDictionary()` | - -### StoreKit 2 - -| Native StoreKit 2 | Unity IAP 5.x | +| Native StoreKit | Unity IAP 5.x | |---|---| -| `Product.products(for:)` | `store.FetchProducts(definitions)` → `store.OnProductsFetched` | -| `product.purchase()` | `store.PurchaseProduct(product)` | -| `Transaction.currentEntitlements` (async stream) | `store.FetchPurchases()` → `store.OnPurchasesFetched` | -| `Transaction.updates` (async stream) | `store.OnPurchasePending` (event-driven) | -| `transaction.finish()` | `store.ConfirmPurchase(pendingOrder)` | -| JWS transaction string from `verificationResult` | `order.Info.Apple?.jwsRepresentation` | -| `Transaction.currentEntitlements` for entitlement check | `store.CheckEntitlement(product)` → `store.OnCheckEntitlement` | -| `winBackOffer` / `eligibleWinBackOffers` | **Not supported** — report as hard blocker | - ---- +| `SKProduct` / SK2 `Product` | `Product`, from `store.GetProductById` after `OnProductsFetched` | +| `SKPaymentTransaction` / SK2 `Transaction` | `PendingOrder`, from `OnPurchasePending` | +| `transactionIdentifier` | `pendingOrder.Info.TransactionID` | +| `SKPaymentTransactionStateRestored` | `OnPurchasesFetched` (from `FetchPurchases`) or `OnPurchasePending` (from `RestoreTransactions`) | +| SK2 `Transaction.updates` | `store.OnPurchasePending` | +| SK2 `Transaction.currentEntitlements` | `store.FetchPurchases()`, or `store.CheckEntitlement(product)` for one product | +| `SKPaymentQueue.canMakePayments()` | `store.AppleStoreExtendedService?.canMakePayments`, after `Connect()` | +| `SKReceiptRefreshRequest` | No equivalent; `store.FetchPurchases()` covers re-delivery | +| `SKStorefront` | `AppleStoreExtendedService.FetchStorefront` from package 5.2; not exposed in 5.0–5.1. Check the installed version's `xrefmap.yml` | +| `product.subscriptionPeriod` / `product.introductoryPrice` | `AppleStoreExtendedProductService.GetProductDetails()` / `GetIntroductoryPriceDictionary()` (raw JSON), or the `SubscriptionInfo` methods | +| `SKPaymentDiscount` (promotional offer), `winBackOffer` | **Not supported** — report as a hard blocker (Step 5) | ## Step 10 — Rollback Plan diff --git a/skills/implement-in-app-purchases/references/path-implement-iap-d2c.md b/skills/implement-in-app-purchases/references/path-implement-iap-d2c.md index 6c93f12..819bba6 100644 --- a/skills/implement-in-app-purchases/references/path-implement-iap-d2c.md +++ b/skills/implement-in-app-purchases/references/path-implement-iap-d2c.md @@ -18,7 +18,7 @@ Use this reference when the user explicitly asks to add IAP D2C Capabilities (third-party payment provider such as Stripe or Coda) to a project. This path is valid only when the project has no IAP, or already has `com.unity.purchasing` v5.x installed. -**Not valid when:** the project has IAP v4, native Google Billing, or a third-party IAP package — resolve those first via `pre-check.md` before returning to this path. +**Not valid when:** the project has IAP v4, native Google Billing, or a third-party IAP package — resolve those first via the pre-check routing before returning to this path. ## Trigger Phrases @@ -30,19 +30,9 @@ Use this reference when the user explicitly asks to add IAP D2C Capabilities (th ## Prerequisites (verify before any changes) -| Requirement | Detail | -|---|---| -| Unity Editor | **2022.3 or later** — IAP 5.4 does not support older Editor versions | -| `com.unity.purchasing` | **v5.4+** | -| `com.unity.services.authentication` | **v3.7.1+** — IAP D2C Capabilities will not initialize without this version or later | -| `com.unity.services.core` | **v1.18.0+** — required by IAP 5.4 | -| Unity Gaming Services initialized | `UnityServices.InitializeAsync()` and sign-in must complete **before** IAP D2C Capabilities initialization | -| Unity Deployment package | Required to deploy product catalogs to the Remote Catalog service (see Step 3) — install via **Window > Package Manager > Unity Registry > Deployment** | -| Unity Cloud project linked | Project must be connected to a Unity Cloud organization | -| Payment provider account | Developer must have a Stripe or Coda account connected in the Unity Cloud IAP dashboard | -| Unity Cloud IAP dashboard setup | Products must be created and deployed to the Remote Catalog in Unity Cloud before the client can fetch them | - -If any prerequisite is missing, stop and list the missing items for the user before writing any code. +1. Read the Prerequisites section of [Integrate D2C payment providers workflow](https://docs.unity.com/en-us/iap/payment-providers/workflow.md). It lists the minimum Editor version, the required package versions (`com.unity.purchasing`, Authentication, Core, Deployment) and the Unity Cloud setup. +2. Check each one against the project: the Editor version in `ProjectSettings/ProjectVersion.txt`, and the package versions in `Packages/packages-lock.json`. +3. If anything is missing, stop and list the missing items for the user before writing any code, because D2C fails at runtime, not at compile time, when a prerequisite is missing. ## Step 1 — Project Scan @@ -78,7 +68,7 @@ Shop|Store|Purchase|Buy|IAP|Product ### If existing `.ucat` files are found -Locate `Assets/**/*.ucat` files. Each file is a JSON product definition (see format below). Use them as-is. +Locate `Assets/**/*.ucat` and `Assets/**/*.catalog.csv` files. Use them as-is (see [Catalog file formats](#catalog-file-formats)). ### If existing IAP 5 `ProductDefinition` objects are found @@ -97,313 +87,82 @@ These are local store products — IAP D2C Capabilities uses a Remote Catalog in Continue with the remaining Consumable and NonConsumable products. If no supported products remain after exclusion, stop and inform the user. -### Product definition format (`.ucat`) - -IAP D2C Capabilities products are defined as JSON files with the `.ucat` extension under `Assets/` — not defined in code. Each file defines one product. The local `.ucat` shape is fed by `Editor/Authoring/Core/Model/CatalogItem.cs`; on upload the SDK converts it to the wire DTO (`CatalogItemDto.cs`) — see "Local vs uploaded shape" below. - -**Identity — filename drives it:** - -The `.ucat` filename (without extension) is the source of truth for two identifiers on load (`Editor/Authoring/IO/CatalogItemLoader.cs`): - -- **`CatalogListingId` = `"catalog/" + `**. Always. This is the id passed to `PurchaseProduct(catalogListingId)` and matched against the remote catalog. It is **never** stored in the `.ucat` JSON body (`[IgnoreDataMember, JsonIgnore]` — see `CatalogItem.cs:41-42`); the `catalog/` prefix is added by the SDK, not the developer. -- **`uSKU` = the JSON `uSKU` field if present, otherwise ``**. Omit `uSKU` from the JSON when you want it to match the filename (the common case). Include it only when the store SKU must differ from the filename (e.g. legacy IDs you can't rename). On save, if `uSKU` equals the filename stem, the writer strips it out again to keep the file lean. - -So a file named `coins_100.ucat` with no `uSKU` field gives you `uSKU = "coins_100"` and `CatalogListingId = "catalog/coins_100"` for free. - -```json -{ - "type": "Consumable", - "productDetails": [ - { - "language": "en_US", - "title": "100 Coins", - "subtitle": "Best value", - "description": "A pack of 100 coins.", - "badge": { "text": "Popular", "imageUrl": "https://example.com/badges/popular.png" } - } - ], - "pricing": [ - { "currencyCode": "USD", "amount": 1.99 }, - { "currencyCode": "EUR", "amount": 1.79, "webshopPrice": 1.49 } - ], - "imageUrl": "https://example.com/img/coins100.png", - "storeIdOverrides": [ - { "store": "apple", "value": "com.mygame.coins100.ios" }, - { "store": "google", "value": "com.mygame.coins100.android" } - ], - "isWebshopAvailable": true, - "categories": ["currency", "starter"], - "hdImages": [ - { "url": "https://example.com/hd/coins100.png", "altText": "100 coins bundle" } - ], - "promotion": { - "type": "Sale", - "startsAt": "2026-12-01T00:00:00Z", - "endsAt": "2026-12-15T23:59:59Z" - } -} -``` +### Catalog file formats -(Add `"uSKU": "..."` explicitly only when overriding the filename-derived value.) - -**Top-level fields:** - -| Field | Type | Required | Notes | -|---|---|---|---| -| `uSKU` | string | | Product ID — equivalent to `ProductDefinition.id` in standard IAP. **Optional in the JSON body** — defaults to the filename stem. Include it only to override. Pattern `^[a-zA-Z0-9._-]+$`, max 141 chars. | -| `type` | enum | ✓ | `"Consumable"`, `"NonConsumable"`, or `"Subscription"`. | -| `productDetails` | array | ✓ | ≥1 entry. See sub-schema below. | -| `pricing` | array | ✓ | ≥1 entry. See sub-schema below. | -| `imageUrl` | string | | HTTPS URL, max 2048 chars. Recommended for shop UI. | -| `storeIdOverrides` | array | | Per-store SKU overrides. See sub-schema below. | -| `isWebshopAvailable` | bool | | **Local toggle only** — never uploaded. Opts the item into the webshop schema variant. When `true`, the SDK adds the `UnityRemoteCatalogWebshop` `$schema` URL and emits `categories` / `hdImages` / `promotion` on upload. When `false`, those webshop fields are stripped on upload and any server-side webshop data is erased. | -| `categories` | string[] | | Webshop taxonomy. Only meaningful when `isWebshopAvailable=true`. | -| `hdImages` | array | | Webshop hero images. Only meaningful when `isWebshopAvailable=true`. See sub-schema below. | -| `promotion` | object | | Webshop promotion overlay. Only meaningful when `isWebshopAvailable=true`. See sub-schema below. | - -**`productDetails[]`:** `language` (required, enum e.g. `en_US`, `fr_FR`), `title` (required, 1–50 chars), `subtitle` (optional, 1–50 chars), `description` (optional, 1–250 chars), `badge` (optional, `{ text (required), imageUrl (optional HTTPS) }`). - -**`pricing[]`:** `currencyCode` (required, ISO 4217 e.g. `"USD"`), `amount` (required, decimal in major units — `1.99`, not micros), `webshopPrice` (optional, decimal in major units — set only when a webshop-only price differs from `amount`; values below `0.001` are treated as unset). - -**`storeIdOverrides[]`:** `store` (required, enum: `"apple"`, `"google"`, `"xbox"`, `"applemacos"`), `value` (required, the store-specific SKU). - -**`hdImages[]`:** `url` (required, HTTPS), `altText` (optional). - -**`promotion`:** `type` (required, enum: `"Sale"`, `"Bonus"`, `"Limited"`), `startsAt` (optional, ISO 8601 UTC), `endsAt` (optional, ISO 8601 UTC). - -**Local vs uploaded shape.** The `.ucat` on disk is what devs edit; the payload sent to the Live Content admin API is converted by `LiveContentConfigClient.ConvertToDto`: - -- `amount` and `webshopPrice` go from decimal major units on disk (`1.99`) to micros integer on the wire (`1990000`). Rounding: `Math.Round(amount * 1_000_000, MidpointRounding.AwayFromZero)`. -- `$schema` is populated by the SDK on upload — never in the on-disk `.ucat`: - - Always: `https://services.api.unity.com/schema-registry/v1/schemas/UnityRemoteCatalog/versions/1.1.0` - - Added when `isWebshopAvailable=true`: `https://services.api.unity.com/schema-registry/v1/schemas/UnityRemoteCatalogWebshop/versions/1.1.0` -- `isWebshopAvailable` is never uploaded — the server infers webshop-ness from the `$schema` array. -- `categories`, `hdImages`, `promotion` are only emitted on upload when `isWebshopAvailable=true`. Toggling it off and re-uploading erases the server-side webshop data. - -Remind the user that `.ucat` files must be **deployed to the Remote Catalog** before the client can fetch them — see Step 3. - -### Product definition format (`.catalog.csv`) - -An alternative to per-item `.ucat` JSON is a single `.catalog.csv` file at any location under `Assets/`. The parser is `Editor/Authoring/Core/IO/CatalogCsvParser.cs`; each row is aggregated by `CatalogListingId` into the same `CatalogItem` model as the `.ucat` path, so uploaded output is identical. - -**Columns (23):** - -| Column | Maps to | Aggregation | -|---|---|---| -| `CatalogListingId` | catalog listing id — row-identity key. If provided, it **must include the `catalog/` prefix** (e.g. `catalog/coins_100`). If blank, the parser derives it as `"catalog/" + Sku`. | per-item | -| `Sku` | `uSKU` — required. Also seeds `CatalogListingId` when that column is blank. | per-item | -| `ProductType` | `type` (Consumable / NonConsumable / Subscription) | per-item | -| `Language` | `productDetails[].language` | per-row | -| `Title` | `productDetails[].title` | per-row | -| `Subtitle` | `productDetails[].subtitle` | per-row | -| `Description` | `productDetails[].description` | per-row | -| `BadgeText` | `productDetails[].badge.text` | per-row | -| `BadgeImageUrl` | `productDetails[].badge.imageUrl` | per-row | -| `CurrencyCode` | `pricing[].currencyCode` | per-row | -| `Amount` | `pricing[].amount` (decimal major units, e.g. `1.99`) | per-row | -| `WebshopPrice` | `pricing[].webshopPrice` (decimal major units) | per-row | -| `ImageUrl` | `imageUrl` | per-item | -| `GoogleOverride` | `storeIdOverrides[store="google"].value` | per-item | -| `AppleOverride` | `storeIdOverrides[store="apple"].value` | per-item | -| `XboxStoreOverride` | `storeIdOverrides[store="xbox"].value` | per-item | -| `MacAppStoreOverride` | `storeIdOverrides[store="applemacos"].value` | per-item | -| `IsWebshopAvailable` | `isWebshopAvailable` (local toggle — see .ucat notes) | per-item | -| `Category` | `categories[]` (one category per row) | per-row, aggregated | -| `HdImageUrl` | `hdImages[].url` (one image per row) | per-row, aggregated | -| `HdImageAltText` | `hdImages[].altText` | per-row, aggregated | -| `PromotionType` | `promotion.type` (Sale / Bonus / Limited) | per-item, first row wins, conflicts on later rows flagged | -| `PromotionStartsAt` | `promotion.startsAt` (ISO 8601 UTC) | per-item, first row wins | -| `PromotionEndsAt` | `promotion.endsAt` (ISO 8601 UTC) | per-item, first row wins | - -**Aggregation rules:** -- **per-item** columns (Sku, ProductType, imageUrl, overrides, IsWebshopAvailable, Promotion*): set once on the first row of a given `CatalogListingId`. Subsequent rows are conflict-checked and flagged if they disagree. -- **per-row** columns without aggregation (Language/Title/Subtitle/Description/Badge*/CurrencyCode/Amount/WebshopPrice): one `productDetails` or `pricing` entry per row. -- **per-row, aggregated** (Category, HdImageUrl, HdImageAltText): each row contributes one entry to the corresponding array on the item. - -**Minimal example (one product, EN + FR localizations, one price):** - -```csv -CatalogListingId,Sku,ProductType,Language,Title,Description,CurrencyCode,Amount,ImageUrl -,com.mygame.coins100,Consumable,en_US,100 Coins,A pack of 100 coins.,USD,1.99,https://example.com/img/coins100.png -,com.mygame.coins100,Consumable,fr_FR,100 Pièces,Un lot de 100 pièces.,EUR,1.79, -``` +D2C products are defined as `.ucat` files (one product per file) or a single `.catalog.csv` file under `Assets/`, not in code. Before creating or editing either format, read [Create your Remote Catalog](https://docs.unity.com/en-us/iap/payment-providers/configure-remote-catalog.md) for the fields, the CSV columns and the validation rules, and reread it if the Deployment window reports a validation error. Prefer one `.ucat` per product for a handful of products, and `.catalog.csv` when the user already keeps products in a spreadsheet. -The `CatalogListingId` column is deliberately blank — the parser derives the value as `"catalog/" + Sku` (yielding `catalog/com.mygame.coins100` here). If you fill it in explicitly, the value **must** include the `catalog/` prefix (e.g. `catalog/coins_100`); a bare value like `coins_100` fails validation because catalog listing IDs must start with `catalog/`. +Gotchas the agent must respect: -The same amount/schema conversion applies on upload as for `.ucat`. +- **The `.ucat` filename drives identity.** `CatalogListingId` is always `"catalog/" + ` and is never written in the JSON. `uSKU` defaults to the filename stem, so omit `uSKU` from the JSON unless the store SKU must differ from the filename. Name files after the product ID so `coins_100.ucat` gives `uSKU = "coins_100"` and `CatalogListingId = "catalog/coins_100"`. +- **In `.catalog.csv`, a filled-in `CatalogListingId` must include the `catalog/` prefix.** Leave the column blank to derive it from `Sku`. +- **Prices are decimal major units** (`1.99`), not micros. The SDK converts them on upload. +- **`isWebshopAvailable` is a local toggle.** Turning it off and redeploying erases the server-side webshop data (`categories`, `hdImages`, `promotion`). Confirm with the user before turning it off on a deployed item. +- **Skip `Subscription` items**, as described above. ## Step 3 — Deploy Catalog to Remote Catalog -`.ucat` and `.catalog.csv` files under `Assets/` are local definitions only. Until they are deployed to the Remote Catalog for the active Unity Cloud environment, `FetchRemoteCatalog()` returns no products and no purchase can be initiated. Deploy from the Editor **before** writing or running the client code in later steps. - -### Deploying from the Editor +`.ucat` and `.catalog.csv` files are local definitions only. Until they are deployed to the Remote Catalog for the active Unity Cloud environment, `FetchRemoteCatalog()` returns no products and no purchase can start. -1. Open **Services > Deployment** in the Unity Editor. If the menu is missing, install the Unity Deployment package via **Window > Package Manager > Unity Registry > Deployment**. -2. The Deployment window lists the `.ucat` and `.catalog.csv` files discovered under `Assets/` alongside any other deployable assets. -3. Select the catalog files and click **Deploy**. -4. On success, the window marks each file as deployed. The products are now fetchable by the client via `FetchRemoteCatalog()` (see Step 5). +1. Follow the upload section of [Create your Remote Catalog](https://docs.unity.com/en-us/iap/payment-providers/configure-remote-catalog.md) to deploy the catalog files from the Deployment window. If the Deployment window is missing, the Deployment package isn't installed (see Prerequisites). +2. Check the environment in **Edit > Project Settings > Services > Environments**, because deployment targets the active environment and each environment has its own catalog. +3. If the user can't deploy yet (for example, no Unity Cloud access), list deployment as a manual step in the report and continue with the client code. -The deployment targets the environment configured in **Edit > Project Settings > Services > Environments** (Development / Staging / Production). To move a catalog between environments, switch the active environment and re-deploy. For environment configuration, project linking, and multi-environment workflows, see the [build-live-games](https://github.com/Unity-Technologies/skills) skill (also shipped with Unity AI Assistant). +For environment configuration, project linking and multi-environment workflows, use the build-live-game skill. ## Step 4 — IAPManager with PaymentProvider -### Key difference from standard IAP 5 - -IAP D2C Capabilities uses a `PaymentProvider.Name` parameter when obtaining the `StoreController`: - -```csharp -// Standard IAP 5 -_storeController = UnityIAPServices.StoreController(); - -// IAP D2C Capabilities — pass the payment provider name -_storeController = UnityIAPServices.StoreController(PaymentProvider.Name); -``` - -`PaymentProvider.Name` is a constant that identifies the third-party provider integration. It does **not** return the display name of the provider (Stripe/Coda). - -### Initialization sequence - -IAP D2C Capabilities requires Unity Gaming Services and player sign-in to complete **before** `StoreController` is obtained. Follow this order: +Read [Initialize Unity IAP](https://docs.unity.com/en-us/iap/set-up-in-app-purchasing.md) and [Initiate a purchase](https://docs.unity.com/en-us/iap/payment-providers/initiate-purchase.md) before writing the IAPManager, and reread them if a D2C call fails to compile or returns nothing. -1. `await UnityServices.InitializeAsync()` -2. Sign the player in via Unity Authentication -3. Only after sign-in succeeds: obtain `StoreController(PaymentProvider.Name)` and call `Connect()` +### Initialization order -Do not call `StoreController(PaymentProvider.Name)` before the player is authenticated. +1. `await UnityServices.InitializeAsync()`. +2. Sign the player in with Unity Authentication. Don't use anonymous sign-in for D2C purchases, because an anonymous player who reinstalls or clears app data loses their purchase history. Use a persistent identity (a linked Unity Authentication account or the user's own identity provider), and ask the user which one if the project has none. +3. Only after sign-in succeeds, obtain `UnityIAPServices.StoreController(PaymentProvider.Name)` and call `Connect()`. -**Anonymous sign-in warning:** Do not use anonymous sign-in as the authentication method for D2C purchases. Anonymous sign-in does not persist purchases after the session token is lost — if the player reinstalls the app or clears app data, their purchase history becomes unrecoverable. Use a persistent identity method (e.g., Unity Authentication with a linked account, or your own identity provider). +`PaymentProvider.Name` identifies the payment provider integration. It is not the provider's display name (Stripe or Coda). ### Payment provider services on the StoreController -The `StoreController` (scoped to `PaymentProvider.Name`) exposes the payment-provider APIs through **two** properties. Use the exact property names below — do not substitute the interface name for the property name, and do not use `PurchaseService.PaymentProviders` (that is a different entry point that returns the purchase-extended interface). Both properties are typed as nullable, but on a `PaymentProvider.Name`-scoped `StoreController` the factory always populates them — they are only `null` when reached from a `StoreController` scoped to another store (Apple / Google), where the payment-provider extensions do not apply. Guard with `?.` if the same code path could see either kind of controller. +The payment-provider APIs are on two properties of the `PaymentProvider.Name`-scoped `StoreController`. The names are asymmetric, and agents often swap them: -| StoreController property | Returns interface | Members used in this doc | +| Property | Interface | Holds | | --- | --- | --- | -| `store.PaymentProviderStoreExtendedService` | `IPaymentProvidersExtendedService` | `GetEligiblePaymentProviders`, `GetPaymentOptionProviderUGUI`, `GetPaymentOptionProviderUITK`, `SetCheckoutPresentationMode`, `SetWebshopPresentationMode`, `SetDeepLinkScheme` | -| `store.PaymentProvidersExtendedPurchaseService` | `IPaymentProvidersExtendedPurchaseService` | `PurchaseProduct`, `RedirectToWebshop`, `GenerateURL`, `SetComplianceCheck`, `SetPaymentProviderOverride` | +| `store.PaymentProviderStoreExtendedService` (singular) | `IPaymentProvidersExtendedService` | eligibility, the Purchase Options UI, presentation modes, `SetDeepLinkScheme` | +| `store.PaymentProvidersExtendedPurchaseService` (plural) | `IPaymentProvidersExtendedPurchaseService` | `PurchaseProduct`, `RedirectToWebshop`, `GenerateURL`, `SetComplianceCheck`, `SetPaymentProviderOverride` | -Note the naming asymmetry: the presentation-service property is singular (`PaymentProvider`**Store**`ExtendedService`) while the purchase-service property is plural (`PaymentProviders`**ExtendedPurchase**`Service`). This mirrors the Apple/Google split (`...ExtendedService` + `...ExtendedPurchaseService`). +Don't use `PurchaseService.PaymentProviders`, because it is a different entry point. Both properties are `null` on a `StoreController` scoped to Apple or Google, so guard with `?.` if the same code can see either kind of controller. ### Coexistence with existing Apple / Google StoreController -If the project already has a `StoreController` for Apple App Store or Google Play (standard IAP 5): - -- **Always create a new, separate `StoreController`** scoped to `PaymentProvider.Name`. Never modify or replace the existing one. -- The two controllers are independent — they manage different products on different billing backends and do not interfere with each other. -- After the new IAP D2C Capabilities `StoreController` is successfully created and connected, **prompt the user:** - - > "An existing Apple/Google StoreController was found. Would you like to: - > - **Keep both** — run Apple/Google billing and IAP D2C Capabilities side by side (existing products stay on Apple/Google, new products use IAP D2C Capabilities) - > - **Remove the Apple/Google StoreController** — migrate fully to IAP D2C Capabilities (note: existing Apple/Google products and purchase history will no longer be managed by the app)" - -- If the user chooses **keep both**: leave the existing `StoreController` and its initialization code untouched. Document the dual-controller setup in code comments. -- If the user chooses **remove**: wrap the existing `StoreController` code in `#if !USE_IAP_D2C_ONLY` guards — do not delete it. Mark it `[Obsolete]` and document the removal decision. Instruct the user to also retire the corresponding products in App Store Connect / Play Console as needed. - -### Checking eligible payment providers - -Before initiating purchase, use `GetEligiblePaymentProviders()` to confirm that at least one payment provider is available for the current user and region. Call this after `Connect()` and `FetchProducts()` succeed: - -```csharp -var svc = store.PaymentProviderStoreExtendedService; -if (svc == null) -{ - // No payment provider service — hide purchase UI or show an error - return; -} - -var eligible = await svc.GetEligiblePaymentProviders(); -if (eligible == null || eligible.Providers.Count == 0) -{ - // No payment providers available — hide purchase UI or show an error - return; -} - -if (!eligible.PaymentOptionPopupEnabled) -{ - // Only one provider available — proceed directly without showing the picker UI -} -``` - -`EligiblePaymentProviders.Providers` is a priority-ordered list of provider names. If `PaymentOptionPopupEnabled` is false, there is only one provider and the built-in picker UI should be suppressed. - -### Built-in payment options picker UI - -Unity IAP 5.4 ships a ready-made Purchase Options UI that presents the player with all eligible payment options (native App Store/Google Play, D2C payment provider, webshop). **Use `ShowPurchaseOption` as the primary purchase entry point** — it is the recommended integration path for D2C payments. - -Obtain `IPaymentOptionProvider` once after connect and store it: - -**UGUI** (for projects using Unity UI): -```csharp -var m_PaymentOptionProvider = store.PaymentProviderStoreExtendedService?.GetPaymentOptionProviderUGUI(); -``` - -**UI Toolkit** (for projects using UI Toolkit): -```csharp -var m_PaymentOptionProvider = store.PaymentProviderStoreExtendedService?.GetPaymentOptionProviderUITK(hostDocument); -``` - -Then initiate purchase with the `catalogListingId`: -```csharp -public async void Buy(string catalogListingId) -{ - var svc = store.PaymentProviderStoreExtendedService; - var eligibility = svc != null ? await svc.GetEligiblePaymentProviders() : null; - if (eligibility?.Providers.Count > 0) - { - // Show the Purchase Options UI — lets the player pick native, D2C, or webshop - await m_PaymentOptionProvider.ShowPurchaseOption(catalogListingId); - } - else if (m_NativeStore != null) - { - // No PSP available — fall back to a NATIVE StoreController (App Store / Google Play). - // `store` above is scoped to PaymentProvider.Name, so calling PurchaseProduct on it - // would still route through the PSP flow (not native billing). The fallback must use - // a separate native-scoped StoreController that the app kept alive alongside `store` — - // see "Coexistence with existing Apple / Google StoreController" above. - m_NativeStore.PurchaseProduct(catalogListingId); - } - else - { - // No PSP available AND no native controller — the sale can't proceed. - // Surface the error to the caller so it can show an appropriate message. - Debug.LogWarning($"No payment path available for {catalogListingId}."); - } -} -``` - -`GetEligiblePaymentProviders()` returns an `EligiblePaymentProviders` bundle. An empty `Providers` list means no routing rules match the player or no payment providers are available for their region. - -**Do not call `PurchaseProduct` on the PSP-scoped `StoreController` as a "native fallback"** — it routes through the payment provider store regardless. Native billing requires a separate `StoreController()` (default constructor = platform default). Projects that already have an Apple/Google `StoreController` should reuse it here; greenfield D2C-only projects have no native fallback path and should surface a "payment unavailable" error instead. - -`EligiblePaymentProviders` also exposes `PaymentOptionPopupEnabled` (server-driven killswitch) — check it before showing the picker if you want to respect the server's suppression. - -### Interpreting the picker result +If the project already has a `StoreController` for Apple App Store or Google Play: -`ShowPurchaseOption(catalogListingId)` returns `Task`. The returned string is the identifier of whatever the player picked: +1. Create a new, separate `StoreController` scoped to `PaymentProvider.Name`. Never modify or replace the existing one, because the two manage different products on different billing backends. +2. After the new controller connects, ask the user: -- `IPaymentOptionProvider.NativeAlias` (`"native"`) — the platform default (App Store / Google Play) at click time. -- `IPaymentOptionProvider.WebshopAlias` (`"webshop"`) — the "Continue with webshop" button (only appears when the listing has a webshop; see `PaymentProviderProductMetadata.hasWebshop` below). -- A specific PSP identifier (`"stripe"`, `"codapay"`, …) — the player picked a payment provider directly. -- `null` — the player dismissed the picker (X button / backdrop tap). + > "An existing Apple/Google StoreController was found. Would you like to: + > - **Keep both** — run Apple/Google billing and IAP D2C Capabilities side by side (existing products stay on Apple/Google, new products use IAP D2C Capabilities) + > - **Remove the Apple/Google StoreController** — migrate fully to IAP D2C Capabilities (note: existing Apple/Google products and purchase history will no longer be managed by the app)" -The cross-product overload `ShowPurchaseOption(IReadOnlyList)` returns `Task` — each `PurchaseOption` is `(StoreName, CatalogListingId, Badge?)` and the returned value is the chosen one (or null on cancel). Failures surface as `PurchaseChoiceFailedException` on the awaited task (carries the `PurchaseOption Choice` and the inner exception). +3. If the user chooses **keep both**, leave the existing controller and its initialization untouched, and document the dual-controller setup in code comments. +4. If the user chooses **remove**, wrap the existing controller code in `#if !USE_IAP_D2C_ONLY` guards rather than deleting it, mark it `[Obsolete]`, and tell the user to retire the matching products in App Store Connect or the Play Console. -### Provider memory +### Purchase entry point -The SDK remembers the last payment provider used by a player on a given device and pre-selects it the next time the Purchase Options picker is shown. This is intentional UX — returning players skip the picker and go straight to their previous provider. +Use the built-in Purchase Options UI (`ShowPurchaseOption(catalogListingId)`) as the purchase entry point, as [Initiate a purchase](https://docs.unity.com/en-us/iap/payment-providers/initiate-purchase.md) describes. Get the `IPaymentOptionProvider` once after `Connect()`, with `GetPaymentOptionProviderUGUI()` or `GetPaymentOptionProviderUITK(hostDocument)` to match the project's UI system. -Implications to be aware of: +Gotchas: -- **Not a bug:** If the picker appears to skip showing options, the player has a remembered provider. This is expected behavior. -- **Testing:** After switching providers in the Unity Dashboard, test devices may still route to the previously remembered provider. Clear app data on the device to reset provider memory during testing. -- **No override API:** There is no programmatic way to clear or override provider memory at runtime. If your UX requires always showing the full picker, present a "Change payment method" option that re-invokes `ShowPurchaseOption` — the picker will still show the remembered provider pre-selected, but the player can change it. +- Call `GetEligiblePaymentProviders()` after `Connect()` and gate the purchase UI on it. An empty `Providers` list means no routing rule matches the player or region. If `PaymentOptionPopupEnabled` is false, skip the picker, because the server has suppressed it. +- **Don't call `PurchaseProduct` on the D2C-scoped controller as a "native fallback".** It still routes through the payment provider. A native fallback needs a separate default `StoreController()`. Reuse the project's existing Apple/Google controller if there is one; in a D2C-only project, show a "payment unavailable" error instead. +- `ShowPurchaseOption(catalogListingId)` returns the player's choice: `IPaymentOptionProvider.NativeAlias` (`"native"`), `IPaymentOptionProvider.WebshopAlias` (`"webshop"`), a provider ID such as `"stripe"`, or `null` if the player dismissed the picker. Treat `null` as a cancel, not an error. +- **The SDK remembers each player's last provider on the device** and preselects it. If the picker seems to skip options, that's expected. During testing, clear app data to reset it, because there is no API to clear it. ### IAPManager responsibilities Same as standard IAP 5 (see `path-add-iap-to-new-project.md` Step 4), with these additions: - Hold the `PaymentProvider.Name`-scoped `StoreController` instance. - Hold the `IPaymentOptionProvider` instance (UGUI or UI Toolkit, matching project's UI system). -- Expose `Buy(string catalogListingId)` — calls `ShowPurchaseOption(catalogListingId)` if eligible, falls back to `PurchaseProduct(catalogListingId)` if not. +- Expose `Buy(string catalogListingId)` — calls `ShowPurchaseOption(catalogListingId)` if eligible, and otherwise falls back to the native controller or reports that payment is unavailable. - Expose `RestorePurchases()` only if NonConsumable products exist. - Ensure `UnityServices.InitializeAsync()` and player sign-in both complete before `StoreController(PaymentProvider.Name)` is obtained and `Connect()` is called. - Call `GetEligiblePaymentProviders()` after connect and use the result to gate purchase UI visibility. @@ -411,188 +170,31 @@ Same as standard IAP 5 (see `path-add-iap-to-new-project.md` Step 4), with these ## Step 5 — Remote Catalog -IAP D2C Capabilities products come from Unity Cloud, not a local `List`. Use `RemoteCatalogProvider` to fetch them: - -```csharp -// 1. Create the provider -var catalogProvider = new RemoteCatalogProvider(); - -// 2. Fetch catalog — no parameters needed, fetches all configured providers -var result = await catalogProvider.FetchRemoteCatalog(); - -if (!result.Success) - throw result.Exception; - -// 3. Pass fetched definitions to the StoreController -var productDefinitions = catalogProvider.GetProducts(); -_storeController.FetchProducts(productDefinitions); -``` - -- `FetchRemoteCatalog` fetches the catalog deployed to the Unity Cloud environment configured in **Edit > Project Settings > Services > Environments** (Development / Staging / Production). -- Call `FetchRemoteCatalog` after `Connect()` succeeds. -- `catalogProvider.GetProducts()` returns `List` — pass directly to `store.FetchProducts()`. -- If `result.Success` is false, do not proceed — surface the error to the user. - -### CatalogListing and multiple offers per product - -IAP D2C Capabilities supports products with multiple purchasable offers, each identified by a `catalogListingId`. A `CatalogListing` groups a `ProductDefinition` with its metadata and availability flag: - -```csharp -// Look up the Product that owns a given catalogListingId (set in Unity Cloud dashboard). -// GetProductByCatalogListingId returns the Product; grab the specific CatalogListing off it. -var product = _storeController.GetProductByCatalogListingId("coins_100_offer_usd"); -if (product != null - && product.catalogListings.TryGetValue("coins_100_offer_usd", out var listing) - && listing.availableToPurchase) -{ - // listing.definition — ProductDefinition (id, type, catalogListingId) - // listing.metadata — ProductMetadata (localizedTitle, localizedPriceString, etc.) - _storeController.PaymentProvidersExtendedPurchaseService? - .PurchaseProduct("coins_100_offer_usd", PaymentProvider.Name); -} -``` - -Use `catalogListingId` when a single product (uSKU) has multiple regional or promotional offers. If products have only one offer each, `GetProductById(uSKU)` is sufficient and `CatalogListing` lookup is not needed. - -### Iterating catalog listings on a fetched product - -After `OnProductsFetched` fires, each `Product` object exposes a `catalogListings` dictionary. Iterate its values to enumerate all available offers for that product: - -```csharp -void OnProductsFetched(List products) -{ - foreach (var product in products) - { - foreach (var catalogListing in product.catalogListings.Values) - { - Debug.Log($"ID: {catalogListing.definition.id} - Price: {catalogListing.metadata.localizedPriceString}"); - } - } -} -``` - -`product.catalogListings` is a `Dictionary` keyed by `catalogListingId`. Use `.Values` to iterate all listings, or index directly by ID when the listing ID is known. - -### Webshop-aware product metadata - -Products fetched through the PaymentProvider store carry an extended metadata type, `PaymentProviderProductMetadata`, that surfaces webshop-specific fields alongside the standard `ProductMetadata`. Retrieve it via the extension method on `ProductMetadata`: - -```csharp -var pspMeta = product.metadata.GetPaymentProviderProductMetadata(); -if (pspMeta?.hasWebshop == true) -{ - // The listing has a webshop configured; you can render a "Continue with webshop" button - // and/or a "save X% in the webshop" badge. - var webshopPrice = pspMeta.localizedWebshopPrice; // decimal? — null when no webshop price - var webshopPriceString = pspMeta.webshopPriceString; // pre-formatted for the current locale -} -``` - -Returns `null` when the product wasn't fetched through the PaymentProvider store. The built-in picker already reads `hasWebshop` to decide whether to append the webshop button on the single-listing form — this API is for custom UIs that need the same signal. - -### Direct webshop launch (no picker) - -Skip the picker entirely and open the webshop for a specific listing (or the generic Unity webshop when `catalogListingId` is null): +D2C products come from the Remote Catalog, not a local `List`. Fetch them with `RemoteCatalogProvider`, as [Initialize Unity IAP](https://docs.unity.com/en-us/iap/set-up-in-app-purchasing.md) shows. -```csharp -var svc = store.PaymentProvidersExtendedPurchaseService; -if (svc == null) -{ - // No payment provider purchase service — surface an error or hide the entry point - return; -} - -await svc.RedirectToWebshop("coins_100_offer_usd"); -``` - -The SDK fetches the webshop URL, runs the registered compliance callback (`SetComplianceCheck`), and opens the URL on approval. Network failures propagate as exceptions on the returned `Task`; compliance rejection routes through the standard `OnPurchaseFailed` path. +- Call `FetchRemoteCatalog()` after `Connect()` succeeds, then pass `catalogProvider.GetProducts()` to `store.FetchProducts()`. +- If the fetch result isn't successful, stop and surface the error, because every later step depends on the catalog. +- `FetchRemoteCatalog()` reads the catalog of the active environment. An empty result usually means the catalog wasn't deployed to that environment (Step 3). +- A product (uSKU) can have several catalog listings, for example regional or promotional offers. `product.catalogListings` is keyed by `catalogListingId`, and `GetProductByCatalogListingId(id)` returns the owning `Product`. Check `listing.availableToPurchase` before offering a listing. If every product has one listing, `GetProductById(uSKU)` is enough. +- For a custom shop UI, `product.metadata.GetPaymentProviderProductMetadata()` returns webshop fields (`hasWebshop`, `localizedWebshopPrice`, `webshopPriceString`). It returns `null` for products not fetched through the payment provider store. +- `PaymentProvidersExtendedPurchaseService.RedirectToWebshop(catalogListingId)` opens the webshop directly, without the picker. It runs the compliance callback first. Network failures throw on the returned `Task`, and a compliance rejection arrives through `OnPurchaseFailed`. ## Step 6 — Deep Link Setup -IAP D2C Capabilities launches the device's mobile browser to handle payment. After payment, the browser must redirect back to the game via a deep link. - -### Choosing a deep link scheme - -**Use app-scheme deep links** (e.g., `mygame://iapresult/okay`) rather than URL-scheme deep links (e.g., `https://mygame.com/iapresult`). - -URL-scheme deep links require the domain to be verified by Google (Digital Asset Links). App-scheme deep links avoid this requirement and are simpler to configure. - -**Prompt the user:** - -> "What redirect URL did you configure in the Unity Cloud IAP payment provider dashboard? For example: `mygame://iapresult/okay`. If you haven't set one yet, set it in the dashboard first before continuing." - -### Registering the deep link scheme - -Unity IAP 5.4 provides a programmatic API to register the deep link scheme directly, without editing `AndroidManifest.xml` manually: - -```csharp -// Pass the SCHEME ONLY (the part before "://"), not the full redirect URL. -store.PaymentProviderStoreExtendedService?.SetDeepLinkScheme("mygame"); -``` - -Call `SetDeepLinkScheme` before `Connect()`. The SDK matches incoming deep links against `:` — passing the full URL (e.g. `"mygame://iapresult/okay"`) produces the prefix `"mygame://iapresult/okay:"` which will never match. Register only the scheme portion of whatever redirect URL is configured in the Unity Cloud IAP payment provider dashboard. - -### AndroidManifest.xml (required in addition to `SetDeepLinkScheme`) - -Android also requires the scheme to be declared in `AndroidManifest.xml` so the OS routes the browser redirect back to the app. `SetDeepLinkScheme` alone is not sufficient on Android — both are needed. - -If the project does not have a custom `AndroidManifest.xml`, instruct the user to enable it: **Edit > Project Settings > Player > Publishing Settings > Custom Main Manifest**. - -Add an intent filter for the deep link scheme inside the `` element: - -```xml - - - - - - -``` - -Replace `mygame` and `iapresult` with the scheme and host from the user's configured redirect URL. - -### Proxy HTML page (recommended for production) - -On some Android devices and iOS configurations, the OS silently drops a direct app-scheme redirect originating from a payment provider domain. To avoid missed redirects, host a lightweight proxy HTML page on a domain you control and use its URL as the Success Redirect URL in the Unity Cloud payment provider dashboard: - -```html - - -Redirecting… - - -

Redirecting back to the app…

- - -``` - -How to set this up: - -1. Host the HTML file at a stable HTTPS URL you control (e.g., `https://mygame.com/iap-redirect.html`). -2. In the Unity Cloud IAP dashboard, set **Success Redirect URL** to the HTTPS proxy URL — **not** the app-scheme URL directly. -3. The proxy page's JavaScript immediately redirects the browser to the app-scheme URL (`mygame://iapresult/okay`), which the OS routes back into the app. -4. The `AndroidManifest.xml` intent filter and `SetDeepLinkScheme` registration remain the same — they handle the final app-scheme leg of the redirect. - -**When to skip this:** If you are testing in the Unity Editor or on a controlled device where direct app-scheme redirects work reliably, the proxy is optional. For production builds targeting a broad range of Android and iOS devices, the proxy is recommended. +D2C checkout runs in a browser, which must redirect back to the game when payment completes. Read [Deep link success URL example](https://docs.unity.com/en-us/iap/payment-providers/success-deep-link.md) and the Unity Manual's [deep linking](https://docs.unity.com/en-us/engine//manual/platform-specific/cross-platform-features/deep-linking.md) page and the platform page it links to before configuring it, and reread them if the redirect doesn't return to the app. In this Manual URL, replace `` with the major.minor of the Editor in `ProjectSettings/ProjectVersion.txt`, not the package version. -### Unity Editor +1. Ask the user: -In the Unity Editor, the purchase callback is received directly without a browser redirect — no additional setup is required for Editor testing. + > "What redirect URL did you configure in the Unity Cloud IAP payment provider dashboard? For example: `mygame://iapresult/okay`. If you haven't set one yet, set it in the dashboard first before continuing." -### Google Play external link validation (dev/QA only) +2. Prefer an app-scheme deep link (`mygame://...`) over an `https://` link, because `https://` links on Android need Google Digital Asset Links domain verification. +3. Call `store.PaymentProviderStoreExtendedService?.SetDeepLinkScheme("mygame")` **before** `Connect()`. Pass the scheme only, not the full URL, because the SDK matches incoming links against `:` and a full URL never matches. +4. On Android, also declare the scheme and host in a custom `AndroidManifest.xml` intent filter, as the Manual's Android deep linking page describes, because `SetDeepLinkScheme` alone doesn't make the OS route the redirect to the app. +5. For production builds, recommend the proxy page from the success deep link example as the Success Redirect URL, because some devices drop a direct app-scheme redirect from a payment provider domain. The app-side setup stays the same. -If testing on Android and running into external link validation issues, the following scripting define can be added for dev/QA builds only: +In the Editor, the purchase callback arrives without a browser redirect, so no deep link setup is needed to test there. -``` -IAP_SKIP_EXTERNAL_LINK_VALIDATION -``` - -**Location:** Edit > Project Settings > Player > Scripting Define Symbols - -**WARNING:** Do not include this define in production builds — it violates Google Play policies. +If Android external link validation blocks dev or QA testing, the `IAP_SKIP_EXTERNAL_LINK_VALIDATION` scripting define bypasses it. Add it only to dev or QA builds and tell the user, because shipping it violates Google Play policies. ## Step 7 — Purchase Handling @@ -600,79 +202,20 @@ Purchase handling follows the same save-before-confirm contract as standard IAP IAP D2C Capabilities-specific notes: -- **Checkout presentation mode:** By default, the payment flow opens in the device's external browser. Unity IAP 5.4 also supports an in-app WebView via `CheckoutPresentationMode`. Set the mode before purchase via `store.PaymentProviderStoreExtendedService` (the `IPaymentProvidersExtendedService` accessor — see "Payment provider services on the StoreController" above): - - ```csharp - // External browser (default) - store.PaymentProviderStoreExtendedService?.SetCheckoutPresentationMode(CheckoutPresentationMode.ExternalBrowser); - - // In-app WebView - store.PaymentProviderStoreExtendedService?.SetCheckoutPresentationMode(CheckoutPresentationMode.WebView); - ``` - - When using `ExternalBrowser`, the game is suspended during payment and resumes via the deep link redirect. When using `WebView`, the game remains active and the WebView is dismissed on completion. `OnPurchasePending` fires in both cases after the payment is processed. - -- **Webshop presentation mode:** `RedirectToWebshop` uses a **separate** `SetWebshopPresentationMode` setter — it is independent of `SetCheckoutPresentationMode` (verified by `PaymentProviderImplTests.TestWebshopModeIsIndependentOfCheckoutMode`). Setting the checkout mode to `WebView` does **not** move the webshop flow into the in-app WebView; without an explicit webshop-mode call, `RedirectToWebshop` opens the external browser. Set it before invoking `RedirectToWebshop`: - - ```csharp - // External browser (default) - store.PaymentProviderStoreExtendedService?.SetWebshopPresentationMode(CheckoutPresentationMode.ExternalBrowser); - - // In-app WebView - store.PaymentProviderStoreExtendedService?.SetWebshopPresentationMode(CheckoutPresentationMode.WebView); - ``` - +- **Checkout presentation mode:** leave the default, the external browser, unless the user asks for an in-app WebView. In that case call `store.PaymentProviderStoreExtendedService?.SetCheckoutPresentationMode(CheckoutPresentationMode.WebView)` before the purchase. With the external browser the game is suspended and resumes through the deep link. With WebView the game stays active. `OnPurchasePending` fires either way. +- **Webshop presentation mode is independent.** `RedirectToWebshop` uses `SetWebshopPresentationMode`, not the checkout mode, so setting checkout to WebView doesn't move the webshop into the WebView. If the user wants both in-app, set both. - `OnPurchaseDeferred` fires if the payment is not immediately completed. Do not grant — show pending UI. - Always confirm (`ConfirmPurchase`) only after entitlement is granted and saved. For consumables, Unity IAP D2C Capabilities prevents re-purchase until the previous order is confirmed. ### Apple and Google external purchase compliance -Apple and Google allow external web payments only in select regions and under their own program rules. Unity does not perform compliance on your behalf — the developer is responsible for eligibility and disclosure requirements. +Apple and Google allow external web payments only in some regions and under their own program rules, and the developer, not Unity, is responsible for eligibility and disclosure. Read [Report transactions to Apple and Google](https://docs.unity.com/en-us/iap/payment-providers/external-purchase-compliance.md) before adding any compliance code, and ask the user which programs and regions they ship in, because the required flow depends on it. -Three compliance tools are available: +Gotchas: -**1. Gate purchases with a compliance callback** - -Register a callback via `SetComplianceCheck` that runs before any purchase is initiated. Return `false` to block the purchase for ineligible players: - -```csharp -store.PaymentProvidersExtendedPurchaseService?.SetComplianceCheck(async (context) => -{ - bool isEligible = await CheckRegionalEligibility(context); - return isEligible; -}); -``` - -The callback runs for `PurchaseProduct` and `Purchase` calls only — it does **not** gate `GenerateURL`. - -**2. Disclose the checkout URL before opening it** - -Some Apple and Google program rules require showing or disclosing the checkout URL to the player before redirecting. Use `GenerateURL` to obtain the URL without opening it, then call `PurchaseProduct` separately to open the checkout: - -```csharp -// Get the URL without opening checkout -string url = await store.PaymentProvidersExtendedPurchaseService.GenerateURL(catalogListingId); - -// Disclose or display the URL to the player, then proceed -store.PaymentProvidersExtendedPurchaseService.PurchaseProduct(catalogListingId, PaymentProvider.Name); -``` - -If `GenerateURL` was already called for the same listing, `PurchaseProduct` reuses the same order — no duplicate charge. - -**3. Attach Apple/Google transaction tokens** - -Some programs require reporting transaction tokens back to Apple or Google. Attach tokens when calling `GenerateURL`: - -```csharp -var tokens = new List -{ - new PaymentProviderToken { store = "apple", token = appleToken, type = "acquisition" }, - new PaymentProviderToken { store = "google", token = googleToken } -}; -await store.PaymentProvidersExtendedPurchaseService.GenerateURL(catalogListingId, tokens); -``` - -Tokens are stored with the order and surfaced in the `order.paid` webhook payload under `externalTransactionTokens`. You can supply up to two tokens per order (e.g., one for EU, one for Japan). +- `SetComplianceCheck` gates `PurchaseProduct` and `Purchase` only. It does **not** gate `GenerateURL`. +- Calling `GenerateURL` and then `PurchaseProduct` for the same listing reuses the same order, so disclosing the URL first doesn't double-charge. +- An order accepts at most two Apple/Google transaction tokens. ## Step 8 — Product Type Behavior @@ -699,17 +242,12 @@ After applying changes, produce a report with these sections: ## Step 11 — Manual Steps (always include in report) -These cannot be automated and must be completed by the developer: - -1. **Product catalog deployment** — deploy `.ucat` / `.catalog.csv` files to the Remote Catalog from the Editor (see Step 3). Alternatively, products can be authored directly in the Unity Cloud IAP dashboard. -2. **Payment provider account** — connect Stripe or Coda account in Unity Cloud IAP dashboard (**IAP > Payment Providers > Connect**). Request enablement from Unity Client Partner with your organization ID if not yet enabled. -3. **Redirect URLs** — set the Success Redirect URL (and optionally Cancel Redirect URL) in the payment provider dashboard (e.g., `mygame://iapresult/okay`). The Cancel Redirect URL adds a back button to the checkout page. -4. **Entitlement delivery method** — in the Unity Dashboard under **IAP > Payment Providers > Entitlement Delivery Method**, select how purchases are fulfilled server-side: - - **Your own server webhooks** — enter your webhook URL; Unity IAP sends `order.paid` events for server-side grant and fulfillment. - - **Cloud Code module** — select a deployed Cloud Code module and endpoint to handle fulfillment without managing your own backend. - - If client-side only (SDK fulfillment via `ConfirmPurchase`), no configuration is needed here — but note that server-side fulfillment is recommended for production. -5. **Routing rules** — in the Unity Dashboard under **IAP > Payment Providers > Provider routing**, add at least one routing rule to activate a payment provider for your players. Without a routing rule no payment provider is offered, even if a provider account is connected. Configure platform, country, and rollout percentage per rule. -6. **Environment selection** — confirm the correct environment (Development / Staging / Production) is set in **Edit > Project Settings > Services > Environments**. -7. **Android deep link verification** — if using URL-scheme deep links, complete Google Digital Asset Links domain verification. (Avoided if using app-scheme deep links.) -8. **Platform compliance review** — external web payments and third-party payment providers are permitted in select regions only. Developer is responsible for determining eligibility and meeting Apple and Google program requirements before shipping. -9. **Prohibited business / content review** — review Stripe's Prohibited/Restricted Businesses list and Coda's Prohibited Content Policy for compliance. +These happen in the Unity Dashboard or with third parties, so the agent can't do them. List each one in the report with a link to the section of [Integrate D2C payment providers workflow](https://docs.unity.com/en-us/iap/payment-providers/workflow.md) that explains it: + +1. **Deploy the catalog** to the Remote Catalog (Step 3), if you couldn't. +2. **Connect a payment provider** (Stripe or Coda). Until then, the environment runs in [test mode](https://docs.unity.com/en-us/iap/payment-providers/test-mode.md). +3. **Set the redirect URLs** to match the deep link scheme you registered in Step 6. +4. **Choose purchase fulfillment**: SDK only, a webhook, or a Cloud Code module. Recommend server-side fulfillment for production. +5. **Add at least one routing rule** ([Add a routing rule](https://docs.unity.com/en-us/iap/payment-providers/routing.md)), because without one no player is offered a payment provider, even with a connected account. +6. **Confirm the active environment** in **Edit > Project Settings > Services > Environments** matches where the catalog was deployed. +7. **Review platform compliance** ([Report transactions to Apple and Google](https://docs.unity.com/en-us/iap/payment-providers/external-purchase-compliance.md)) and the provider's prohibited-business policy before shipping. diff --git a/skills/implement-in-app-purchases/references/platform-notes.md b/skills/implement-in-app-purchases/references/platform-notes.md index ba95209..62cbea5 100644 --- a/skills/implement-in-app-purchases/references/platform-notes.md +++ b/skills/implement-in-app-purchases/references/platform-notes.md @@ -1,204 +1,55 @@ # Unity IAP Platform-Specific Notes -## Table of Contents - -- [Apple App Store Extensions](#apple-app-store-extensions) -- [Google Play Store Extensions](#google-play-store-extensions) -- [Apple JWS Representation (Server-Side Validation)](#apple-jws-representation-server-side-validation) -- [SubscriptionInfo Platform Nuances](#subscriptioninfo-platform-nuances) -- [Cross-Platform Best Practices](#cross-platform-best-practices) -- [Platform Extension Availability](#platform-extension-availability) - -## Apple App Store Extensions - -Access via `store.AppleStoreExtendedPurchaseService` (for purchase features) and `store.AppleStoreExtendedService` (for store features). - -### Promotional Purchases - -Apple can present purchases from the App Store product page. Intercept and handle them: - -```csharp -if (store.AppleStoreExtendedPurchaseService != null) -{ - store.AppleStoreExtendedPurchaseService.OnPromotionalPurchaseIntercepted += (product) => - { - // Player tapped "Buy" on App Store product page - // You can delay the purchase (e.g., show a loading screen first) - Debug.Log($"Promotional purchase intercepted: {product.definition.id}"); - - // When ready, continue the purchase - store.AppleStoreExtendedPurchaseService.ContinuePromotionalPurchases(); - }; -} -``` - -**CRITICAL:** If you subscribe to `OnPromotionalPurchaseIntercepted`, you MUST eventually call `ContinuePromotionalPurchases()` or the purchase will hang indefinitely. - -### Ask-to-Buy (Parental Approval) - -On devices with Family Sharing and a child account, purchases may require parental approval: - -```csharp -store.OnPurchaseDeferred += (deferredOrder) => -{ - // Purchase is waiting for parental approval - // Show UI: "Purchase pending approval" - // Do NOT grant content yet - Debug.Log("Purchase deferred - awaiting approval"); -}; -``` - -The purchase will complete later (triggering `OnPurchasePending`) when approved, or fail when rejected. - -For testing in sandbox: - -```csharp -// Simulate Ask-to-Buy in the editor/sandbox -store.AppleStoreExtendedPurchaseService.simulateAskToBuy = true; -``` - -### Offer Code Redemption - -Present the Apple offer code redemption sheet: - -```csharp -store.AppleStoreExtendedPurchaseService.PresentCodeRedemptionSheet(); -``` - -### Entitlement Revocation - -Listen for revoked entitlements (e.g., refund granted): +This file lists only the platform behavior agents commonly get wrong. For the extension APIs, setup steps and code samples, read the docs pages below before writing the code, and again if a step fails. Look up individual extension types in the package's `xrefmap.yml`. In API URLs, replace `` with the major.minor of `com.unity.purchasing` in `Packages/packages-lock.json`. -```csharp -store.AppleStoreExtendedPurchaseService.OnEntitlementRevoked += (productId) => -{ - Debug.Log($"Entitlement revoked for: {productId}"); - // Remove the content/feature from the player -}; -``` +- [Apple App Store](https://docs.unity.com/en-us/iap/apple-store.md): promotional purchases, Ask-to-Buy, offer code redemption, App Account Token and storefront. +- [Use Google Play billing extensions](https://docs.unity.com/en-us/iap/use-google-play-billing-extensions.md): obfuscated IDs, subscription upgrade and downgrade, and deferred subscription changes. +- [Receipt validation](https://docs.unity.com/en-us/iap/receipt-validation.md): Google local validation and Apple server-side validation. +- [SubscriptionInfo class reference](https://docs.unity.com/en-us/iap/subscriptioninfo-class-reference.md): per-store behavior of each method. -### Store Capabilities - -```csharp -// Check if the device can make payments (parental controls may block) -bool canPay = store.AppleStoreExtendedService.canMakePayments; - -// Set App Account Token for server-to-server verification -store.AppleStoreExtendedService.SetAppAccountToken(Guid.NewGuid()); - -// Fetch storefront info (StoreKit 2 only) -store.AppleStoreExtendedService.FetchStorefront( - (storefront) => Debug.Log($"Storefront: {storefront.CountryCode}"), - (error) => Debug.LogError($"Storefront error: {error}") -); -``` - -## Google Play Store Extensions - -Access via `store.GooglePlayStoreExtendedPurchaseService` (for purchase features) and `store.GooglePlayStoreExtendedService` (for store features). - -### Subscription Upgrade/Downgrade - -Change a player's subscription tier: - -```csharp -// Get the current subscription order and the new product -Order currentOrder = /* the player's current subscription order */; -Product newProduct = store.GetProductById("com.mygame.vip_annual"); - -store.GooglePlayStoreExtendedPurchaseService.UpgradeDowngradeSubscription( - currentOrder, - newProduct, - GooglePlayReplacementMode.ChargeFullPrice -); -``` - -### GooglePlayReplacementMode Values - -| Mode | Behavior | -|---|---| -| `UnknownReplacementMode` | Default | -| `WithTimeProration` | Remaining time is adjusted | -| `ChargeProratedPrice` | Prorated charge for the upgrade | -| `WithoutProration` | New plan starts at next billing | -| `ChargeFullPrice` | Full price charged immediately | -| `Deferred` | Change takes effect at next billing | - -### Obfuscated IDs - -Set obfuscated account/profile IDs for fraud detection: - -```csharp -store.GooglePlayStoreExtendedService.SetObfuscatedAccountId("hashed_account_id"); -store.GooglePlayStoreExtendedService.SetObfuscatedProfileId("hashed_profile_id"); -``` - -Retrieve them from an order: - -```csharp -string accountId = store.GooglePlayStoreExtendedPurchaseService.GetObfuscatedAccountId(order); -string profileId = store.GooglePlayStoreExtendedPurchaseService.GetObfuscatedProfileId(order); -``` - -### Deferred Payment Events +## Table of Contents -```csharp -store.GooglePlayStoreExtendedPurchaseService.OnDeferredPaymentUntilRenewalDate += (deferredOrder) => -{ - Debug.Log($"Payment deferred until renewal: {deferredOrder.SubscriptionOrdered.definition.id}"); -}; -``` +- [Apple App Store](#apple-app-store) +- [Google Play Store](#google-play-store) +- [SubscriptionInfo platform differences](#subscriptioninfo-platform-differences) +- [Cross-platform rules](#cross-platform-rules) -## Apple JWS Representation (Server-Side Validation) +## Apple App Store -For server-side Apple receipt validation (replacing the deprecated `CrossPlatformValidator`): +- If you subscribe to `AppleStoreExtendedPurchaseService.OnPromotionalPurchaseIntercepted`, you MUST eventually call `ContinuePromotionalPurchases()`, because otherwise the promotional purchase hangs indefinitely. +- Ask-to-Buy purchases arrive through `store.OnPurchaseDeferred`. Show a "pending approval" state and don't grant content, because the purchase arrives later through `OnPurchasePending` if a parent approves it, or fails if they decline. +- To test Ask-to-Buy in the sandbox, set `AppleStoreExtendedPurchaseService.simulateAskToBuy = true`. Remove it before release. +- Handle `OnEntitlementRevoked` (for example after a refund) by removing the content. It receives the product ID as a `string`. +- Check `AppleStoreExtendedService.canMakePayments` before showing a store, because parental controls can block payments. +- Apple non-renewable subscriptions can't be restored through `RestoreTransactions`. Track them server-side. -```csharp -store.OnPurchasePending += (pendingOrder) => -{ - // Access the JWS-signed transaction - string jws = pendingOrder.Info.Apple?.jwsRepresentation; - // Send to your server for validation with Apple's App Store Server API (v2) - ValidateOnServer(jws); -}; -``` +## Google Play Store -The `jwsRepresentation` contains a signed JSON Web Signature that can be verified using Apple's public keys. +- `UpgradeDowngradeSubscription` takes the player's current subscription `Order`, the new `Product`, and a `GooglePlayReplacementMode`. Read the [GooglePlayReplacementMode API](https://docs.unity3d.com/Packages/com.unity.purchasing@/api/UnityEngine.Purchasing.GooglePlayReplacementMode.html) page to pick the mode that matches the user's billing intent, and confirm the choice with the user, because each mode charges the player differently. +- Pass hashed values to `SetObfuscatedAccountId` and `SetObfuscatedProfileId`, never raw user IDs, because Google rejects personally identifiable values. +- `OnDeferredPaymentUntilRenewalDate` is on `GooglePlayStoreExtendedPurchaseService`, not on `StoreController`. -## SubscriptionInfo Platform Nuances +## SubscriptionInfo platform differences | Method | Apple | Google | |---|---|---| -| `GetPurchaseDate()` | Returns the **renewal date** (most recent renewal) | Returns the **original purchase date** | -| `GetCancelDate()` | Returns actual cancellation date | Returns `DateTime.MinValue` (not supported) | - -Always check `IsSubscribed()` first — if the result is `Result.Unsupported`, the platform doesn't provide that information. - -## Cross-Platform Best Practices +| `GetPurchaseDate()` | Returns the **most recent renewal** date | Returns the **original purchase** date | +| `GetCancelDate()` | Returns the cancellation date | Returns `DateTime.MinValue` (not supported) | -| Concern | Recommendation | -|---|---| -| Restore button | Required on iOS (Apple guideline). Add a "Restore Purchases" button that calls `store.RestoreTransactions()`. | -| Receipt validation | **Google Play:** Use `CrossPlatformValidator(GooglePlayTangle.Data(), Application.identifier)` for local validation. **Apple (StoreKit 2):** Local validation via `CrossPlatformValidator` is a **no-op** — use `order.Info.Apple?.jwsRepresentation` for server-side validation instead. | -| Pending purchases | Always handle `OnPurchasePending` and call `ConfirmPurchase`. Unconfirmed purchases persist across launches. | -| Product IDs | Use reverse-domain naming: `com.company.game.product` | -| Testing | Use sandbox accounts (Apple) and license testing accounts (Google) for testing without real charges. | -| Subscriptions | Always check `IsSubscribed()` at app launch, not just purchase time. Subscriptions can expire or be cancelled externally. | +Check `IsSubscribed()` first. A `Result.Unsupported` result means the platform doesn't provide that information. -## Platform Extension Availability +## Cross-platform rules -| Extension | iOS | Android | Editor | -|---|---|---|---| -| `AppleStoreExtendedService` | Available | `null` | `null` | -| `AppleStoreExtendedPurchaseService` | Available | `null` | `null` | -| `GooglePlayStoreExtendedService` | `null` | Available | `null` | -| `GooglePlayStoreExtendedPurchaseService` | `null` | Available | `null` | +- Platform extension services are `null` on other platforms and in the Editor: -**CRITICAL:** Always null-check platform extensions before use: + | Extension | iOS | Android | Editor | + |---|---|---|---| + | `AppleStoreExtendedService` | Available | `null` | `null` | + | `AppleStoreExtendedPurchaseService` | Available | `null` | `null` | + | `GooglePlayStoreExtendedService` | `null` | Available | `null` | + | `GooglePlayStoreExtendedPurchaseService` | `null` | Available | `null` | -```csharp -if (store.AppleStoreExtendedPurchaseService != null) -{ - store.AppleStoreExtendedPurchaseService.OnPromotionalPurchaseIntercepted += HandlePromo; -} -``` + Null-check each one with `if` before use, because `?.` doesn't work with `+=`. +- Add a "Restore Purchases" button on iOS that calls `store.RestoreTransactions()`, because Apple's review guidelines require it. +- Check subscription status at every app launch, not only at purchase time, because subscriptions can expire or be cancelled outside the app. +- Test with Apple sandbox accounts and Google license testing accounts, so test purchases aren't charged. diff --git a/skills/implement-in-app-purchases/references/pre-check.md b/skills/implement-in-app-purchases/references/pre-check.md index f621d04..d67b231 100644 --- a/skills/implement-in-app-purchases/references/pre-check.md +++ b/skills/implement-in-app-purchases/references/pre-check.md @@ -1,5 +1,7 @@ # Pre-Check: Project Scan and Path Routing +Links to other reference files point to files in this `references/` folder. Any other relative paths resolve to the skill directory. + ## Table of Contents - [Ambiguous Intent](#ambiguous-intent)