Files
flatrender/services/payment/internal/handlers/admin.go
T
soroush.asadi ec51e87d2d feat(payment): standalone ZarinPal broker on pay.flatrender.ir
A generic multi-client payment gateway so FlatRender, meezi.ir and
bargevasat.ir can all pay through ZarinPal's single verified callback
domain (pay.flatrender.ir).

New Go service services/payment (clones the notification skeleton +
vendored deps):
- migration 31_payment_broker.sql — `payment` schema: client_apps,
  transactions, webhook_deliveries.
- ZarinPal v4 client ported from the proven identity PaymentService
  (request.json -> StartPay -> verify.json; codes 100/101).
- client API: POST /v1/pay/request + /v1/pay/inquiry, authed by
  X-Api-Key + HMAC body signature; GET /callback/zarinpal (the single
  verified endpoint) verifies, then 302s the user back to the site's
  return_url (signed) and fires a signed, retried webhook.
- per-client ZarinPal merchant override (default = shared merchant);
  amount stored canonically in Rial, unit to ZarinPal env-configurable.
- admin API /v1/admin/* (FlatRender admin JWT): client-app CRUD +
  key issue/rotate + transactions list.

Deploy wiring: payment-svc in docker-compose.v2.yml (host port 1607),
pay.flatrender.ir server block in mirror-nginx conf, ENV_FILE +
README updates (cert SAN + manual migration note).

Admin UI: src/components/admin/PaymentsAdmin.tsx (client apps with
one-time key reveal + rotate, transactions table) + /admin/payments
page + nav link + fa/en strings; pay-admin proxy route to payment-svc.

Docs/SDK: deploy/PAYMENTS.md (integration contract) + deploy/sdk/flatpay.js
(zero-dep Node client + webhook verifier) for meezi/any site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:59:54 +03:30

205 lines
6.0 KiB
Go

package handlers
import (
"crypto/rand"
"encoding/hex"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/flatrender/payment-svc/internal/db"
"github.com/flatrender/payment-svc/internal/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AdminHandler struct {
store *db.Store
}
func NewAdminHandler(store *db.Store) *AdminHandler { return &AdminHandler{store: store} }
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
func slugify(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = slugRe.ReplaceAllString(s, "-")
return strings.Trim(s, "-")
}
func randToken(prefix string) string {
b := make([]byte, 24)
_, _ = rand.Read(b)
return prefix + hex.EncodeToString(b)
}
type clientInput struct {
Name string `json:"name"`
Slug string `json:"slug"`
ZarinPalMerchantID *string `json:"zarinpal_merchant_id"`
ZarinPalSandbox *bool `json:"zarinpal_sandbox"`
AllowedReturnOrigins []string `json:"allowed_return_origins"`
WebhookURL *string `json:"webhook_url"`
IsActive *bool `json:"is_active"`
}
func (h *AdminHandler) List(c *gin.Context) {
clients, err := h.store.ListClientApps(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
return
}
if clients == nil {
clients = []*models.ClientApp{}
}
c.JSON(http.StatusOK, gin.H{"data": clients})
}
func (h *AdminHandler) Create(c *gin.Context) {
var in clientInput
if err := c.ShouldBindJSON(&in); err != nil || strings.TrimSpace(in.Name) == "" {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "name is required"})
return
}
slug := slugify(in.Slug)
if slug == "" {
slug = slugify(in.Name)
}
active := true
if in.IsActive != nil {
active = *in.IsActive
}
if in.AllowedReturnOrigins == nil {
in.AllowedReturnOrigins = []string{}
}
client := &models.ClientApp{
Name: in.Name,
Slug: slug,
APIKey: randToken("pk_"),
Secret: randToken("sk_"),
ZarinPalMerchantID: in.ZarinPalMerchantID,
ZarinPalSandbox: in.ZarinPalSandbox,
AllowedReturnOrigins: in.AllowedReturnOrigins,
WebhookURL: in.WebhookURL,
IsActive: active,
}
created, err := h.store.CreateClientApp(c.Request.Context(), client)
if err != nil {
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
c.JSON(http.StatusConflict, models.APIError{Code: "conflict", Message: "slug already exists"})
return
}
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
return
}
// created includes the plaintext secret exactly once.
c.JSON(http.StatusCreated, created)
}
func (h *AdminHandler) Get(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
return
}
client, err := h.store.GetClientApp(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "client not found"})
return
}
c.JSON(http.StatusOK, client)
}
func (h *AdminHandler) Update(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
return
}
existing, err := h.store.GetClientApp(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "client not found"})
return
}
var in clientInput
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid body"})
return
}
if in.Name != "" {
existing.Name = in.Name
}
existing.ZarinPalMerchantID = in.ZarinPalMerchantID
existing.ZarinPalSandbox = in.ZarinPalSandbox
if in.AllowedReturnOrigins != nil {
existing.AllowedReturnOrigins = in.AllowedReturnOrigins
}
existing.WebhookURL = in.WebhookURL
if in.IsActive != nil {
existing.IsActive = *in.IsActive
}
updated, err := h.store.UpdateClientApp(c.Request.Context(), id, existing)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
return
}
c.JSON(http.StatusOK, updated)
}
func (h *AdminHandler) RotateSecret(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
return
}
updated, err := h.store.RotateSecret(c.Request.Context(), id, randToken("sk_"))
if err != nil {
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "client not found"})
return
}
c.JSON(http.StatusOK, updated) // includes the new secret once
}
func (h *AdminHandler) Delete(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
return
}
if err := h.store.DeleteClientApp(c.Request.Context(), id); err != nil {
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "client not found"})
return
}
c.Status(http.StatusNoContent)
}
func (h *AdminHandler) ListTransactions(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 200 {
pageSize = 20
}
var clientID *uuid.UUID
if cidStr := c.Query("client_id"); cidStr != "" {
if cid, err := uuid.Parse(cidStr); err == nil {
clientID = &cid
}
}
txns, total, err := h.store.ListTransactions(c.Request.Context(), clientID, c.Query("status"), page, pageSize)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
return
}
if txns == nil {
txns = []*models.Transaction{}
}
c.JSON(http.StatusOK, gin.H{
"data": txns,
"meta": gin.H{"page": page, "page_size": pageSize, "total": total, "has_more": int64(page*pageSize) < total},
})
}