62ea110605
Lets the broker's ZarinPal merchant / sandbox / amount-unit be set from Admin → درگاه پرداخت (persisted in payment.settings) instead of env + redeploy, and adds a per-app "test payment" button that mints a real ZarinPal StartPay link straight from the panel — no site wiring needed. - migration 33_payment_settings.sql: singleton payment.settings + a transactions.is_test column. (33, not 32 — 32 is content_render_engine.) - broker read-path precedence: per-client override > DB settings > env. - POST /v1/admin/clients/:id/test-payment + GET/PUT /v1/admin/settings. - admin UI: «تنظیمات زرینپال» tab + «پرداخت آزمایشی» button. Adversarial-review fixes (2 confirmed HIGH): - do NOT pre-seed the settings row — a seeded sandbox=TRUE default would override a production ZARINPAL_SANDBOX=false env and silently route real payments to sandbox.zarinpal.com until an admin untouched the toggle. No row → env governs until an admin saves. - test transactions are tagged is_test and the webhook dispatcher skips them, so an admin smoke-test can never notify (or credit) a real client, regardless of metadata. Broker-authoritative, not consumer-dependent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
245 lines
7.5 KiB
Go
245 lines
7.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"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"`
|
|
}
|
|
|
|
// ── Global ZarinPal settings (admin-editable) ────────────────────────────────
|
|
|
|
func (h *AdminHandler) GetSettings(c *gin.Context) {
|
|
s, err := h.store.GetSettings(c.Request.Context())
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrNotFound) {
|
|
// Row missing (table exists) — return sane defaults so the form renders.
|
|
c.JSON(http.StatusOK, models.Settings{ZarinPalSandbox: true, ZarinPalAmountUnit: "rial"})
|
|
return
|
|
}
|
|
// Most likely the table doesn't exist yet — tell the admin to run migration 32.
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, s)
|
|
}
|
|
|
|
func (h *AdminHandler) UpdateSettings(c *gin.Context) {
|
|
var in struct {
|
|
ZarinPalMerchantID string `json:"zarinpal_merchant_id"`
|
|
ZarinPalSandbox bool `json:"zarinpal_sandbox"`
|
|
ZarinPalAmountUnit string `json:"zarinpal_amount_unit"`
|
|
}
|
|
if err := c.ShouldBindJSON(&in); err != nil {
|
|
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid body"})
|
|
return
|
|
}
|
|
unit := strings.ToLower(strings.TrimSpace(in.ZarinPalAmountUnit))
|
|
if unit != "toman" {
|
|
unit = "rial"
|
|
}
|
|
s, err := h.store.UpdateSettings(c.Request.Context(), strings.TrimSpace(in.ZarinPalMerchantID), in.ZarinPalSandbox, unit)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, s)
|
|
}
|
|
|
|
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},
|
|
})
|
|
}
|