Chương 4: In-App Purchase trong Game với App.Iap

Tích hợp hệ thống thanh toán gói mua trong ứng dụng (In-App Purchase) chuẩn Unity IAP 5.x qua giao diện IIapService (truy cập nhanh qua App.Iap). Bảo đảm an toàn giao dịch tuyệt đối với cơ chế cấp hàng 2 bước, sổ cái chống crash PendingOrderStore, tự động hóa xử lý gói Xóa quảng cáo và móc nối xác thực hóa đơn chống gian lận.

1. Mục đích & Triết lý vận hành

Trong phát triển game, lỗi giao dịch IAP là một trong những nguyên nhân hàng đầu khiến người chơi phàn nàn và yêu cầu hoàn tiền (refund). Các tình huống lỗi điển hình gồm:

  • Người chơi đã bị trừ tiền trên App Store / Google Play nhưng game bị crash trước khi kịp cộng vàng/kim cương.
  • Giao diện Shop cấp vật phẩm ngay trong callback của nút bấm mua hàng khi hóa đơn chưa được kho ứng dụng xác nhận.
  • Game cấp hàng hai lần cho cùng một hóa đơn khi người chơi mở lại ứng dụng hoặc mạng bị chập chờn.
Nguyên tắc vàng của MGW SDK: Tách biệt hoàn toàn giữa UI Feedback và Logic Giao hàng

Lệnh gọi App.Iap.Purchase(id, callback) chỉ nhằm mục đích phản hồi giao diện (bật/tắt vòng xoay tải trang, thông báo thành công hoặc thất bại cho người dùng).
Toàn bộ việc cộng tài nguyên, mở khóa nhân vật và lưu trữ dữ liệu bắt buộc phải diễn ra bên trong hàm ủy quyền PurchaseGrantHandler được đăng ký qua App.Iap.SetGrantHandler.

2. Bảng tra cứu API IIapService đầy đủ

Mọi thao tác liên quan đến giao dịch IAP đều thực hiện qua interface TapBrain.Core.Services.IIapService:

Thành viên (Member) Kiểu & Chữ ký Mô tả chi tiết & Hợp đồng
IsReady bool (get) true khi dịch vụ StoreController đã kết nối thành công và tải xong danh mục sản phẩm từ IAP Catalog.
Ready event Action Kích hoạt một lần khi danh mục sản phẩm tải xong và IAP sẵn sàng giao dịch. Không phát lại cho người đăng ký muộn (kiểm tra IsReady trước), và không bao giờ bắn nếu tải sản phẩm thất bại hoặc enableIap = false.
RemoveAdsGranted event Action Bắn ra khi gói Xóa quảng cáo được mua thành công hoặc được khôi phục từ kho ứng dụng.
SetGrantHandler() void SetGrantHandler(PurchaseGrantHandler handler) Đăng ký bộ xử lý giao hàng duy nhất của game. Phải đăng ký thật sớm khi khởi động game. Mọi đơn hàng còn tồn đọng (pending) từ các phiên trước được bàn giao đồng bộ ngay bên trong lệnh gọi này (handler có thể chạy trước khi SetGrantHandler trả về), vì vậy hãy nạp xong dữ liệu lưu của game trước khi đăng ký handler.
Purchase() void Purchase(string productId, Action<PurchaseResult> onDone) Bắt đầu tiến trình mua sản phẩm. Nếu chưa sẵn sàng, onDone được gọi đồng bộ với PurchaseResult.NotReady. Nếu cùng productId đang trong tiến trình xử lý, lệnh gọi thứ hai bị bỏ qua kèm log Warning và onDone của lệnh gọi thứ hai KHÔNG BAO GIỜ được gọi. Vì vậy đừng bật UI chặn (spinner, ShowBlocker) chỉ để chờ callback nếu không chắc không có giao dịch cùng sản phẩm đang chạy: hãy khóa nút mua và chỉ để một nơi gọi Purchase cho mỗi sản phẩm.
Restore() void Restore(Action<bool> onDone) Yêu cầu store khôi phục các giao dịch không tiêu hao (non-consumable) trên iOS hoặc thiết bị mới. Nếu IAP chưa sẵn sàng, onDone(false) được gọi đồng bộ. true chỉ nghĩa là lệnh restore của store thành công (không đảm bảo có sản phẩm nào được khôi phục); callback chạy ngay sau khi SDK yêu cầu tải lại danh sách đơn đã mua, nên gói Xóa quảng cáo được áp dụng ngay sau đó qua RemoveAdsGranted. Core chỉ tự áp dụng gói Xóa quảng cáo; với non-consumable khác, game đọc App.Iap.IsOwned(productId). Gọi Restore lần hai khi lần đầu chưa xong sẽ làm mất callback của lần đầu.
GetLocalizedPrice() string GetLocalizedPrice(string productId) Lấy chuỗi giá tiền bản địa hóa kèm ký hiệu tiền tệ từ store (ví dụ: "$0.99", "23.000 ₫"). Trả về chuỗi rỗng nếu chưa tải xong.
IsOwned() bool IsOwned(string productId) Kiểm tra xem người chơi đã sở hữu sản phẩm vĩnh viễn chưa (hữu ích cho gói Xóa quảng cáo hoặc mở khóa vĩnh viễn). Gói Xóa quảng cáo đúng ngay khi cờ core.remove_ads được đặt; các sản phẩm khác trả false cho tới khi store sẵn sàng và đã tải danh sách đơn đã mua.

Delegate và Enum liên quan

namespace TapBrain.Core.Services
{
    // Delivery delegate. Return true ONLY when the goods are granted AND saved to disk.
    public delegate bool PurchaseGrantHandler(string productId);

    public enum PurchaseResult
    {
        Success,    // Purchase finished and the grant handler already delivered the goods
        Cancelled,  // The player cancelled or closed the payment dialog
        Failed,     // Store error (card, network, refusal) or the receipt was rejected
        Pending,    // Deferred by the store (Ask-to-Buy), or the game handler is missing / refused
        NotReady    // IAP is not initialized yet
    }
}

3. Quy trình cấp hàng 2 bước & Sổ cái PendingOrderStore

Để đảm bảo tính toàn vẹn dữ liệu ngay cả khi ứng dụng bị tắt đột ngột, pin yếu tắt máy hoặc mất sóng mạng giữa chừng, SDK sử dụng mô hình sổ cái nội bộ PendingOrderStore:

Các bước trong luồng giao hàng an toàn

  1. Nhận thông báo từ Store: Khi người chơi bấm thanh toán thành công trên Google Play hoặc App Store, kho ứng dụng kích hoạt sự kiện OnPurchasePending với mã giao dịch TransactionID (txId).
  2. Kiểm tra trùng lặp: SDK kiểm tra xem txId này đã được cấp hàng trong lịch sử chưa (PendingOrderStore.IsGranted(txId)). Nếu đã cấp rồi, SDK lập tức gọi ConfirmPurchase để đóng đơn trên store mà không cấp lại.
  3. Xác thực biên lai (Receipt Validation): Nếu game có đăng ký IReceiptValidator, SDK chuyển biên lai tới để kiểm tra (không có validator thì biên lai được chấp nhận nguyên trạng). Nếu biên lai không hợp lệ, SDK ghi log Error [IAP] Invalid receipt ..., không cấp hàng, không xác nhận đơn với store và callback của Purchase nhận PurchaseResult.Failed.
  4. Ghi nhận vào sổ cái Pending: SDK lưu đơn hàng vào danh sách chờ với thông tin txId, productId và thời gian tạo, sau đó lưu xuống đĩa qua khóa core.iap_orders_v1.
  5. Chuyển giao cho Game xử lý: SDK gọi hàm ủy quyền PurchaseGrantHandler(productId) do game đăng ký.
  6. Game trả về kết quả:
    • Nếu Game trả về true: Hàng đã được cộng và lưu đĩa an toàn. SDK chuyển trạng thái đơn hàng sang granted (lưu tối đa 200 giao dịch gần nhất), gọi ConfirmPurchase để chốt đơn trên store và kích hoạt callback PurchaseResult.Success.
    • Nếu Game trả về false hoặc phát sinh lỗi Exception: Đơn hàng VẪN ĐƯỢC GIỮ NGUYÊN TRẠNG THÁI PENDING ở cả local lẫn trên store. Callback UI nhận PurchaseResult.Pending. Trong lần mở game tiếp theo, khi game vừa gọi SetGrantHandler, đơn hàng này sẽ được tự động kích hoạt cấp hàng lại!
Bảo vệ chống crash (Crash Safety):

Nhờ ghi sổ cái PendingOrderStore.AddPending trước khi gọi game và chỉ gọi ConfirmPurchase sau khi game trả về true, một đơn hàng đã trả tiền sẽ không bị bỏ sót: nếu game crash trước khi đơn được ghi nhận là đã cấp, đơn vẫn còn pending và được giao lại ở lần mở sau.

Giao hàng theo kiểu "ít nhất một lần" (at-least-once): handler phải an toàn khi bị gọi lại

Nếu game crash sau khi handler đã cộng và lưu hàng nhưng trước khi SDK ghi MarkGranted, lần mở game tiếp theo handler sẽ được gọi lại cho cùng đơn hàng. Handler chỉ nhận productId (không có txId) nên game không thể tự phân biệt hai lần giao của cùng một đơn. Với vật phẩm mở khóa (nhân vật, gói vĩnh viễn) hãy viết handler idempotent (đặt cờ "đã sở hữu" thay vì cộng dồn). Với tiền tệ, hãy lưu số dư và mọi trạng thái liên quan trong cùng một lần ghi nguyên tử, và chấp nhận rủi ro hiếm gặp này như một giới hạn của mô hình.

4. Khởi tạo Unity IAP, IAP Catalog & Cảnh báo UGS

Dịch vụ IAP của SDK được xây dựng trên nền tảng Unity IAP phiên bản 5.x (sử dụng API StoreController hiện đại):

Khai báo danh mục sản phẩm (IAPProductCatalog.json)

Tất cả sản phẩm phải được định nghĩa trong file danh mục mặc định của Unity IAP: Assets/Resources/IAPProductCatalog.json. Bạn có thể mở giao diện quản lý bằng menu của Unity:

Services > In-App Purchasing > IAP Catalog...

Trong cửa sổ này, bạn thêm từng sản phẩm với:

  • Product ID: Định danh chung trong game (ví dụ: coins_100, remove_ads).
  • Type: Consumable (tiêu hao: vàng, gem) hoặc Non-Consumable (không tiêu hao: gói xóa quảng cáo, mở khóa vĩnh viễn).
  • Store IDs Override: SDK không hỗ trợ ánh xạ này (nó dùng chính Product ID của catalog làm mã sản phẩm trên store). Product ID trong catalog phải trùng chính xác với mã sản phẩm trên Google Play Console và App Store Connect.

Xử lý cảnh báo Unity Gaming Services (UGS)

Khi bắt đầu khởi tạo (trong bước boot IAP kéo dài tối đa 8 giây), SDK sẽ gọi UnityServices.InitializeAsync() nếu dịch vụ UGS chưa khởi chạy. Nếu dự án chưa được liên kết với một Project trên Unity Cloud Dashboard (trong Project Settings > Services), Console sẽ xuất hiện dòng cảnh báo:

[IAP] Unity Gaming Services init failed (project not linked?), continuing without it: ...

Đây là cảnh báo hoàn toàn bình thường trong quá trình phát triển. Quá trình kết nối với cửa hàng thực tế (Google Play / App Store) không bị phụ thuộc vào UGS và hệ thống vẫn tiếp tục nạp sản phẩm bình thường.

Khi khởi tạo IAP thất bại hoặc quá thời gian

  • Nếu tải danh sách sản phẩm thất bại (ghi [IAP] Products fetch failed: ...) hoặc có ngoại lệ lúc khởi tạo ([IAP] Initialization failed: ...), bước boot kết thúc nhưng IsReady vẫn false trong suốt phiên chơi: mọi Purchase trả NotReady, Ready không bắn và SDK không tự thử lại cho tới khi khởi động lại game.
  • Nếu bước IAP chạy quá 8 giây, boot ghi [Boot] IAP timed out after 8s, continuing và đi tiếp; IAP vẫn tiếp tục chạy nền và có thể sẵn sàng muộn hơn (Ready bắn lúc đó).
  • Đặt enableIap = false thì IAP không bao giờ được khởi tạo (IsReady luôn false).
  • Vì vậy UI shop luôn phải kiểm tra IsReady hoặc đăng ký Ready và hiển thị trạng thái "chưa sẵn sàng" khi cần.

5. Tích hợp gói Xóa quảng cáo (Remove Ads)

Gói Xóa quảng cáo là sản phẩm IAP đặc biệt được SDK Core tự động quản lý trọn vẹn:

  1. Điền mã Product ID của gói vào trường removeAdsProductId trong file CoreConfig.asset (ví dụ: com.mycompany.mygame.remove_ads). Mặc định trường này để trống, nghĩa là game không có gói Xóa quảng cáo. Sản phẩm này cũng phải có trong IAP Catalog với kiểu Non-Consumable.
  2. Khi giao dịch sản phẩm này thành công (hoặc khi người dùng nhấn Restore Purchases), SDK Core tự động nhận diện thông qua Product ID trùng khớp.
  3. Core tự động lưu giá trị true vào khóa core.remove_ads (provider quảng cáo đọc cờ này), và kích hoạt sự kiện App.Iap.RemoveAdsGranted. Game đọc trạng thái bằng App.Iap.IsOwned(removeAdsProductId); AdsPolicy là lớp nội bộ của SDK.
  4. Gói này không được chuyển tiếp tới PurchaseGrantHandler của game để tránh việc game phải viết code xử lý thừa.

6. Xác thực hóa đơn biên lai (Receipt Validation & Tangles)

Để loại bỏ biên lai giả, SDK cung cấp cổng kết nối xác thực biên lai cục bộ thông qua IReceiptValidator. Cơ chế này kiểm tra chữ ký của biên lai do store phát hành; nó không thay thế việc kiểm tra phía server và không chống được máy đã root/jailbreak.

Do các khóa bí mật làm rối mã hóa đơn (IAP Tangles) mang tính đặc thù riêng theo từng tài khoản nhà phát triển và từng dự án game, package SDK không thể nhúng sẵn key của bất kỳ ai. Thay vào đó, mỗi dự án game tự sinh file tangle và đăng ký với SDK.

Các bước thiết lập xác thực biên lai cho dự án của bạn

  1. Lấy chuỗi Base64 License Key từ Google Play Console (mục Monetization setup).
  2. Trong Unity Editor, mở cửa sổ: Services > In-App Purchasing > Receipt Validation Obfuscator.
  3. Dán Google Play Key vào và nhấn Obfuscate Keys. Unity IAP sẽ tự động sinh các file mã nguồn: GooglePlayTangle.cs, AppleTangle.cs và AppleStoreKitTestTangle.cs trong thư mục Assets/Scripts/UnityPurchasing/generated.
  4. Tạo một lớp hiện thực IReceiptValidator và đăng ký thật sớm trước khi Boot khởi chạy bằng thuộc tính AfterAssembliesLoaded. Validator chỉ chạy kiểm tra thật trên thiết bị Android/iOS; trong Editor và FakeStore phải trả true (như mẫu dưới), nếu không mọi giao dịch thử trong Editor sẽ bị từ chối. Nếu game dùng assembly definition, nó phải tham chiếu assembly chứa các file Tangle (UnityPurchasing.Tangles) và assembly bảo mật của Unity IAP:
using UnityEngine;
using TapBrain.Core;
using TapBrain.Core.Services;
#if !UNITY_EDITOR && (UNITY_ANDROID || UNITY_IOS)
using UnityEngine.Purchasing.Security;
#endif

namespace MyGame.Iap
{
    public sealed class GameReceiptValidator : IReceiptValidator
    {
        // Register before Bootstrapper.Boot so orders re-delivered during IAP init are checked too.
        [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
        private static void Register() => SdkModules.RegisterReceiptValidator(new GameReceiptValidator());

        // Contract: must not throw; return true only for a genuine receipt.
        public bool IsValid(string receipt)
        {
#if !UNITY_EDITOR && (UNITY_ANDROID || UNITY_IOS)
            if (string.IsNullOrEmpty(receipt)) return false;

            try
            {
                // Tangle classes are generated by the Receipt Validation Obfuscator.
                var validator = new CrossPlatformValidator(
                    GooglePlayTangle.Data(),
                    AppleTangle.Data(),
                    Application.identifier);
                var result = validator.Validate(receipt);
                return result != null && result.Length > 0;
            }
            catch (IAPSecurityException)
            {
                return false;
            }
            catch (System.Exception e)
            {
                Debug.LogWarning($"[Receipt] Validation error: {e.Message}");
                return false;
            }
#else
            // Editor / fake store receipts cannot be validated: accept them.
            return true;
#endif
        }
    }
}
Mặc định khi chưa có Validator:

Nếu dự án chưa cấu hình các file Tangle hoặc chưa gọi SdkModules.RegisterReceiptValidator, SDK sẽ ghi nhận log Info: [IAP] No IReceiptValidator registered: receipts accepted without local validation và chấp thuận hóa đơn giả định (thuận tiện cho việc chạy thử nghiệm trong Editor và các bản build nội bộ). Ngược lại, một khi đã đăng ký validator thì mọi biên lai (kể cả trong Editor) đều đi qua nó, nên validator của bạn phải tự trả true trong Editor.

7. Cơ chế theo dõi giao dịch trên SayKit

Khi dự án biên dịch với Provider SayKit (cờ SAYKIT_PURCHASING được SDK Settings tự đặt khi chọn SayKit), SDK tự động gắn thêm bộ theo dõi giao dịch SayKitPurchaseTracker thông qua interface nội bộ IPurchaseTracker.

Mỗi khi có một đơn hàng mới với biên lai hợp lệ (chỉ một lần cho mỗi đơn, không lặp lại khi đơn được giao lại ở lần mở sau; chạy trước khi chuyển vào cho Game cấp hàng), hệ thống sẽ tự động gọi lệnh SayKit.trackOrderPurchase để đẩy doanh thu lên máy chủ SayGames phục vụ việc tối ưu hóa chiến dịch quảng cáo người dùng (UA). Quá trình này diễn ra hoàn toàn tự động phía SDK, game developer không cần phải viết thêm bất kỳ lệnh đo lường IAP nào cho SayKit.

8. Các dòng Console Log chuẩn cần xác minh

Trong quá trình kiểm thử thanh toán, lọc Console theo từ khóa [IAP] để đối chiếu với các trạng thái vận hành của SDK. Lưu ý: các dòng mức Info chỉ được in khi CoreConfig.verboseLog bật hoặc trong Development Build; Warning và Error luôn được in.

Dòng Log Console Mức độ Nguyên nhân & Ý nghĩa
[IAP] Ready, {N} products Info Dịch vụ IAP đã kết nối thành công với Store và tải xong N sản phẩm từ catalog.
[IAP] No IReceiptValidator registered: receipts accepted without local validation Info Chưa đăng ký bộ kiểm tra Tangle, biên lai được chấp nhận tự động.
[IAP] Unity Gaming Services init failed (project not linked?), continuing without it: {error} Warning Project chưa link Unity Cloud. Vẫn tiến hành kết nối Store bình thường.
[IAP] Products fetch failed: {reason} Warning Không thể tải danh sách sản phẩm từ kho ứng dụng (thường do mất mạng hoặc chưa cấu hình trên Store). IsReady giữ false cả phiên, mọi Purchase trả NotReady.
[IAP] Initialization failed: {message} Error Khởi tạo Unity IAP gặp sự cố nghiêm trọng. IsReady giữ false.
[Boot] IAP timed out after 8s, continuing Warning Bước boot IAP quá 8 giây nên boot đi tiếp. IAP vẫn chạy nền và có thể sẵn sàng muộn (Ready bắn sau).
[IAP] Purchase of {productId} already in progress Warning Lệnh Purchase thứ hai của cùng sản phẩm khi giao dịch trước chưa kết thúc. Lệnh thứ hai bị bỏ qua và callback của nó không bao giờ được gọi.
[IAP] Invalid receipt for {productId} tx={txId}, not granted Error Biên lai không vượt qua được bài kiểm tra của IReceiptValidator (hoặc validator ném ngoại lệ). Callback của Purchase nhận PurchaseResult.Failed; đơn không được xác nhận với store và sẽ được store gửi lại ở lần mở sau.
[IAP] {productId} tx={txId} kept pending (no handler or handler refused) Warning Game chưa gán SetGrantHandler hoặc hàm ủy quyền trả về false. Đơn hàng tiếp tục được giữ lại.
[IAP] Confirmed tx={txId} Info Đơn hàng đã được xác nhận kết thúc thành công với kho ứng dụng (Google Play / App Store).
[IAP] Restore success={bool} {error} Info Kết quả của thao tác khôi phục giao dịch (Restore Transactions).

9. Hướng dẫn thực hành & Ví dụ mã nguồn C#

Ví dụ 1: Đăng ký Grant Handler sớm & Giao hàng an toàn

Kịch bản: Dịch vụ kinh tế trong game (EconomyService) được tạo đúng một lần ngay ở đầu game (bởi đoạn khởi tạo game của bạn). Nó nạp dữ liệu lưu trước, rồi đăng ký làm người tiếp nhận giao hàng duy nhất; handler tính trạng thái mới, lưu xuống đĩa trước, và chỉ khi lưu thành công mới cập nhật bộ nhớ và trả về true:

using UnityEngine;
using TapBrain.Core;

namespace MyGame.Economy
{
    // Plain class, created exactly once by the game's own startup code (no singleton needed:
    // App.Iap already holds the single registered handler).
    public sealed class EconomyService
    {
        public const string ProductCoins100 = "coins_100";
        public const string ProductCoins500 = "coins_500";
        public const string ProductHeroKnight = "hero_knight";

        private const string GoldKey = "player_gold";
        private const string KnightKey = "hero_unlocked_knight";

        private int _gold;

        public int Gold => _gold;

        public EconomyService()
        {
            // 1) Load saved state FIRST: pending orders are delivered synchronously inside SetGrantHandler.
            _gold = PlayerPrefs.GetInt(GoldKey, 0);

            // 2) Register early so orders left pending by a previous session are delivered at once.
            App.Iap.SetGrantHandler(OnGrantProduct);
        }

        // Note: the remove-ads product never reaches this handler, Core grants it itself.
        private bool OnGrantProduct(string productId)
        {
            int newGold = _gold;
            bool unlockKnight = false;

            switch (productId)
            {
                case ProductCoins100:
                    newGold += 100;
                    break;

                case ProductCoins500:
                    newGold += 500;
                    break;

                case ProductHeroKnight:
                    unlockKnight = true; // Idempotent: a redelivered order only re-sets the flag
                    break;

                default:
                    // Unknown product: refuse so the order stays pending for investigation.
                    Debug.LogError($"[Economy] Unknown product id: {productId}");
                    return false;
            }

            // Persist BEFORE committing to memory and BEFORE returning true.
            if (!TrySave(newGold, unlockKnight)) return false;

            _gold = newGold;
            Debug.Log($"[Economy] Granted {productId}, gold={_gold}");
            return true;
        }

        private static bool TrySave(int gold, bool unlockKnight)
        {
            try
            {
                PlayerPrefs.SetInt(GoldKey, gold);
                if (unlockKnight) PlayerPrefs.SetInt(KnightKey, 1);
                PlayerPrefs.Save();
                return true;
            }
            catch (System.Exception e)
            {
                Debug.LogError($"[Economy] Save failed: {e.Message}");
                return false;
            }
        }
    }
}

Ví dụ 2: Nút mua hàng Shop UI & Khôi phục giao dịch Restore

Kịch bản: Một nút mua hàng trong giao diện Shop. Nút bấm kiểm tra trạng thái sẵn sàng, hiển thị giá bản địa hóa lấy từ Store và chỉ sử dụng callback Purchase cho mục đích phản hồi UI. Vì callback của lệnh Purchase/Restore trùng lặp không bao giờ được gọi, mẫu này không dùng ShowBlocker (bộ đếm tham chiếu sẽ bị kẹt nếu callback không về) mà chỉ khóa chính nút bấm bằng cờ riêng và giả định chỉ một view gọi Purchase cho mỗi sản phẩm:

using UnityEngine;
using UnityEngine.UI;
using TapBrain.Core;
using TapBrain.Core.Services;

namespace MyGame.UI
{
    public sealed class ShopItemView : MonoBehaviour
    {
        [SerializeField] private string _productId = "coins_100";
        [SerializeField] private Button _buyButton;
        [SerializeField] private Text _priceText;
        [SerializeField] private Button _restoreButton; // Usually in the shop corner or Settings

        // A duplicate in-flight Purchase/Restore never calls back, so never start a second one.
        private bool _buying;
        private bool _restoring;

        private void OnEnable()
        {
            _buyButton.onClick.AddListener(OnBuyClicked);
            if (_restoreButton != null)
            {
                _restoreButton.onClick.AddListener(OnRestoreClicked);
#if !UNITY_IOS
                // Apple requires a Restore button on iOS; other platforms do not need it.
                _restoreButton.gameObject.SetActive(false);
#endif
            }

            if (App.Iap.IsReady) UpdatePriceDisplay();
            else App.Iap.Ready += UpdatePriceDisplay; // Ready is not replayed for late subscribers
        }

        private void OnDisable()
        {
            _buyButton.onClick.RemoveListener(OnBuyClicked);
            if (_restoreButton != null) _restoreButton.onClick.RemoveListener(OnRestoreClicked);
            App.Iap.Ready -= UpdatePriceDisplay;
        }

        private void UpdatePriceDisplay()
        {
            string price = App.Iap.GetLocalizedPrice(_productId);
            if (!string.IsNullOrEmpty(price)) _priceText.text = price;
        }

        private void OnBuyClicked()
        {
            if (_buying) return;

            if (!App.Iap.IsReady)
            {
                App.Overlay.Toast("Cửa hàng đang kết nối, vui lòng thử lại sau ít giây!");
                return;
            }

            _buying = true;
            _buyButton.interactable = false;

            App.Iap.Purchase(_productId, result =>
            {
                _buying = false;

                // UI feedback ONLY. The goods are granted by the grant handler, never here.
                ShowResult(result);

                // The view may be destroyed by the time the store answers.
                if (this != null && _buyButton != null) _buyButton.interactable = true;
            });
        }

        private static void ShowResult(PurchaseResult result)
        {
            switch (result)
            {
                case PurchaseResult.Success:
                    App.Overlay.Toast("Mua hàng thành công! Đã thêm vào tài khoản.");
                    break;

                case PurchaseResult.Cancelled:
                    Debug.Log("[Shop] Purchase cancelled by the player");
                    break;

                case PurchaseResult.Pending:
                    // Deferred by the store (Ask-to-Buy) OR the game handler has not delivered yet.
                    App.Overlay.Toast("Giao dịch đang được xử lý, vật phẩm sẽ được cộng khi hoàn tất.");
                    break;

                case PurchaseResult.Failed:
                    App.Overlay.Toast("Giao dịch thất bại. Vui lòng kiểm tra lại phương thức thanh toán.");
                    break;

                case PurchaseResult.NotReady:
                    App.Overlay.Toast("Hệ thống cửa hàng chưa sẵn sàng.");
                    break;
            }
        }

        private void OnRestoreClicked()
        {
            if (_restoring) return;
            _restoring = true;

            App.Iap.Restore(success =>
            {
                _restoring = false;

                // success only means the store restore request worked, not that something was restored.
                App.Overlay.Toast(success
                    ? "Đã gửi yêu cầu khôi phục. Các gói đã mua sẽ được áp dụng."
                    : "Khôi phục thất bại, vui lòng thử lại sau.");
            });
        }
    }
}

10. Các lỗi phổ biến & So sánh Đúng vs Sai

Cách làm sai (Dễ mất mát giao dịch / Sai kiến trúc)
// 1. GRANTING INSIDE THE PURCHASE CALLBACK
App.Iap.Purchase("coins_100", result => {
    if (result == PurchaseResult.Success) {
        // BUG: Success only arrives AFTER the grant handler already delivered the goods,
        // so this grants twice. It also never runs for orders re-delivered on the next launch.
        wallet.AddGold(100);
    }
});

// 2. HANDLER RETURNS TRUE BEFORE SAVING
App.Iap.SetGrantHandler(productId => {
    wallet.AddGold(100);
    return true; // BUG: not saved yet. A crash right after this loses the gold, but the SDK
                 // already marked the order granted and confirmed it at the store.
});

// 3. REGISTERING THE HANDLER TOO LATE
void OpenShopPopup() {
    // BUG: orders left pending by a previous session stay undelivered until the player
    // opens the shop, and the Purchase callback reports Pending in the meantime.
    App.Iap.SetGrantHandler(MyHandler);
}
Cách làm đúng chuẩn SDK
// 1. THE PURCHASE CALLBACK ONLY UPDATES THE UI
App.Iap.Purchase("coins_100", result => {
    // OK: show a toast / reset the buy button
    if (result == PurchaseResult.Success) {
        ShowCongratulationsPopup();
    }
});

// 2. SAVE FIRST, THEN RETURN TRUE
App.Iap.SetGrantHandler(productId => {
    int newGold = wallet.Gold + 100;
    bool saved = wallet.TrySave(newGold); // OK: persist before committing / returning true
    if (saved) wallet.Gold = newGold;
    return saved;
});

// 3. REGISTER AT STARTUP
public EconomyService() {
    // OK: load state, then register in the economy service created at game start.
    // Pending orders from earlier sessions are delivered right away.
    App.Iap.SetGrantHandler(MyHandler);
}

11. Checklist kiểm tra hệ thống IAP

Đảm bảo delegate PurchaseGrantHandler được đăng ký trong pha khởi tạo dịch vụ kinh tế của game, không để tới lúc mở Shop mới gán.
Rà soát toàn bộ code UI, bảo đảm callback của lệnh mua chỉ làm nhiệm vụ thông báo giao diện và điều khiển popup.
Kiểm tra Assets/Resources/IAPProductCatalog.json trùng khớp hoàn toàn với các Product ID đã tạo trên Google Play Console và App Store Connect (SDK không hỗ trợ Store ID Override, nên hai mã phải giống nhau).
Đảm bảo chuỗi removeAdsProductId trong CoreConfig.asset khớp với mã sản phẩm Non-Consumable đã thiết lập.
Xác nhận có nút bấm gọi App.Iap.Restore() trong màn hình Shop hoặc Settings khi chạy trên hệ điều hành iOS để vượt qua kiểm duyệt App Store.
Xác nhận handler lưu dữ liệu xuống đĩa trước khi cập nhật bộ nhớ và trả true, an toàn khi bị gọi lại cho cùng một đơn, và UI không gọi Purchase/Restore lần hai khi lần đầu chưa trả callback.