Files
HokmPlay/src/lib/storeBilling.ts
T
soroush.asadi d1bd279eba
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 24s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m8s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m1s
feat(iap): native Myket in-app billing plugin (AIDL) + wire purchase/consume
Implements real Myket IAB for the Capacitor app (Myket has no purchase
deep-link like Bazaar — it uses the classic Google Play IAB v3 AIDL bound to
the Myket app):

- AIDL: com.android.vending.billing.IInAppBillingService (Myket-compatible).
- MyketBillingPlugin (Capacitor): binds ir.mservices.market via
  "ir.mservices.market.InAppBillingService.BIND", runs getBuyIntent →
  startIntentSenderForResult, verifies INAPP_DATA_SIGNATURE with the RSA key
  (Security.java, SHA1withRSA), returns the purchaseToken; consume() too.
- MainActivity registers the plugin + forwards the purchase activity result.
- Manifest: ir.mservices.market.BILLING permission + <queries> for Android 11+
  package visibility.
- build.gradle: enable buildFeatures.aidl (AGP 8 disables it by default).
- storeBilling: Myket goes through the plugin (RSA key embedded); after server
  verify, BuyCoins consumes the purchase so coins can be re-bought.

Bazaar (deep-link) and web (ZarinPal) paths unchanged. Needs on-device testing
with the Myket app installed + published products.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 20:59:56 +03:30

137 lines
5.0 KiB
TypeScript

// 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<void>;
purchase(opts: { sku: string; rsaPublicKey: string }): Promise<{
purchaseToken: string;
productId?: string;
}>;
consume(opts: { token: string }): Promise<void>;
}
const MyketBilling = registerPlugin<MyketBillingPlugin>("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<PurchaseStart> {
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<void> {
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 };
}