Bazaar: subscriptions (subscribeProduct), dynamic price token, RSA key; Zarinpal and Bazaar side by side at checkout

- Checkout sheet always shows both methods as two tiles; Bazaar tile disabled with the reason
  (not in Bazaar / Android app only / update the app) instead of hidden; default is Bazaar inside an app that can buy.
- Bridge v2: TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken); inAndroidApp() split
  from canBuyFromBazaar() so the pay.html workaround still applies to the installed APK.
- Bazaar flow: order -> optional discount code -> POST /payment/bazaar/orders/{id}/start -> Poolakey -> verify;
  consume only packs.
- Android: RSA key, subscribeProduct/getSubscribedProducts for subscriptions, dynamicPriceToken, versionCode 2.
  compileDebugKotlin green; no purchase tested on a phone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:51:03 +03:30
parent 3a2a685ed3
commit fffdeac3ed
7 changed files with 174 additions and 93 deletions

View File

@@ -64,13 +64,15 @@ android {
applicationId = "com.tadrisino.math.app"
minSdk = 23
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
// بازار نسخه‌ی تازه را فقط با versionCodeِ بزرگ‌تر می‌پذیرد؛ هر build که منتشر می‌شود یکی بالا.
versionCode = 2
versionName = "1.1.0"
// URL of the web frontend.
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
// Public RSA key from the Cafe Bazaar panel. Purchases won't start until it is filled in.
buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (پرداخت درون‌برنامه‌ای)؛ سینیور، ۲۰۲۶-۱۰-۱۲. عمومی است و در APK
// می‌رود؛ Poolakey با آن امضای خرید را روی گوشی می‌سنجد. سنجشِ اصلی سمتِ سرور است.
buildConfigField("String", "BAZAAR_RSA_KEY", "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwC9EBw0RW6jeTdHkrJwfB/mX+klpjpVXVb2stpGDcixeqHiLE+/Iow+PiMbZEQfxZDuED6ELvZlzQX+ALOlGRPxaa1ctl6/Uy6wGaPXhGPDpZL6jPrXV88mjJzMcEoB4sf3yNb+6YNKrvKhRyXXR0JdMUmPMqMe8igmQZTv/UF+QEMNqv7lg7qw9Yl+fG802nqjasft7qUPQK+YHJYPS4PJ5Pj/YpTkGDdDmVi7AIMCAwEAAQ==\"")
}
buildFeatures {

View File

@@ -41,7 +41,7 @@ import org.json.JSONObject
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درون‌برنامه‌ایِ بازار است.
*
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
* JS → اینجا TadrisinoAndroid.bazaarPurchase(sku, orderId)
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)
* TadrisinoAndroid.bazaarConsume(purchaseToken)
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
* { orderId, error: "canceled" | "failed", message }
@@ -218,31 +218,39 @@ class MainActivity : AppCompatActivity() {
}
}
private fun purchase(sku: String, orderId: String) {
/**
* productType «inapp» (بسته: purchaseProduct، بعد از تأییدِ سرور consume) یا «subscription» (اشتراکِ
* بازار: subscribeProduct، مصرف نمی‌شود و بازار خودش تمدید می‌کند). dynamicPriceToken خالی یعنی قیمتِ
* پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده (بازارِ ۱۳.۳ به بعد).
*/
private fun buy(sku: String, orderId: String, productType: String, dynamicPriceToken: String) {
val p = payment
if (p == null) {
send(orderId, error = "failed", message = "bazaar not connected")
return
}
p.purchaseProduct(
registry = activityResultRegistry,
request = PurchaseRequest(productId = sku, payload = orderId),
) {
val subscription = productType == "subscription"
val request = PurchaseRequest(
productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null },
)
val callback: ir.cafebazaar.poolakey.callback.PurchaseCallback.() -> Unit = {
purchaseSucceed { send(orderId, token = it.purchaseToken) }
purchaseCanceled { send(orderId, error = "canceled") }
purchaseFailed { recoverOwned(sku, orderId, it) }
failedToBeginFlow { recoverOwned(sku, orderId, it) }
purchaseFailed { recoverOwned(sku, orderId, subscription, it) }
failedToBeginFlow { recoverOwned(sku, orderId, subscription, it) }
}
if (subscription) p.subscribeProduct(activityResultRegistry, request, callback)
else p.purchaseProduct(activityResultRegistry, request, callback)
}
/**
* خریدِ قبلیِ همین محصول که مصرف نشده (پول رفته ولی تأییدِ سرور نرسیده بود) جلوی خریدِ
* دوباره را می‌گیرد. فرانت در تلاشِ بعدی همان سفارش را می‌فرستد؛ اگر توکنِ ماندهٔ همان
* خریدِ قبلیِ همین محصول (بسته‌ی مصرف‌نشده، یا اشتراکِ فعال) که پولش رفته ولی تأییدِ سرور نرسیده بود
* جلوی خریدِ دوباره را می‌گیرد. فرانت در تلاشِ بعدی همان سفارش را می‌فرستد؛ اگر توکنِ ماندهٔ همان
* سفارش (payload) پیدا شد، همان برمی‌گردد تا سرور ثبتش کند.
*/
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
private fun recoverOwned(sku: String, orderId: String, subscription: Boolean, cause: Throwable) {
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
p.getPurchasedProducts {
val query: ir.cafebazaar.poolakey.callback.PurchaseQueryCallback.() -> Unit = {
querySucceed { owned ->
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
if (match != null) send(orderId, token = match.purchaseToken)
@@ -250,6 +258,7 @@ class MainActivity : AppCompatActivity() {
}
queryFailed { send(orderId, error = "failed", message = cause.message) }
}
if (subscription) p.getSubscribedProducts(query) else p.getPurchasedProducts(query)
}
private fun consume(purchaseToken: String) {
@@ -273,11 +282,12 @@ class MainActivity : AppCompatActivity() {
/** متدهای این کلاس روی threadِ WebView صدا زده می‌شوند، نه UI. */
private inner class Bridge {
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
@JavascriptInterface
fun bazaarPurchase(sku: String, orderId: String) {
fun bazaarBuy(sku: String, orderId: String, productType: String, dynamicPriceToken: String) {
runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
purchase(sku, orderId)
buy(sku, orderId, productType, dynamicPriceToken)
}
}