Files
soroush.asadi 62ea110605
CI/CD / CI · Web (tsc) (push) Successful in 1m33s
CI/CD / Deploy · full stack (push) Failing after 20s
feat(payment): admin-editable ZarinPal settings + in-panel test payment
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>
2026-06-26 00:47:10 +03:30

88 lines
2.8 KiB
Go

package main
import (
"context"
"log"
"net/http"
"github.com/flatrender/payment-svc/internal/config"
"github.com/flatrender/payment-svc/internal/db"
"github.com/flatrender/payment-svc/internal/handlers"
"github.com/flatrender/payment-svc/internal/middleware"
"github.com/flatrender/payment-svc/internal/web"
"github.com/flatrender/payment-svc/internal/zarinpal"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
cfg := config.Load()
pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
if err != nil {
log.Fatalf("connect db: %v", err)
}
defer pool.Close()
if err := pool.Ping(context.Background()); err != nil {
log.Fatalf("ping db: %v", err)
}
store := db.NewStore(pool)
zp := zarinpal.New()
disp := handlers.NewDispatcher(store)
// Background webhook delivery loop.
go disp.Run(context.Background())
payH := handlers.NewPayHandler(store, zp, disp, cfg)
adminH := handlers.NewAdminHandler(store)
r := gin.Default()
r.SetTrustedProxies(nil) //nolint — behind mirror-nginx; we read X-Forwarded-* only informally
health := func(c *gin.Context) {
if err := store.Ping(c.Request.Context()); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "down", "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "service": "payment"})
}
r.GET("/health", health)
r.GET("/healthz", health)
// Public pages + ZarinPal callback (the single verified domain).
r.GET("/", web.Landing)
r.GET("/result", web.Result)
r.GET("/callback/zarinpal", payH.Callback)
v1 := r.Group("/v1")
// ── Client API (API key + HMAC signature) ────────────────────────────────
pay := v1.Group("/pay", middleware.ClientAuth(store))
{
pay.POST("/request", payH.Request)
pay.POST("/inquiry", payH.Inquiry)
}
// ── Admin API (FlatRender admin JWT) ─────────────────────────────────────
admin := v1.Group("/admin", middleware.JWTAuth(cfg.JWTSecret), middleware.RequireAdmin())
{
admin.GET("/settings", adminH.GetSettings)
admin.PUT("/settings", adminH.UpdateSettings)
admin.GET("/clients", adminH.List)
admin.POST("/clients", adminH.Create)
admin.GET("/clients/:id", adminH.Get)
admin.PUT("/clients/:id", adminH.Update)
admin.DELETE("/clients/:id", adminH.Delete)
admin.POST("/clients/:id/rotate-secret", adminH.RotateSecret)
admin.POST("/clients/:id/test-payment", payH.AdminTest)
admin.GET("/transactions", adminH.ListTransactions)
}
log.Printf("payment-svc listening on :%s (sandbox=%v, unit=%s, base=%s)",
cfg.Port, cfg.ZarinPalSandbox, cfg.ZarinPalAmountUnit, cfg.PublicBaseURL)
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("server: %v", err)
}
}