# Mua trong ứng dụng và gói thuê bao

Khi khách nhắn hỗ trợ từ app của bạn, nhân viên và trợ lý AI cần biết khách đang trả tiền cho gì: gói nào, có tự gia hạn không, hết hạn khi nào, đã hoàn tiền chưa. Hạ Tầng ghi gói thuê bao và giao dịch mua một lần (App Store, Google Play và website của bạn) vào hồ sơ khách và giữ chúng luôn đúng.

App vẫn bán hàng bằng StoreKit hoặc Google Play Billing (hoặc `in_app_purchase` / `react-native-iap`). Hạ Tầng chỉ cần biết từng lần mua, gia hạn, hết hạn và hoàn tiền.

## Chọn cách đưa giao dịch vào Hạ Tầng {#choose}

| | A. Từ app, có kết nối cửa hàng | B. Từ backend của bạn |
|---|---|---|
| Ai kiểm tra giao dịch | Hạ Tầng, với Apple và Google | Server của bạn, như hiện nay |
| Bạn thêm gì | `purchaseToken` và `reportPurchase` trong app; thông báo của cửa hàng gửi về Hạ Tầng | `chat.subscriptions.upsert` và `chat.purchases.upsert` từ server sau mỗi thay đổi |
| Gia hạn, hết hạn, hoàn tiền | Từ thông báo của cửa hàng về Hạ Tầng | Server của bạn gửi khi biết |
| Bán trên web | Gửi từ backend (như cách B) | Có sẵn |
| Hợp với | App mà server chưa tự kiểm tra giao dịch trong cửa hàng | App mà server đã kiểm tra giao dịch, đã giữ URL thông báo của cửa hàng, hoặc bán cả trên web |

Apple chỉ cho một URL App Store Server Notifications cho mỗi môi trường, nên nếu server của bạn đã nhận thông báo này thì chọn B. Có thể chạy cả hai: cùng một giao dịch từ hai phía vẫn chỉ là một bản ghi.

## Cách A hoạt động thế nào {#overview}

```
App ──mua(appAccountToken = purchaseToken(user))──▶ App Store / Google Play
 │                                                          │
 └─ reportPurchase(bằng chứng) ──▶ Hạ Tầng ◀── gia hạn, huỷ, hoàn tiền (thông báo của cửa hàng)
                                     │
                                     └─ xác minh với cửa hàng, ghi vào hồ sơ khách
```

1. **Lúc mua**, app truyền `purchaseToken(userId)` cho cửa hàng. Cửa hàng gửi lại mã này trong mọi thông báo về giao dịch đó, nên việc gia hạn, huỷ hay hoàn tiền nhiều tháng sau vẫn về đúng khách.
2. **Sau khi mua**, app gọi `reportPurchase` kèm bằng chứng của cửa hàng. Hạ Tầng kiểm tra với Apple hoặc Google và gói hiện ngay trên hồ sơ khách, không phải chờ thông báo của cửa hàng.
3. **Về sau**, thông báo của cửa hàng giữ gói luôn đúng. Có khoá App Store Server API, Hạ Tầng còn kiểm tra lại các gói sắp gia hạn mỗi ngày.

## Chuẩn bị {#before-you-start}

Cho cả hai cách:

- **Chat trong app và website**, đăng nhập khách bằng `identify` với user id mà backend của bạn dùng cho họ (xem [Ứng dụng di động](https://hatang.dev/vi/docs/guides/support/mobile-apps.md)). Gói và giao dịch về đúng khách qua id này.
- **Sản phẩm** trên dashboard với loại (gói thuê bao, dùng hết, mua một lần), chu kỳ thanh toán, số ngày dùng thử, mã sản phẩm và giá trên App Store / Google Play, và **SKU** cho những gì bán trên web. Thẻ sản phẩm trong chat khi đó hiện giá của cửa hàng, trợ lý biết các gói của bạn, và bản ghi tìm được sản phẩm. Xem [Bán phần mềm và gói thuê bao](https://hatang.dev/vi/help/products/software-subscriptions).

Cho cách A:

- **SDK chat** có các lệnh mua hàng: `@hatang/chat-react-native` 0.4.0+, `hatang_chat` 0.6.0+, `dev.hatang:chat` 0.4.0+ hoặc `HatangChat` 0.4.0+.
- **Cửa hàng đã kết nối** với app trên dashboard (**Kênh → app của bạn → Thuê bao từ App Store và Google Play**):
  - **App Store**: dán URL thông báo vào App Store Connect cho cả Production và Sandbox; không bắt buộc nhưng nên có khoá App Store Server API (Issuer ID, Key ID, file `.p8`) để Hạ Tầng làm mới gói, kiểm tra mỗi ngày và nhập khách đã mua từ trước.
  - **Google Play**: JSON của service account có quyền xem dữ liệu tài chính trong Play Console, và một Pub/Sub push subscription tới URL mà dashboard hiện, kèm bật thông báo thời gian thực (RTDN). Thiếu service account thì Hạ Tầng không xác minh và không acknowledge được giao dịch Android.

Bài trợ giúp ở trên hướng dẫn từng bước cho cả hai cửa hàng.

Cho cách B:

- **Một API key** có scope `crm:write`, giữ trên server.
- **SDK cho server**: `@hatang/sdk` 0.6.0+ ([Node.js](https://hatang.dev/vi/docs/guides/sdks/node.md)) hoặc `hatang` 0.1.0+ ([Python](https://hatang.dev/vi/docs/guides/sdks/python.md)), hoặc [REST API](https://hatang.dev/vi/docs/reference/api.md).

## A1. Truyền mã mua hàng khi mua {#purchase-token}

`purchaseToken(userId)` được tạo từ site của bạn và user id bạn truyền vào `identify`, nên giống nhau trên mọi thiết bị và cả ở backend (`hatang.chat.subscriptions.purchaseToken(siteId, userId)` trong `@hatang/sdk`). Dùng đúng user id bạn dùng để đăng nhập khách.

**iOS**

```swift
let token = HatangChat.purchaseToken(userId: currentUser.id)   // nil nếu chưa configure
let result = try await product.purchase(options: token.map { [.appAccountToken($0)] } ?? [])
```

**Android**

```kotlin
val params = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(listOf(productParams))
    .setObfuscatedAccountId(HatangChat.purchaseToken(currentUser.id))
    .build()
billingClient.launchBillingFlow(activity, params)
```

**Flutter**

```dart
// in_app_purchase: applicationUserName thành appAccountToken của StoreKit trên iOS
// và obfuscatedAccountId của Google Play trên Android.
final param = PurchaseParam(
  productDetails: details,
  applicationUserName: HatangChat.instance.purchaseToken(currentUser.id),
);
await InAppPurchase.instance.buyNonConsumable(purchaseParam: param); // gói thuê bao cũng vậy
```

**React Native**

```ts
import { requestPurchase } from "react-native-iap"; // hoặc "expo-iap": cùng API

const token = Hatang.purchaseToken(currentUser.id);
await requestPurchase({
  type: "subs", // "in-app" cho sản phẩm mua một lần
  request: {
    apple: { sku, appAccountToken: token },
    google: { skus: [sku], obfuscatedAccountId: token /* , subscriptionOffers: [{ sku, offerToken }] */ },
  },
});
```

## A2. Báo từng giao dịch {#report}

Gọi `reportPurchase` mỗi khi app thấy một giao dịch hoàn tất, không chỉ ngay sau nút mua:

- sau khi mua thành công,
- với giao dịch đến muộn (gia hạn, giao dịch phụ huynh duyệt, thanh toán chờ xử lý vừa xong): `Transaction.updates` trên iOS, `PurchasesUpdatedListener` trên Android, purchase stream của `in_app_purchase` / `react-native-iap`,
- khi khách khôi phục giao dịch (restore).

Truyền bằng chứng của cửa hàng: **signed transaction** (JWS) của StoreKit 2 trên iOS, **purchase token** và loại sản phẩm (`subs` hoặc `inapp`) trên Android. Hạ Tầng xác minh với cửa hàng, ghi gói thuê bao hoặc giao dịch mua một lần cho khách, acknowledge giao dịch Google Play, và để lại một ghi chú nội bộ trong hội thoại đang mở của khách. Báo lại cùng một giao dịch chỉ cập nhật bản ghi cũ, không tạo bản ghi thứ hai.

**iOS**

```swift
func handle(_ result: VerificationResult<Transaction>) async {
    guard case .verified(let tx) = result else { return }
    await HatangChat.reportPurchase(storeProductId: tx.productID,
                                    transactionId: String(tx.originalID),
                                    signedTransaction: result.jwsRepresentation)
    await tx.finish()
}

// Sau product.purchase(…): if case .success(let result) = purchaseResult { await handle(result) }
// Khi mở app, cho gia hạn và giao dịch đến muộn:
Task { for await result in Transaction.updates { await handle(result) } }
// Khôi phục:
for await result in Transaction.currentEntitlements { await handle(result) }
```

**Android**

```kotlin
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List<Purchase>?) {
    purchases.orEmpty().filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }.forEach { p ->
        scope.launch {
            HatangChat.reportPurchase(
                storeProductId = p.products.first(),
                purchaseToken = p.purchaseToken,
                productType = "subs",          // "inapp" cho sản phẩm mua một lần
                // consumable = true,          // sản phẩm dùng hết mà app tự consume (consumeAsync)
            )
        }
    }
}
// Khôi phục: queryPurchasesAsync(QueryPurchasesParams…) rồi báo từng giao dịch như trên.
```

Hạ Tầng tự acknowledge gói thuê bao và sản phẩm mua một lần. Sản phẩm dùng hết thì app tự consume bằng `consumeAsync`.

**Flutter**

```dart
InAppPurchase.instance.purchaseStream.listen((purchases) async {
  for (final p in purchases) {
    if (p.status == PurchaseStatus.purchased || p.status == PurchaseStatus.restored) {
      final ios = Platform.isIOS;
      await HatangChat.instance.reportPurchase(
        platform: ios ? 'ios' : 'android',
        storeProductId: p.productID,
        transactionId: p.purchaseID,
        // iOS: JWS của StoreKit 2 (mặc định từ in_app_purchase_storekit 0.4); Android: purchase token.
        signedTransaction: ios ? p.verificationData.serverVerificationData : null,
        purchaseToken: ios ? null : p.verificationData.serverVerificationData,
        productType: ios ? null : 'subs', // 'inapp' cho sản phẩm mua một lần
      );
    }
    if (p.pendingCompletePurchase) await InAppPurchase.instance.completePurchase(p);
  }
});
// Khôi phục: InAppPurchase.instance.restorePurchases() — giao dịch khôi phục đi qua cùng stream.
```

**React Native**

```ts
import { finishTransaction, getAvailablePurchases, purchaseUpdatedListener } from "react-native-iap"; // hoặc "expo-iap"

purchaseUpdatedListener(async (purchase) => {
  await Hatang.reportPurchase({
    storeProductId: purchase.productId,
    transactionId: purchase.id,
    // purchaseToken là signed transaction (JWS) của StoreKit 2 trên iOS và purchase token của Play trên Android.
    signedTransaction: Platform.OS === "ios" ? purchase.purchaseToken : null,
    purchaseToken: Platform.OS === "android" ? purchase.purchaseToken : null,
    productType: "subs", // "inapp" cho sản phẩm mua một lần
  });
  await finishTransaction({ purchase, isConsumable: false });
});
// Khôi phục: với mỗi p trong await getAvailablePurchases(), báo như trên.
```

Tên hàm và trường ở trên dành cho **react-native-iap 15 trở lên** và **expo-iap 4 trở lên**, hai thư viện dùng chung một API (đã đối chiếu với định nghĩa kiểu đến react-native-iap 17.3 và expo-iap 6.4): `request.apple` / `request.google`, `obfuscatedAccountId` cho Android, và `purchase.purchaseToken` chứa JWS trên iOS. Các bản cũ hơn đặt tên khác (`request.ios` / `request.android`, `obfuscatedAccountIdAndroid`, hoặc `requestSubscription` trước bản 14); hãy nâng cấp trước khi tích hợp.

`reportPurchase` trả về `true` khi giao dịch đã được ghi, `false` khi cửa hàng từ chối bằng chứng, app chưa kết nối cửa hàng đó, hoặc gọi lỗi. **Dù kết quả thế nào, app vẫn finish hoặc acknowledge giao dịch như bình thường**: Hạ Tầng là bản ghi cho hỗ trợ khách hàng, không phải hệ thống mở quyền của app, và giao dịch bị sót vẫn đến qua thông báo của cửa hàng.

Giao dịch báo lên trước khi khách đăng nhập được giữ trên khách ẩn danh, và chuyển sang tài khoản khi bạn gọi `identify`.

## B. Gửi từ backend {#backend}

Server của bạn báo cho Hạ Tầng từng thay đổi mà nó đã biết, dùng đúng user id bạn truyền vào `identify`:

1. **Sau mỗi lần mua, gia hạn, hết hạn hoặc huỷ một gói**, gọi `chat.subscriptions.upsert(platform, id, …)` với trạng thái hiện tại: `status` (`active`, `trial`, `canceled`, `expired`, `refunded`…), `current_period_end`, và sản phẩm (`store_product_id`, hoặc `sku` trên web).
2. **Sau mỗi giao dịch mua một lần** (gói xu, mở khoá, khoá học), gọi `chat.purchases.upsert(platform, id, …)` với `kind`, `amount`, `currency` và `purchased_at`; khi hoàn tiền, gửi lại cùng mã với `status: "refunded"`.
3. **Một lần**, gửi lịch sử: mọi gói và giao dịch bạn đang có, để khách cũ cũng hiện.

Gửi lại cùng một bản ghi chỉ cập nhật nó (gói không đổi thì không ghi thêm vào lịch sử), nên server có thể gửi lại toàn bộ thông tin của một người sau mỗi thay đổi.

**Node.js**

```ts
import { Hatang } from "@hatang/sdk"; // 0.6.0+

const hatang = new Hatang(process.env.HATANG_API_KEY);

// Gói bán trên website
await hatang.chat.subscriptions.upsert("web", subscription.id, {
  external_user_id: user.id, sku: "PRO-1M", status: "active", auto_renew: false,
  started_at: "2026-10-01T00:00:00Z", current_period_end: "2026-11-01T00:00:00Z", amount: 99000, currency: "VND",
});
// Gói App Store mà server đã xác minh
await hatang.chat.subscriptions.upsert("ios", originalTransactionId, {
  external_user_id: user.id, store_product_id: "com.example.pro.monthly", status: "active", current_period_end: expiresAt,
});
// Giao dịch mua một lần; khi hoàn tiền, gửi lại cùng mã với status "refunded"
await hatang.chat.purchases.upsert("web", order.id, {
  external_user_id: user.id, store_product_id: "credits.300", sku: "XU-300", kind: "consumable",
  amount: 29000, currency: "VND", purchased_at: order.paidAt,
});
```

**Python**

```python
from hatang import Hatang  # pip install hatang

hatang = Hatang()  # đọc HATANG_API_KEY

# Gói bán trên website
hatang.chat.subscriptions.upsert("web", subscription.id, {
    "external_user_id": str(user.id), "sku": "PRO-1M", "status": "active", "auto_renew": False,
    "started_at": "2026-10-01T00:00:00Z", "current_period_end": "2026-11-01T00:00:00Z", "amount": 99000, "currency": "VND",
})
# Gói App Store mà server đã xác minh
hatang.chat.subscriptions.upsert("ios", original_transaction_id, {
    "external_user_id": str(user.id), "store_product_id": "com.example.pro.monthly", "status": "active",
    "current_period_end": expires_at,
})
# Giao dịch mua một lần; khi hoàn tiền, gửi lại cùng mã với status "refunded"
hatang.chat.purchases.upsert("web", order.id, {
    "external_user_id": str(user.id), "store_product_id": "credits.300", "sku": "XU-300", "kind": "consumable",
    "amount": 29000, "currency": "VND", "purchased_at": order.paid_at.isoformat(),
})
```

### Gửi mã nào {#backend-only}

| Loại | Platform | Mã |
|---|---|---|
| Gói bán trên website | `web` | Mã gói của riêng bạn |
| Gói App Store | `ios` | `originalTransactionId` của Apple (không đổi qua các lần gia hạn) |
| Gói Google Play | `android` | Purchase token |
| Mua một lần trên website | `web` | Mã đơn của bạn (mỗi dòng của giỏ hàng một bản ghi, ví dụ `order_981:0`) |
| Mua một lần trên App Store | `ios` | `transactionId` của Apple |
| Mua một lần trên Google Play | `android` | Order id của Play (`GPA.…`), hoặc purchase token |

Sản phẩm được tìm theo `product_id`, theo `sku` trong danh mục của bạn, hoặc (App Store, Google Play) theo mã sản phẩm của cửa hàng. Trên web, `store_product_id` là mã riêng của bạn cho thứ đã bán; nó hiện ra khi không khớp sản phẩm nào trong danh mục.

Nếu app của bạn dùng cả cách A, giao dịch trong cửa hàng sẽ gặp đúng các bản ghi này: Hạ Tầng dùng cùng các mã đó làm khoá.

### Đọc và nhập {#backend-read}

- Đọc bản ghi của một khách: `chat.subscriptions.list({ external_user_id })`, `chat.purchases.list({ external_user_id })`, hoặc `forCustomer(customerId)`.
- Chỉ cho cách A: nhập khách đã mua trong cửa hàng một lần, sau khi kết nối: `POST /chat/subscriptions/import` với app id và tối đa 500 mã (transaction id của Apple, cần khoá App Store Server API; purchase token gói thuê bao của Google Play). Nhân viên cũng làm được việc này trên dashboard.

Xem [Support REST API](https://hatang.dev/vi/docs/guides/support/rest-api.md) để biết đủ các trường.

## Nhân viên và trợ lý thấy gì {#what-they-see}

- Khung thông tin khách trong hộp thư liệt kê các gói (App Store, Google Play hoặc Web; trạng thái như dùng thử, đang hoạt động, gia hạn trễ, đã huỷ hay đã hoàn tiền; ngày gia hạn hoặc hết hạn) và các giao dịch mua một lần. Khi đã kết nối cửa hàng (cách A), nút **Làm mới** hỏi lại cửa hàng (trên iOS cần khoá App Store Server API).
- Trợ lý AI nhận một dòng về gói hiện tại của khách, nên trả lời được "khi nào gói của tôi gia hạn?" hay "tôi bị trừ tiền hai lần" bằng dữ liệu thật.
- Giao dịch sandbox và thử nghiệm được đánh dấu **thử nghiệm**, không lẫn với giao dịch thật.

## Thử trong sandbox {#testing}

- **iOS**: mua bằng Sandbox Apple Account (file StoreKit configuration trong Xcode chỉ để thử giao diện: giao dịch StoreKit cục bộ không được Apple ký, nên Hạ Tầng không xác minh được). Trang thông báo trong App Store Connect có nút **Request a Test Notification** để thử URL.
- **Android**: thêm license tester trong Play Console và cài app từ track internal testing. Gói thử nghiệm gia hạn vài phút một lần, tiện để xem việc gia hạn đến.
- Mở hội thoại của khách trong hộp thư: gói phải hiện trên hồ sơ trong vài giây sau `reportPurchase`.
- **Cách B**: gửi một gói và một giao dịch cho một người dùng thử đã mở chat, rồi xem hồ sơ của họ. Với giao dịch sandbox của cửa hàng, gửi `environment: "sandbox"` để được đánh dấu đúng.

## Xử lý lỗi {#troubleshooting}

| Bạn thấy | Nguyên nhân thường gặp |
|---|---|
| Hồ sơ không có gì sau khi mua | Không truyền bằng chứng (`signedTransaction` / `purchaseToken`), app chưa kết nối cửa hàng đó, hoặc chưa gọi `reportPurchase` cho giao dịch đó. Khi đó chỉ có ghi chú nội bộ |
| Gia hạn hay hoàn tiền không về, hoặc không gắn với ai | Giao dịch mua không có `purchaseToken(userId)`, hoặc dùng user id khác với id truyền vào `identify`. Nhập gói từ dashboard để gắn lại |
| `Bundle id … is not this app's` | Bundle id trong App Store Connect khác với bundle id nhập cho app trên dashboard |
| `Connect Google Play for this app first` | App chưa có package name và service account |
| Google tự hoàn tiền giao dịch Android sau ba ngày | Không ai acknowledge: kết nối service account, hoặc acknowledge trong app |
| Giao dịch iOS không bao giờ được xác minh khi thử | Đang thử bằng StoreKit cục bộ trong Xcode; dùng Sandbox Apple Account |
| Cách B: có bản ghi nhưng không gắn với khách | `external_user_id` khác với id truyền vào `identify`, hoặc người đó chưa từng mở chat (bản ghi hiện khi họ mở) |
| Cách B: khung khách hiện mã thay vì tên sản phẩm | Không có sản phẩm nào mang SKU hoặc mã cửa hàng đó: điền mã cho sản phẩm trong danh mục |

Source: https://hatang.dev/vi/docs/guides/support/in-app-purchases
