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
| 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
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
- 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. - Sau khi mua, app gọi
reportPurchasekè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. - 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ị
Cho cả hai cách:
- Chat trong app và website, đăng nhập khách bằng
identifyvới user id mà backend của bạn dùng cho họ (xem Ứng dụng di động). 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.
Cho cách A:
- SDK chat có các lệnh mua hàng:
@hatang/chat-react-native0.4.0+,hatang_chat0.6.0+,dev.hatang:chat0.4.0+ hoặcHatangChat0.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.
- 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
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/sdk0.6.0+ (Node.js) hoặchatang0.1.0+ (Python), hoặc REST API.
A1. Truyền mã mua hàng khi mua
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
- Android
- Flutter
- React Native
let token = HatangChat.purchaseToken(userId: currentUser.id) // nil nếu chưa configure
let result = try await product.purchase(options: token.map { [.appAccountToken($0)] } ?? [])
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams))
.setObfuscatedAccountId(HatangChat.purchaseToken(currentUser.id))
.build()
billingClient.launchBillingFlow(activity, params)
// 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
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
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.updatestrên iOS,PurchasesUpdatedListenertrên Android, purchase stream củain_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
- Android
- Flutter
- React Native
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) }
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.
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.
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
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:
- 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ặcskutrên web). - 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ớikind,amount,currencyvàpurchased_at; khi hoàn tiền, gửi lại cùng mã vớistatus: "refunded". - 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
- Python
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,
});
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
| 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
- Đọc bản ghi của một khách:
chat.subscriptions.list({ external_user_id }),chat.purchases.list({ external_user_id }), hoặcforCustomer(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/importvớ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 để biết đủ các trường.
Nhân viên và trợ lý thấy gì
- 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
- 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
| 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 |