From fffdeac3ed507e505762c51e1a177443a9cbc0f5 Mon Sep 17 00:00:00 2001 From: AliReza Date: Mon, 12 Oct 2026 17:51:03 +0330 Subject: [PATCH] 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 --- android/README.md | 18 ++- android/app/build.gradle.kts | 10 +- .../java/com/tadrisino/app/MainActivity.kt | 38 +++-- src/api/index.ts | 2 + src/api/types.ts | 15 +- src/lib/bazaar.ts | 35 ++-- src/screens/SubscriptionScreen.tsx | 149 +++++++++++------- 7 files changed, 174 insertions(+), 93 deletions(-) diff --git a/android/README.md b/android/README.md index 54dffaf..0787373 100644 --- a/android/README.md +++ b/android/README.md @@ -21,6 +21,7 @@ | `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش می‌شوند، نه در لحظه‌ی لمس | صدای جوابِ درست/غلط و پایانِ تمرین | | `isPaymentPage` + `paysInApp()` | درگاهِ زرین‌پال داخلِ خودِ اپ باز می‌شود. اپِ فعلی آن را به مرورگرِ گوشی می‌دهد که Referer نمی‌فرستد و زرین‌پال بی‌Referer «دسترسی از این دامنه مجاز نمی‌باشد» می‌دهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش می‌زند | «پرداخت» با کارتِ بانکی: صفحه‌ی زرین‌پال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمه‌ی برگشت وسطِ صفحه‌ی بانک چه می‌کند را هم ببین | | اسپلشِ یک‌مرحله‌ای: `installSplashScreen()` + `setKeepOnScreenCondition` + `TadrisinoAndroid.appReady()` (`446cf36`، `ab02bb4`) | اپِ فعلی سه تکه می‌شود: اسپلشِ اندروید ← WebViewِ سفیدِ خالی ← اسپلشِ وب (آیکون + نام) ← خانه. با APKِ تازه اسپلشِ بومی تا اولین صفحه‌ی واقعیِ وب می‌ماند و مستقیم می‌رود روی همان صفحه؛ اسپلشِ وب داخلِ اپ نامرئی است (در APKِ قدیمی فقط آیکونِ بی‌نام). فرانت `appReady` را بعد از اولین صفحه‌ی غیر‌boot صدا می‌زند و APKِ قدیمی آن را ندارد، پس بی‌اثر است | روی گوشی: باز کردنِ اپ با اینترنت باید آیکون ← خانه باشد، بی‌سفیدی و بی‌تصویرِ دوم؛ اندازه‌ی آیکون روی اندروید ۱۲+ (`drawable/splash_icon.xml`: ۲۸۸dp، حاشیه ۲۲٪) | +| خریدِ کافه‌بازار: کلیدِ RSA، `bazaarBuy` با اشتراک (`subscribeProduct`) و بسته، «تخفیف پویا»؛ `versionCode` ۲ | بی‌این‌ها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سه‌ماهه: کاشیِ «کافه‌بازار» فعال و پیش‌فرض باشد؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداخت‌شده». یک بار هم «انصراف» در صفحه‌ی بازار | | صفحه‌ی بی‌اتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحه‌ی خطای WebView یا متنِ فنی نمی‌بیند. اتصالِ کند (بی‌خطا) عمداً هیچ متنِ اضافه نمی‌آورد و روی اسپلش می‌ماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) | **اسپلش و بی‌اینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصب‌شده امتحان شد).** چیزی که روی گوشی دیده می‌شود @@ -47,16 +48,19 @@ ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از ایران بی‌VPN ۴۰۴ می‌دهد، `~/.gradle/init.d/google-mirror.gradle` آینه‌ی Aliyun را اضافه می‌کند؛ ساختِ اول ~۹ دقیقه. -## پیش از build — سه چیز از پیشخانِ بازار (سنیور / حسابِ شرکت) +## پیش از build — آنچه از پیشخانِ بازار لازم است -| چه | کجا گذاشته می‌شود | وضعیت | +| چه | کجا گذاشته می‌شود | وضعیت (۲۰۲۶-۱۰-۱۲) | |---|---|---| -| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app` (APKِ ساخته‌شده)؛ ثبتش در پیشخان تأیید نشده | -| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | ندارد؛ بدونِ آن خرید شروع نمی‌شود | -| SKUِ هر بسته (محصولِ **مصرف‌شدنی**) | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | هیچ‌کدام تعریف نشده | +| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصول‌های ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) | +| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد | +| SKUِ هر پلن | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | سه‌ماهه `10343`، شش‌ماهه `10344` (migrationِ `subscription.0014`)؛ یک‌ماهه و بسته‌ها ندارند؛ `10342` در بازار هست ولی معلوم نیست چیست | +| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور | -قیمتِ هر محصول در پیشخانِ بازار باید با قیمتِ همان بسته در ادمین یکی باشد؛ سرور مبلغ را از -بازار نمی‌خواند. +خرید: فرانت `TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken)` را صدا می‌زند؛ اشتراک با +`subscribeProduct` (بی‌consume، بازار خودش تمدید می‌کند)، بسته با `purchaseProduct` و بعد `bazaarConsume`. قیمتِ هر +محصول در پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف خورده، سرور قیمتِ کم‌شده را امضا می‌کند. +`compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده. ## build diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 438dba3..e8bd5da 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt index 2c5ac5e..d735f68 100644 --- a/android/app/src/main/java/com/tadrisino/app/MainActivity.kt +++ b/android/app/src/main/java/com/tadrisino/app/MainActivity.kt @@ -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) } } diff --git a/src/api/index.ts b/src/api/index.ts index edda0f6..207fd50 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -200,6 +200,8 @@ export const api = { */ purchaseBazaar: (planId: string, idempotencyKey: string) => http.create('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }), + /** SKU، نوعِ محصول و (اگر تخفیف خورده) قیمتِ امضاشده برای بازار؛ بعد از آن کدِ تخفیف نمی‌نشیند. */ + startBazaar: (orderId: string) => http.post(`/payment/bazaar/orders/${seg(orderId)}/start`), /** توکنِ خریدِ Poolakey را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */ verifyBazaar: (orderId: string, purchaseToken: string) => http.post('/payment/bazaar/verify', { orderId, purchaseToken }), diff --git a/src/api/types.ts b/src/api/types.ts index a6a7d2d..1d1fbc2 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -287,7 +287,7 @@ export interface Plan { questionCredits: number; /** بسته: روزهای قلبِ نامحدودِ هدیه. */ heartsDays: number; - /** SKUِ همین بسته در کافه‌بازار؛ خالی یعنی داخلِ اپِ بازار خریدنی نیست. */ + /** SKUِ همین پلن (بسته یا اشتراک) در کافه‌بازار؛ خالی یعنی از بازار خریدنی نیست. */ bazaarSku: string; } @@ -327,6 +327,19 @@ export interface PurchaseResponse { paymentUrl: string | null; /** فقط بازار: SKUی که اپِ اندروید می‌خرد. */ bazaarSku?: string; + /** فقط بازار: «inapp» بسته (بعد از تأیید consume)، «subscription» اشتراک. */ + bazaarProductType?: 'inapp' | 'subscription'; +} + +/** POST /payment/bazaar/orders/{id}/start — بعد از کدِ تخفیف، درست پیش از صفحه‌ی خریدِ بازار. */ +export interface BazaarStartResponse { + orderId: string; + sku: string; + productType: 'inapp' | 'subscription'; + /** مبلغِ سفارش (تومان)، پس از کدِ تخفیف. */ + amount: number; + /** JWTِ «تخفیف پویا»؛ null یعنی بازار قیمتِ پیشخان را می‌گیرد. */ + dynamicPriceToken: string | null; } export interface BazaarVerifyResponse { diff --git a/src/lib/bazaar.ts b/src/lib/bazaar.ts index 7577263..fe5402a 100644 --- a/src/lib/bazaar.ts +++ b/src/lib/bazaar.ts @@ -5,22 +5,27 @@ import { store } from './storage'; * * خریدِ درون‌برنامه‌ایِ بازار فقط از کدِ بومیِ اندروید (کتابخانه‌ی Poolakey) شدنی است، پس * اپِ اندروید یک شیء به نامِ `window.TadrisinoAndroid` در صفحه می‌گذارد و این فایل تنها - * جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست، `inBazaarApp()` false است و - * خرید مثلِ قبل به زرین‌پال می‌رود. + * جایی است که با آن حرف می‌زند. در مرورگرِ معمولی آن شیء نیست و خرید با زرین‌پال است. * * قرارداد (سمتِ اندروید: `android/` در همین ریپو): - * JS → اندروید TadrisinoAndroid.bazaarPurchase(sku, orderId) خرید را شروع کن؛ orderId همان - * developerPayloadِ خرید است. - * JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) بعد از تأییدِ سرور؛ بدونِ آن - * همان بسته دوباره خریدنی نیست. + * JS → اندروید TadrisinoAndroid.bazaarBuy(sku, orderId, productType, dynamicPriceToken) + * productType «inapp» (بسته، purchaseProduct) یا «subscription» (subscribeProduct)؛ + * orderId همان payloadِ خرید است؛ dynamicPriceToken رشته‌ی خالی یعنی قیمتِ پیشخان. + * JS → اندروید TadrisinoAndroid.bazaarConsume(purchaseToken) فقط بسته، بعد از تأییدِ سرور. * اندروید → JS رویدادِ window به نامِ «tadrisino:bazaar» با detail: * { orderId, purchaseToken } خرید انجام شد * { orderId, error: 'canceled' | 'failed', message? } * - * هیچ‌کدام از این‌ها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۰۳): اپِ اندروید ساخته نشده. + * `bazaarBuy` از APKهای پس از ۲۰۲۶-۱۰-۱۲ است. APKهای قبلی کلیدِ RSA نداشتند و Poolakey در آن‌ها خاموش بود، پس از + * بازار هیچ خریدی نکرده‌اند؛ در آن‌ها کاشیِ بازار غیرفعال است و «اپ را به‌روز کن» می‌گوید. + * هیچ‌کدام از این‌ها هنوز روی گوشیِ واقعی اجرا نشده (۲۰۲۶-۱۰-۱۲). */ +export type BazaarProductType = 'inapp' | 'subscription'; + interface AndroidBridge { + bazaarBuy?: (sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string) => void; + /** APKِ قدیمی؛ فقط برای این‌که بدانیم داخلِ اپیم ولی خرید از بازار در آن ممکن نیست. */ bazaarPurchase?: (sku: string, orderId: string) => void; bazaarConsume?: (purchaseToken: string) => void; /** اولین صفحه‌ی واقعی کشیده شد؛ اسپلشِ سیستم می‌تواند برود (APKهای قدیمی‌تر این را ندارند). */ @@ -43,17 +48,23 @@ export class BazaarPurchaseError extends Error { } } -export function inBazaarApp(): boolean { - return typeof window.TadrisinoAndroid?.bazaarPurchase === 'function'; +/** داخلِ اپِ اندرویدِ تدریسینوییم (هر نسخه‌ای). */ +export function inAndroidApp(): boolean { + return typeof window.TadrisinoAndroid === 'object' && window.TadrisinoAndroid !== null; +} + +/** اپی که می‌تواند از بازار بخرد (بسته و اشتراک). */ +export function canBuyFromBazaar(): boolean { + return typeof window.TadrisinoAndroid?.bazaarBuy === 'function'; } /** صفحه‌ی خریدِ بازار ممکن است طول بکشد (ورود به بازار، کارتِ بانکی)؛ این سقف فقط جلوی گیر کردنِ همیشگی را می‌گیرد. */ const PURCHASE_TIMEOUT_MS = 10 * 60 * 1000; /** خرید را به اپِ اندروید می‌سپارد و purchaseToken را برمی‌گرداند. */ -export function bazaarPurchase(sku: string, orderId: string): Promise { +export function bazaarBuy(sku: string, orderId: string, productType: BazaarProductType, dynamicPriceToken: string | null): Promise { const bridge = window.TadrisinoAndroid; - if (!bridge?.bazaarPurchase) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app')); + if (!bridge?.bazaarBuy) return Promise.reject(new BazaarPurchaseError('failed', 'not in the Android app')); return new Promise((resolve, reject) => { const timer = window.setTimeout(() => finish(() => reject(new BazaarPurchaseError('timeout'))), PURCHASE_TIMEOUT_MS); function finish(settle: () => void) { @@ -69,7 +80,7 @@ export function bazaarPurchase(sku: string, orderId: string): Promise { } window.addEventListener('tadrisino:bazaar', onResult); try { - bridge.bazaarPurchase!(sku, orderId); + bridge.bazaarBuy!(sku, orderId, productType, dynamicPriceToken ?? ''); } catch (err) { finish(() => reject(new BazaarPurchaseError('failed', String(err)))); } diff --git a/src/screens/SubscriptionScreen.tsx b/src/screens/SubscriptionScreen.tsx index 30af87b..09eb0b7 100644 --- a/src/screens/SubscriptionScreen.tsx +++ b/src/screens/SubscriptionScreen.tsx @@ -3,7 +3,7 @@ import { Check, ChevronLeft, Crown, CreditCard, Heart, ShieldCheck, ShoppingBag, import { api, type Plan, type SubscriptionStatus } from '@/api'; import { daysLeft } from '@/lib/subscription'; import { newIdempotencyKey } from '@/api/http'; -import { BazaarPurchaseError, bazaarConsume, bazaarPending, bazaarPurchase, inBazaarApp } from '@/lib/bazaar'; +import { BazaarPurchaseError, bazaarBuy, bazaarConsume, bazaarPending, canBuyFromBazaar, inAndroidApp } from '@/lib/bazaar'; import { useAsync } from '@/lib/useAsync'; import { Sheet } from '@/components/Sheet'; import { useBackStep } from '@/nav/Navigator'; @@ -34,7 +34,7 @@ type View = 'subscription' | 'pack'; * * چیدمان (۱۴۰۵-۰۷، خواستِ مالک: «شلوغه و برای بچه گیج‌کننده»): مزیت‌های اشتراک یک بار بالای فهرست * می‌آیند نه روی هر کارت (هر کارت همان سه خط و «۳ سوال در روز» را دو بار داشت)، هر پلن یک ردیفِ قابلِ - * انتخاب است با نام و قیمت، و فقط یک دکمه‌ی بزرگ پایینِ صفحه هست. روشِ پرداخت در برگه‌ی پرداخت انتخاب می‌شود. + * انتخاب است با نام و قیمت، و فقط یک دکمه‌ی بزرگ پایینِ صفحه هست. روشِ پرداخت (زرین‌پال یا کافه‌بازار) در برگه‌ی پرداخت انتخاب می‌شود. */ export function SubscriptionScreen({ initialView }: { initialView?: View }) { const plans = useAsync(() => api.subscription.plans(), [], { key: 'plans' }); @@ -42,7 +42,6 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) { const [view, setView] = useState(initialView ?? null); const [picked, setPicked] = useState(null); const [buying, setBuying] = useState(null); - const bazaar = inBazaarApp(); const look = useCompanion(); // از فهرست، «برگشت» اول به دو گزینه برمی‌گردد. useBackStep(view !== null && !initialView, () => setView(null)); @@ -127,7 +126,7 @@ export function SubscriptionScreen({ initialView }: { initialView?: View }) { )} - setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} /> + setBuying(null)} onPaid={() => { setBuying(null); status.reload(); }} /> ); } @@ -259,28 +258,46 @@ function PlanRow({ plan, monthlyPrice, selected, onSelect }: { plan: Plan; month ); } +type Method = 'gateway' | 'bazaar'; + +/** چرا بازار برای این پلن و این‌جا ممکن نیست؛ null یعنی ممکن است. */ +function bazaarBlocker(plan: Plan | null): string | null { + if (!plan?.bazaarSku) return 'این مورد در بازار نیست'; + if (!inAndroidApp()) return 'فقط در اپِ اندروید'; + if (!canBuyFromBazaar()) return 'اپ رو از بازار به‌روز کن'; + return null; +} + /** - * خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود، - * و بعد کاربر در همین برگه به درگاه می‌رود. window.open بعد از await را - * Safariِ آیفون مسدود می‌کند؛ برای همین جابه‌جاییِ همین صفحه است. + * خرید: سفارش ساخته می‌شود، کدِ تخفیف (اگر هست) روی همان سفارش ثبت می‌شود، و بعد کاربر به درگاه یا به + * صفحه‌ی خریدِ بازار می‌رود. window.open بعد از await را Safariِ آیفون مسدود می‌کند؛ برای همین درگاه + * جابه‌جاییِ همین صفحه است. + * + * دو روشِ پرداخت همیشه کنارِ هم دیده می‌شوند (خواستِ مالک، ۲۰۲۶-۱۰-۱۲)؛ اگر بازار این‌جا ممکن نیست، کاشیِ + * آن غیرفعال است و دلیلش را زیرِ نامش می‌گوید (نه پنهان، تا کاربر بداند راهِ دیگری هم هست). داخلِ اپی که + * از بازار می‌خرد پیش‌فرض بازار است. ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ + * درون‌برنامه‌ای را محدود می‌کند؛ نگه داشتنِ زرین‌پال داخلِ اپ تصمیمِ مالک است و با بازار هماهنگ نشده. */ -function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; bazaar: boolean; onClose: () => void; onPaid: () => void }) { - // داخلِ اپِ بازار دو راه هست (خواستِ مالک، ۱۴۰۵-۰۷): درگاهِ بانکی پیش‌فرض است، بازار فقط برای بسته‌ای که SKU دارد. - // ⚠️ قانونِ کافه‌بازار پرداختِ محتوای دیجیتال بیرون از پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ این انتخاب تصمیمِ مالک است - // و با بازار هماهنگ نشده. - const canBazaar = bazaar && !!plan?.bazaarSku; - const [method, setMethod] = useState<'gateway' | 'bazaar'>('gateway'); +function CheckoutSheet({ plan, onClose, onPaid }: { plan: Plan | null; onClose: () => void; onPaid: () => void }) { + const blocker = bazaarBlocker(plan); + const [method, setMethod] = useState('gateway'); const [code, setCode] = useState(''); const [showCode, setShowCode] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [done, setDone] = useState<'code' | 'bazaar' | 'gateway' | null>(null); + const isSubscription = plan?.kind === 'subscription'; + + // هر بار که برگه برای پلنی باز می‌شود: داخلِ اپِ بازار، بازار پیش‌فرض است. + useEffect(() => { + if (plan) setMethod(bazaarBlocker(plan) ? 'gateway' : 'bazaar'); + }, [plan]); /** - * داخلِ اپِ بازار: سفارش (با کلیدِ ثابتِ همین SKU) → خرید در بازار → سنجشِ سرور → مصرفِ خرید. - * کدِ تخفیف این‌جا نیست: بازار قیمتِ ثابتِ محصول را می‌گیرد و سرور تخفیف را رد می‌کند. + * بازار: سفارش (با کلیدِ ثابتِ همین SKU) → کدِ تخفیف → شروع (SKU، نوع، قیمتِ امضاشده) → خرید در بازار + * → سنجشِ سرور → مصرفِ خرید (فقط بسته؛ اشتراک مصرف نمی‌شود). */ - async function payWithBazaar(plan: Plan) { + async function payWithBazaar(plan: Plan): Promise<'bazaar' | 'code' | null> { const sku = plan.bazaarSku; let key = bazaarPending.get(sku); if (key) { @@ -295,27 +312,40 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b bazaarPending.set(sku, key); } const order = await api.subscription.purchaseBazaar(plan.id, key); + if (code.trim()) { + const current = await api.subscription.order(order.orderId); + // سفارشِ مانده از تلاشِ قبلی ممکن است همین کد را از قبل گرفته باشد. + if (current.amount === plan.price) { + const redeemed = await api.subscription.redeem(code.trim(), order.orderId); + if (redeemed.orderStatus === 'paid') { + bazaarPending.clear(sku); + return 'code'; + } + } + } + const start = await api.subscription.startBazaar(order.orderId); let token: string; try { - token = await bazaarPurchase(order.bazaarSku || sku, order.orderId); + token = await bazaarBuy(start.sku, order.orderId, start.productType, start.dynamicPriceToken); } catch (err) { - if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return false; + if (err instanceof BazaarPurchaseError && err.kind === 'canceled') return null; throw err; } const result = await api.subscription.verifyBazaar(order.orderId, token); if (result.status !== 'paid') throw new Error('bazaar purchase not confirmed'); bazaarPending.clear(sku); - bazaarConsume(token); - return true; + if (start.productType === 'inapp') bazaarConsume(token); + return 'bazaar'; } async function pay() { if (!plan || busy) return; setBusy(true); setError(''); - if (canBazaar && method === 'bazaar') { + if (method === 'bazaar') { try { - if (await payWithBazaar(plan)) setDone('bazaar'); + const outcome = await payWithBazaar(plan); + if (outcome) setDone(outcome); } catch (err) { setError(friendlyError(err, 'خرید از بازار تأیید نشد. اگه پولی کم شده، دوباره «پرداخت» رو بزن تا همون خرید ثبت بشه.')); } @@ -336,8 +366,8 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b // فقط http(s): یک «javascript:» یا «data:» از پاسخِ API نباید با assign اجرا شود. if (!/^https?:\/\//i.test(order.paymentUrl)) throw new Error('paymentUrl is not http(s)'); const shell = (window as unknown as { TadrisinoAndroid?: { paysInApp?: () => boolean } }).TadrisinoAndroid; - if (bazaar && !shell?.paysInApp?.()) { - // اپِ اندرویدِ فعلی هر آدرسِ بیرونی را در مرورگرِ گوشی باز می‌کند و آن مرورگر Referer نمی‌فرستد؛ زرین‌پال + if (inAndroidApp() && !shell?.paysInApp?.()) { + // اپِ اندرویدِ قدیمی هر آدرسِ بیرونی را در مرورگرِ گوشی باز می‌کند و آن مرورگر Referer نمی‌فرستد؛ زرین‌پال // بی‌Referer می‌گوید «دسترسی از این دامنه مجاز نمی‌باشد». پس اول pay.htmlِ خودمان در مرورگر باز می‌شود // (src/pay.ts) و آن به درگاه می‌رود. «http://» عمداً است: WebView فقط https‌ِ همین دامنه را در خودش نگه // می‌دارد؛ آروان بلافاصله به https می‌برد و لینک در fragment است، پس به سرور نمی‌رسد. @@ -360,18 +390,19 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b setShowCode(false); setError(''); setDone(null); - setMethod('gateway'); }; + const doneText = + done === 'gateway' ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.' + : done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' + : isSubscription ? 'پرداختت در بازار تأیید شد و اشتراکت فعاله. بازار آخرِ هر دوره خودش تمدیدش می‌کنه؛ لغوِ تمدید از بخشِ «اشتراک‌ها»ی خودِ بازاره.' + : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'; + return ( {done ? ( <> -

- {done === 'gateway' - ? 'صفحه‌ی پرداخت در مرورگرِ گوشی باز شد. بعد از پرداخت برگرد همین‌جا؛ خریدت خودش فعال می‌شه.' - : done === 'code' ? 'کدِ تخفیفت کلِ مبلغ رو پوشوند و همین الان به حسابت اضافه شد.' : 'پرداختت در بازار تأیید شد و به حسابت اضافه شد.'} -

+

{doneText}

{done === 'gateway' ? 'باشه' : 'عالیه!'} ) : plan && ( @@ -380,14 +411,14 @@ function CheckoutSheet({ plan, bazaar, onClose, onPaid }: { plan: Plan | null; b مبلغ {price(plan.price)} - {canBazaar && ( -
- با چی پرداخت می‌کنی؟ - setMethod('gateway')} icon={} title="کارتِ بانکی" note="درگاهِ امنِ زرین‌پال" /> - setMethod('bazaar')} icon={} title="کافه‌بازار" note="با حسابِ بازارت" /> +
+ با چی پرداخت می‌کنی؟ +
+ setMethod('gateway')} icon={} title="کارتِ بانکی" note="زرین‌پال" /> + setMethod('bazaar')} icon={} title="کافه‌بازار" note={blocker ?? 'با حسابِ بازارت'} disabled={!!blocker} />
- )} - {method === 'gateway' && (showCode ? ( +
+ {showCode ? (