Add ZarinPal sandbox payments for buying coins (config-driven merchant)

- ZarinpalService (request/verify) + /api/coins/pay/request (JWT) and
  /api/coins/pay/callback (verify → credit via ProfileService.BuyCoins → redirect
  back with ?pay=success); merchant id from config (sandbox default)
- Client buyCoins (live) returns the StartPay redirect URL; BuyCoinsScreen
  redirects; page.tsx handles the ?pay return (notify + refresh)
- Verified: sandbox request returns a real StartPay URL
- Documented Cafe Bazaar (Poolakey) / Myket IAB as the required store payment path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 17:59:30 +03:30
parent 4f2e4e14ea
commit cfed2950b2
8 changed files with 171 additions and 5 deletions
+22
View File
@@ -47,6 +47,28 @@ export default function Page() {
useEffect(() => {
init();
// ZarinPal payment return (?pay=success&coins= / ?pay=failed)
const params = new URLSearchParams(window.location.search);
const pay = params.get("pay");
if (pay) {
if (pay === "success") {
const coins = params.get("coins");
pushNotification({
kind: "system",
titleFa: "پرداخت موفق",
titleEn: "Payment successful",
bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : undefined,
bodyEn: coins ? `${coins} coins added` : undefined,
icon: "💰",
});
useSessionStore.getState().refreshProfile();
} else {
pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" });
}
window.history.replaceState({}, "", window.location.pathname);
}
useUIStore.getState().initHistory();
useNotifStore.getState().init();
// surface a daily-reward notification if it's available
@@ -28,6 +28,12 @@ export function BuyCoinsScreen() {
const buy = async (p: CoinPack) => {
setBusy(p.id);
const res = await getService().buyCoins(p.id);
// Live: redirect to the ZarinPal gateway; we credit on return via callback.
if (res.redirectUrl) {
window.location.href = res.redirectUrl;
return;
}
// Mock/offline: instant credit.
if (res.ok && res.profile) {
setProfile(res.profile);
sound.play("purchase");
+2 -1
View File
@@ -119,7 +119,8 @@ export interface OnlineService {
/* ----- coin purchases (real payment gateway: TODO Zarinpal/IDPay) ----- */
getCoinPacks(): Promise<CoinPack[]>;
buyCoins(packId: string): Promise<{ ok: boolean; profile?: UserProfile; coins: number }>;
/** Mock credits instantly; live returns a `redirectUrl` to the ZarinPal gateway. */
buyCoins(packId: string): Promise<{ ok: boolean; profile?: UserProfile; coins: number; redirectUrl?: string }>;
}
import { MockOnlineService } from "./mock-service";
+4 -4
View File
@@ -376,9 +376,9 @@ export class SignalrService implements OnlineService {
}
getCoinPacks(): Promise<CoinPack[]> { return this.getJson<CoinPack[]>("/api/coins/packs"); }
async buyCoins(id: string) {
const r = await this.send<{ ok: boolean; profile?: UserProfile; coins: number }>(
"POST", "/api/coins/buy", { packId: id });
if (r.profile) this.cachedProfile = r.profile;
return r;
// Real money → start a ZarinPal payment and hand back the redirect URL.
const r = await this.send<{ ok: boolean; url?: string }>(
"POST", "/api/coins/pay/request", { packId: id });
return { ok: r.ok, coins: 0, redirectUrl: r.url };
}
}