diff --git a/docker-compose.v2.yml b/docker-compose.v2.yml index 7a83052..1ac10d2 100644 --- a/docker-compose.v2.yml +++ b/docker-compose.v2.yml @@ -88,6 +88,9 @@ services: Jwt__Audience: "flatrender" Jwt__AccessTokenMinutes: "${JWT_ACCESS_MINUTES:-1440}" ServiceToken: "${SERVICE_TOKEN:-internal-service-secret}" + # Payment callbacks land on this service (api.*); the result page is on the + # frontend. Used to make /payment/result redirects absolute to the site. + Frontend__BaseUrl: "${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}" ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}" ZarinPal__CallbackUrl: "${ZARINPAL_CALLBACK_URL:-http://localhost:8080/v1/payments/callback/zarinpal}" ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}" diff --git a/services/identity/FlatRender.IdentitySvc/Controllers/PaymentsController.cs b/services/identity/FlatRender.IdentitySvc/Controllers/PaymentsController.cs index 9b1e7f8..4f3f956 100644 --- a/services/identity/FlatRender.IdentitySvc/Controllers/PaymentsController.cs +++ b/services/identity/FlatRender.IdentitySvc/Controllers/PaymentsController.cs @@ -8,7 +8,7 @@ namespace FlatRender.IdentitySvc.Controllers; [ApiController] [Route("v1")] -public class PaymentsController(IPaymentService paymentService) : ControllerBase +public class PaymentsController(IPaymentService paymentService, IConfiguration config) : ControllerBase { // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -19,6 +19,18 @@ public class PaymentsController(IPaymentService paymentService) : ControllerBase private bool IsAdmin => User.FindFirst("is_admin")?.Value == "true"; + // Payment callbacks land on this service (api.flatrender.ir); the result page + // lives on the frontend. Prefix relative result paths with the frontend base so + // the browser is sent to the site, not the gateway. + private IActionResult RedirectFrontend(string path) + { + var baseUrl = config["Frontend:BaseUrl"] ?? ""; + var target = string.IsNullOrEmpty(baseUrl) || path.StartsWith("http") + ? path + : baseUrl.TrimEnd('/') + path; + return Redirect(target); + } + // ── Listing ─────────────────────────────────────────────────────────────────── /// GET /v1/payments — list the caller's payment history @@ -62,7 +74,7 @@ public class PaymentsController(IPaymentService paymentService) : ControllerBase [FromQuery] string Status) { var frontendUrl = await paymentService.HandleZarinPalCallbackAsync(Authority, Status); - return Redirect(frontendUrl); + return RedirectFrontend(frontendUrl); } // ── FlatRender Pay broker flow ──────────────────────────────────────────────── @@ -80,7 +92,7 @@ public class PaymentsController(IPaymentService paymentService) : ControllerBase [FromQuery] string? id) { var frontendUrl = await paymentService.HandleBrokerCallbackAsync(payment_id, id ?? ""); - return Redirect(frontendUrl); + return RedirectFrontend(frontendUrl); } // ── SnapPay flow ────────────────────────────────────────────────────────────── @@ -109,7 +121,7 @@ public class PaymentsController(IPaymentService paymentService) : ControllerBase [FromQuery] string shapSnapStatus) { var frontendUrl = await paymentService.HandleSnapPayCallbackAsync(paymentToken, shapSnapStatus); - return Redirect(frontendUrl); + return RedirectFrontend(frontendUrl); } // ── Tara flow ───────────────────────────────────────────────────────────────── @@ -138,7 +150,7 @@ public class PaymentsController(IPaymentService paymentService) : ControllerBase [FromQuery] string status) { var frontendUrl = await paymentService.HandleTaraCallbackAsync(token, status); - return Redirect(frontendUrl); + return RedirectFrontend(frontendUrl); } // ── Stripe webhook ──────────────────────────────────────────────────────────── diff --git a/src/app/[locale]/payment/result/page.tsx b/src/app/[locale]/payment/result/page.tsx new file mode 100644 index 0000000..235cc87 --- /dev/null +++ b/src/app/[locale]/payment/result/page.tsx @@ -0,0 +1,86 @@ +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +/** + * Payment result landing. Payment gateways/brokers redirect the browser here after + * a purchase: /payment/result?status=success|failed&ref=&gateway=. + * Coins/plans are activated server-side; this page just reports the outcome. + */ +export default function PaymentResultPage({ + params, + searchParams, +}: { + params: { locale: string }; + searchParams: { status?: string; ref?: string; gateway?: string }; +}) { + const fa = params.locale !== "en"; + const success = searchParams.status === "success" || searchParams.status === "Paid"; + const ref = typeof searchParams.ref === "string" ? searchParams.ref : ""; + + const t = { + successTitle: fa ? "پرداخت موفق بود" : "Payment successful", + successBody: fa + ? "پرداخت شما با موفقیت انجام شد و اشتراک‌تان فعال گردید." + : "Your payment went through and your plan is now active.", + failTitle: fa ? "پرداخت ناموفق بود" : "Payment failed", + failBody: fa + ? "پرداخت انجام نشد یا لغو شد. اگر مبلغی کسر شده باشد، طی ۷۲ ساعت بازگردانده می‌شود." + : "The payment didn't complete or was cancelled. Any charged amount is refunded within 72h.", + refLabel: fa ? "کد پیگیری" : "Tracking code", + toDashboard: fa ? "رفتن به داشبورد" : "Go to dashboard", + retry: fa ? "تلاش دوباره" : "Try again", + }; + + return ( +
+
+
+ {success ? ( + + + + ) : ( + + + + )} +
+ +

+ {success ? t.successTitle : t.failTitle} +

+

+ {success ? t.successBody : t.failBody} +

+ + {success && ref ? ( +

+ {t.refLabel}: {ref} +

+ ) : null} + +
+ + {t.toDashboard} + + {!success ? ( + + {t.retry} + + ) : null} +
+
+
+ ); +}