// Store in-app billing (Cafe Bazaar / Myket) for coin packs. // // - **Bazaar** (embedded PWA): pure deep-link flow. We navigate to // `bazaar://in_app?...&sku=...&redirect_url=...`; Bazaar processes payment and // reopens the app at redirect_url with `?purchaseToken=...`. We stash the SKU // first, then on return POST the token to `/api/coins/iab/verify`. // - **Myket**: native AIDL billing. A Capacitor plugin must inject // `window.MyketBilling` (see ANDROID.md). We call `.purchase(sku)` and POST the // returned token to verify. Without the bridge, Myket is "unavailable". // // The active store is the build flavor `NEXT_PUBLIC_STORE` (bazaar|myket|web), // overridden at runtime if the Myket native bridge is present. import { CoinPack } from "./online/types"; export type StoreId = "bazaar" | "myket" | "web"; const ENV_STORE = ((process.env.NEXT_PUBLIC_STORE as StoreId | undefined) ?? "web"); const PACKAGE = process.env.NEXT_PUBLIC_APP_PACKAGE ?? "com.bargevasat.app"; const PENDING_SKU_KEY = "iab_pending_sku"; /** Native bridge contract a Myket Capacitor plugin must fulfil. */ interface MyketBridge { available?: boolean; purchase: (sku: string) => Promise<{ purchaseToken: string; productId?: string }>; consume?: (token: string) => Promise; } declare global { interface Window { MyketBilling?: MyketBridge; Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; } } export function getStore(): StoreId { if (typeof window === "undefined") return ENV_STORE; // Myket's native bridge wins when present (a Myket-flavored build). if (window.MyketBilling?.available) return "myket"; // Honor an explicit build flavor. if (ENV_STORE !== "web") return ENV_STORE; // Otherwise, inside the Android app shell (Capacitor) default to Cafe Bazaar // IAB — the APK ships to Bazaar, which requires its own billing. The web build // stays on the web (ZarinPal) gateway. if (window.Capacitor?.isNativePlatform?.()) return "bazaar"; return ENV_STORE; } /** True when coin purchases should go through a store (not the web ZarinPal gateway). */ export function isStoreBilling(): boolean { return getStore() !== "web"; } function skuFor(pack: CoinPack): string { return pack.sku ?? pack.id; } export type PurchaseStart = | { kind: "redirect" } // Bazaar — the app navigated away; result arrives on return | { kind: "token"; store: StoreId; productId: string; token: string } // Myket — verify now | { kind: "unavailable" }; /** Begin a store purchase for a coin pack. */ export async function purchaseViaStore(pack: CoinPack): Promise { const store = getStore(); const sku = skuFor(pack); if (store === "bazaar") { try { localStorage.setItem(PENDING_SKU_KEY, sku); } catch { /* ignore storage errors */ } const redirect = encodeURIComponent(window.location.origin + window.location.pathname); window.location.href = `bazaar://in_app?package_name=${encodeURIComponent(PACKAGE)}` + `&sku=${encodeURIComponent(sku)}&redirect_url=${redirect}`; return { kind: "redirect" }; } if (store === "myket" && window.MyketBilling) { const res = await window.MyketBilling.purchase(sku); return { kind: "token", store: "myket", productId: res.productId ?? sku, token: res.purchaseToken }; } return { kind: "unavailable" }; } /** * On app load, capture a Bazaar redirect (`?purchaseToken=...`). Returns the * pending purchase to verify, or null. Also clears the stashed SKU. */ export function captureBazaarRedirect(): { store: StoreId; productId: string; token: string } | null { if (typeof window === "undefined") return null; const params = new URLSearchParams(window.location.search); const token = params.get("purchaseToken"); if (!token) return null; let productId = params.get("sku") ?? params.get("productId") ?? ""; if (!productId) { try { productId = localStorage.getItem(PENDING_SKU_KEY) ?? ""; } catch { /* ignore */ } } try { localStorage.removeItem(PENDING_SKU_KEY); } catch { /* ignore */ } return { store: "bazaar", productId, token }; }