d1bd279eba
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>
137 lines
5.0 KiB
TypeScript
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 };
|
|
}
|