// 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 via the `MyketBilling` Capacitor plugin // (android/.../billing/MyketBillingPlugin.java). We call `.purchase({sku})`, // POST the returned token to verify, then `.consume({token})` so the // consumable can be bought again. // // The active store is the build flavor `NEXT_PUBLIC_STORE` (bazaar|myket|web). import { registerPlugin } from "@capacitor/core"; 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"; // Myket in-app billing RSA public key (Myket developer panel). Public, not secret. const MYKET_RSA_PUBLIC_KEY = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCbUBKRU4g1AQrbOO8GkcBn79ol0hbs5PZVd5vPP6za98BTc9leqvyGE+DwSg7lbsXTZxCzPRBS3m0qB9LShe70WG+RQapG9Q2lodszYkauicPkJSpbXWh/nfrziTWNqEHqUfCsC4+lkKSEkxDNa1Po7uZzbwaJ+Kf1+d8wSWYpxwIDAQAB"; interface MyketBillingPlugin { isAvailable(): Promise<{ available: boolean }>; connect(opts: { rsaPublicKey: string }): Promise; purchase(opts: { sku: string; rsaPublicKey: string }): Promise<{ purchaseToken: string; productId?: string; }>; consume(opts: { token: string }): Promise; } const MyketBilling = registerPlugin("MyketBilling"); declare global { interface Window { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; } } export function getStore(): StoreId { if (typeof window === "undefined") return ENV_STORE; // Honor an explicit build flavor (bazaar | myket). 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") { try { const res = await MyketBilling.purchase({ sku, rsaPublicKey: MYKET_RSA_PUBLIC_KEY }); return { kind: "token", store: "myket", productId: res.productId ?? sku, token: res.purchaseToken }; } catch { return { kind: "unavailable" }; } } return { kind: "unavailable" }; } /** * Finalize a verified store purchase. Coin packs are consumable, so on Myket we * must consume the purchase (after the server credited it) to allow re-buying. * Bazaar consumables are handled server-side; this is a no-op there. */ export async function consumeStorePurchase(store: StoreId, token: string): Promise { if (store !== "myket" || !token) return; try { await MyketBilling.consume({ token }); } catch { /* best-effort; the server already credited */ } } /** * 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 }; }