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>
This commit is contained in:
soroush.asadi
2026-06-15 23:59:54 +03:30
parent 896ce3dfa9
commit ec51e87d2d
1830 changed files with 899129 additions and 8 deletions
+204
View File
@@ -0,0 +1,204 @@
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},
})
}
+304
View File
@@ -0,0 +1,304 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/flatrender/payment-svc/internal/config"
"github.com/flatrender/payment-svc/internal/db"
"github.com/flatrender/payment-svc/internal/middleware"
"github.com/flatrender/payment-svc/internal/models"
"github.com/flatrender/payment-svc/internal/signing"
"github.com/flatrender/payment-svc/internal/zarinpal"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
const minAmountRial = 1000 // ZarinPal minimum (= 100 Toman)
type PayHandler struct {
store *db.Store
zp *zarinpal.Client
disp *Dispatcher
cfg config.Config
}
func NewPayHandler(store *db.Store, zp *zarinpal.Client, disp *Dispatcher, cfg config.Config) *PayHandler {
return &PayHandler{store: store, zp: zp, disp: disp, cfg: cfg}
}
// merchantFor resolves the ZarinPal merchant + sandbox flag for a client
// (per-client override falls back to the broker default).
func (h *PayHandler) merchantFor(client *models.ClientApp) (string, bool) {
merchant := h.cfg.ZarinPalMerchantID
if client.ZarinPalMerchantID != nil && *client.ZarinPalMerchantID != "" {
merchant = *client.ZarinPalMerchantID
}
sandbox := h.cfg.ZarinPalSandbox
if client.ZarinPalSandbox != nil {
sandbox = *client.ZarinPalSandbox
}
return merchant, sandbox
}
// zpAmount converts canonical Rial to the unit ZarinPal expects for this broker.
func (h *PayHandler) zpAmount(amountRial int64) int64 {
if h.cfg.ZarinPalAmountUnit == "toman" {
return amountRial / 10
}
return amountRial
}
// toRial normalizes the client-supplied amount + currency to canonical Rial.
func toRial(amount int64, currency string) int64 {
switch strings.ToUpper(strings.TrimSpace(currency)) {
case "IRT", "TOMAN", "TMN", "TOMANS":
return amount * 10
default: // IRR / RIAL / empty
return amount
}
}
// POST /v1/pay/request (client-authed: X-Api-Key + X-Signature)
func (h *PayHandler) Request(c *gin.Context) {
client := middleware.GetClient(c)
rawAny, _ := c.Get(middleware.CtxRawBody)
raw, _ := rawAny.([]byte)
var req models.PayRequest
if err := json.Unmarshal(raw, &req); err != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid JSON body"})
return
}
if req.ReturnURL == "" {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "return_url is required"})
return
}
if !originAllowed(client, req.ReturnURL) {
c.JSON(http.StatusBadRequest, models.APIError{Code: "return_url_not_allowed", Message: "return_url origin is not in the client's allowed list"})
return
}
amountRial := toRial(req.Amount, req.Currency)
if amountRial < minAmountRial {
c.JSON(http.StatusBadRequest, models.APIError{Code: "amount_too_low", Message: fmt.Sprintf("amount must be at least %d Rial (100 Toman)", minAmountRial)})
return
}
desc := req.Description
if desc == "" {
desc = "پرداخت آنلاین"
}
exp := time.Now().Add(30 * time.Minute)
txn := &models.Transaction{
ClientAppID: client.ID,
Status: models.StatusCreated,
Gateway: "ZarinPal",
AmountRial: amountRial,
Currency: "IRR",
Description: &desc,
ReturnURL: req.ReturnURL,
Metadata: req.Metadata,
ExpiresAt: &exp,
}
if req.ClientRef != "" {
txn.ClientRef = &req.ClientRef
}
if req.Mobile != "" {
txn.PayerMobile = &req.Mobile
}
if req.Email != "" {
txn.PayerEmail = &req.Email
}
created, err := h.store.CreateTransaction(c.Request.Context(), txn)
if err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: "could not create transaction"})
return
}
merchant, sandbox := h.merchantFor(client)
if merchant == "" {
c.JSON(http.StatusServiceUnavailable, models.APIError{Code: "gateway_unconfigured", Message: "ZarinPal merchant id is not configured"})
return
}
res, err := h.zp.Request(c.Request.Context(), sandbox, merchant, h.zpAmount(amountRial),
h.cfg.CallbackURL(), desc, map[string]string{"order_id": created.ID.String()})
if err != nil {
_, _ = h.store.MarkFailed(c.Request.Context(), created.ID, err.Error(), nil)
c.JSON(http.StatusBadGateway, models.APIError{Code: "gateway_error", Message: err.Error()})
return
}
if err := h.store.SetAuthority(c.Request.Context(), created.ID, res.Authority); err != nil {
c.JSON(http.StatusInternalServerError, models.APIError{Code: "db_error", Message: "could not persist authority"})
return
}
c.JSON(http.StatusOK, models.PayResponse{
ID: created.ID,
Status: models.StatusPending,
PaymentURL: res.StartPay,
Authority: res.Authority,
AmountRial: amountRial,
})
}
// GET /callback/zarinpal?Authority=..&Status=OK|NOK (PUBLIC — ZarinPal hits this)
func (h *PayHandler) Callback(c *gin.Context) {
authority := c.Query("Authority")
status := c.Query("Status")
if authority == "" {
c.String(http.StatusBadRequest, "missing Authority")
return
}
txn, err := h.store.GetTransactionByAuthority(c.Request.Context(), authority)
if err != nil {
c.String(http.StatusNotFound, "transaction not found")
return
}
client, err := h.store.GetClientApp(c.Request.Context(), txn.ClientAppID)
if err != nil {
c.String(http.StatusInternalServerError, "client not found")
return
}
// Re-fetch with secret for signing the redirect/webhook.
client, _ = h.store.GetClientByAPIKey(c.Request.Context(), client.APIKey)
now := time.Now().Unix()
// User cancelled / bank declined before verify.
if status != "OK" {
failed, _ := h.store.MarkFailed(c.Request.Context(), txn.ID, "user cancelled or status NOK", nil)
if failed != nil {
h.disp.Enqueue(c.Request.Context(), client, failed, now)
h.redirectBack(c, client, failed)
} else {
h.redirectBack(c, client, txn)
}
return
}
merchant, sandbox := h.merchantFor(client)
vr, err := h.zp.Verify(c.Request.Context(), sandbox, merchant, h.zpAmount(txn.AmountRial), authority)
if err != nil {
failed, _ := h.store.MarkFailed(c.Request.Context(), txn.ID, "verify error: "+err.Error(), rawJSON(vr))
final := pick(failed, txn)
h.disp.Enqueue(c.Request.Context(), client, final, now)
h.redirectBack(c, client, final)
return
}
if vr.Code == 100 || vr.Code == 101 {
paid, perr := h.store.MarkPaid(c.Request.Context(), txn.ID, vr.RefID, vr.CardPan, vr.Fee, rawJSON(vr))
if perr != nil {
// Already terminal (duplicate callback) — just bounce the user back with current state.
cur, _ := h.store.GetTransaction(c.Request.Context(), txn.ID)
h.redirectBack(c, client, pick(cur, txn))
return
}
h.disp.Enqueue(c.Request.Context(), client, paid, now)
h.redirectBack(c, client, paid)
return
}
failed, _ := h.store.MarkFailed(c.Request.Context(), txn.ID, fmt.Sprintf("zarinpal verify code %d", vr.Code), rawJSON(vr))
final := pick(failed, txn)
h.disp.Enqueue(c.Request.Context(), client, final, now)
h.redirectBack(c, client, final)
}
// redirectBack bounces the user to the client's return_url with a signed result.
// Signature canonical string: "{id}.{status}.{ref_id}.{amount_rial}".
func (h *PayHandler) redirectBack(c *gin.Context, client *models.ClientApp, t *models.Transaction) {
ref := ""
if t.RefID != nil {
ref = *t.RefID
}
canonical := fmt.Sprintf("%s.%s.%s.%d", t.ID, t.Status, ref, t.AmountRial)
sign := signing.Sign(client.Secret, []byte(canonical))
u, err := url.Parse(t.ReturnURL)
if err != nil {
c.String(http.StatusOK, "payment %s (ref %s)", t.Status, ref)
return
}
q := u.Query()
q.Set("status", t.Status)
q.Set("id", t.ID.String())
q.Set("ref_id", ref)
q.Set("sign", sign)
u.RawQuery = q.Encode()
c.Redirect(http.StatusFound, u.String())
}
// POST /v1/pay/inquiry (client-authed) body: { "id": "<txn uuid>" }
// Authoritative server-side status check — clients should NOT trust the redirect alone.
func (h *PayHandler) Inquiry(c *gin.Context) {
client := middleware.GetClient(c)
rawAny, _ := c.Get(middleware.CtxRawBody)
raw, _ := rawAny.([]byte)
var body struct {
ID string `json:"id"`
ClientRef string `json:"client_ref"`
}
_ = json.Unmarshal(raw, &body)
var txn *models.Transaction
var err error
if body.ID != "" {
id, perr := uuid.Parse(body.ID)
if perr != nil {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid id"})
return
}
txn, err = h.store.GetTransaction(c.Request.Context(), id)
} else {
c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "id is required"})
return
}
if err != nil || txn.ClientAppID != client.ID {
c.JSON(http.StatusNotFound, models.APIError{Code: "not_found", Message: "transaction not found"})
return
}
c.JSON(http.StatusOK, txn)
}
// ── helpers ──────────────────────────────────────────────────────────────────
func originAllowed(client *models.ClientApp, returnURL string) bool {
if len(client.AllowedReturnOrigins) == 0 {
return true // no allow-list configured → permissive
}
u, err := url.Parse(returnURL)
if err != nil || u.Host == "" {
return false
}
origin := strings.ToLower(u.Scheme + "://" + u.Host)
for _, o := range client.AllowedReturnOrigins {
if strings.ToLower(strings.TrimRight(o, "/")) == origin {
return true
}
}
return false
}
func rawJSON(vr *zarinpal.VerifyResult) []byte {
if vr == nil {
return nil
}
return vr.Raw
}
func pick(primary, fallback *models.Transaction) *models.Transaction {
if primary != nil {
return primary
}
return fallback
}
@@ -0,0 +1,123 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"log"
"net/http"
"time"
"github.com/flatrender/payment-svc/internal/db"
"github.com/flatrender/payment-svc/internal/models"
"github.com/flatrender/payment-svc/internal/signing"
)
// Dispatcher delivers signed webhooks to client sites with retry/backoff.
type Dispatcher struct {
store *db.Store
http *http.Client
}
func NewDispatcher(store *db.Store) *Dispatcher {
return &Dispatcher{store: store, http: &http.Client{Timeout: 15 * time.Second}}
}
// Enqueue builds the signed payload for a finished transaction and queues delivery.
// No-op if the client has no webhook_url configured.
func (d *Dispatcher) Enqueue(ctx context.Context, client *models.ClientApp, t *models.Transaction, nowUnix int64) {
if client.WebhookURL == nil || *client.WebhookURL == "" {
return
}
event := "payment.failed"
if t.Status == models.StatusPaid {
event = "payment.paid"
}
payload := models.WebhookPayload{
Event: event,
ID: t.ID,
Status: t.Status,
AmountRial: t.AmountRial,
Currency: t.Currency,
Metadata: t.Metadata,
CreatedAtTs: nowUnix,
PaidAt: t.PaidAt,
}
if t.ClientRef != nil {
payload.ClientRef = *t.ClientRef
}
if t.RefID != nil {
payload.RefID = *t.RefID
}
if t.Authority != nil {
payload.Authority = *t.Authority
}
if t.CardPan != nil {
payload.CardPan = *t.CardPan
}
body, _ := json.Marshal(payload)
sig := signing.Sign(client.Secret, body)
if _, err := d.store.EnqueueWebhook(ctx, t.ID, *client.WebhookURL, body, sig); err != nil {
log.Printf("webhook enqueue failed for txn %s: %v", t.ID, err)
}
}
// Run starts the delivery loop until ctx is cancelled.
func (d *Dispatcher) Run(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
d.tick(ctx)
}
}
}
func (d *Dispatcher) tick(ctx context.Context) {
due, err := d.store.ClaimDueWebhooks(ctx, 20)
if err != nil {
log.Printf("webhook claim failed: %v", err)
return
}
for _, w := range due {
d.deliver(ctx, w)
}
}
func (d *Dispatcher) deliver(ctx context.Context, w *db.WebhookDelivery) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.URL, bytes.NewReader(w.Payload))
if err != nil {
_ = d.store.MarkWebhookFailed(ctx, w.ID, 0, err.Error(), backoff(w.Attempts))
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FlatPay-Signature", w.Signature)
req.Header.Set("X-FlatPay-Event", "payment")
resp, err := d.http.Do(req)
if err != nil {
_ = d.store.MarkWebhookFailed(ctx, w.ID, 0, err.Error(), backoff(w.Attempts))
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
_ = d.store.MarkWebhookDelivered(ctx, w.ID, resp.StatusCode)
return
}
_ = d.store.MarkWebhookFailed(ctx, w.ID, resp.StatusCode, "non-2xx response", backoff(w.Attempts))
}
// backoff: 30s, 1m, 2m, 4m, 8m, ... capped at 1h.
func backoff(attempts int) time.Duration {
d := 30 * time.Second
for i := 0; i < attempts; i++ {
d *= 2
if d >= time.Hour {
return time.Hour
}
}
return d
}