feat(payments): route coin purchases through FlatRender Pay broker
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 56s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m11s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 3m38s

ZarinPal only accepts callbacks on pay.flatrender.ir, so bargevasat
pays through the shared broker and is credited via a signed webhook.

- FlatPayService: broker client (HMAC-signed /v1/pay/request) + webhook
  signature verification + in-memory idempotency guard.
- Program.cs: /api/coins/pay/request prefers the broker when configured
  (FlatPay__ApiKey/Secret set), else the legacy direct ZarinPal path;
  new public POST /api/coins/pay/webhook verifies the HMAC and credits
  coins from the echoed metadata (idempotent).
- appsettings + docker-compose: FlatPay config (empty ⇒ legacy path).
- web: recognise the broker's ?status=Paid return + re-refresh profile
  (coins are credited server-side via webhook).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-16 00:36:12 +03:30
parent 8262fa79b3
commit d05cce6550
5 changed files with 170 additions and 8 deletions
+11 -5
View File
@@ -82,22 +82,28 @@ export default function Page() {
useEffect(() => {
init();
// ZarinPal payment return (?pay=success&coins= / ?pay=failed)
// Payment return — legacy direct ZarinPal (?pay=success&coins= / ?pay=failed)
// OR the FlatRender Pay broker (?pay=done&status=Paid|Failed&id=…&sign=…).
// With the broker, coins are credited server-side via webhook; we just refresh.
const params = new URLSearchParams(window.location.search);
const pay = params.get("pay");
if (pay) {
if (pay === "success") {
const brokerStatus = params.get("status"); // Paid | Failed | Cancelled | Expired
if (pay || brokerStatus) {
const ok = pay === "success" || brokerStatus === "Paid";
if (ok) {
const coins = params.get("coins");
pushNotification({
kind: "system",
titleFa: "پرداخت موفق",
titleEn: "Payment successful",
bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : undefined,
bodyEn: coins ? `${coins} coins added` : undefined,
bodyFa: coins ? `${coins} سکه به حساب شما اضافه شد` : "سکه‌ها به‌زودی به حساب شما اضافه می‌شوند",
bodyEn: coins ? `${coins} coins added` : "Your coins will be credited shortly",
icon: "💰",
route: "shop",
});
useSessionStore.getState().refreshProfile();
// Re-refresh shortly after, in case the webhook lands a moment later.
setTimeout(() => useSessionStore.getState().refreshProfile(), 4000);
} else {
pushNotification({ kind: "system", titleFa: "پرداخت ناموفق بود", titleEn: "Payment failed", icon: "⚠️" });
}