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:
@@ -0,0 +1,54 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config is the payment-broker runtime configuration, all from env.
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
JWTSecret string // verifies FlatRender admin JWTs for /v1/admin/* endpoints
|
||||
Port string
|
||||
|
||||
// PublicBaseURL is the broker's externally reachable base (e.g. https://pay.flatrender.ir).
|
||||
// ZarinPal callbacks and the user redirect target are built from it.
|
||||
PublicBaseURL string
|
||||
|
||||
// ── ZarinPal (shared default merchant) ──────────────────────────────────
|
||||
// A client_app may override MerchantId/Sandbox; otherwise these defaults apply.
|
||||
ZarinPalMerchantID string
|
||||
ZarinPalSandbox bool
|
||||
// AmountUnit is the unit ZarinPal expects in the amount field: "rial" (official
|
||||
// v4) or "toman". The broker always stores Rial canonically and converts here.
|
||||
ZarinPalAmountUnit string
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
base := strings.TrimRight(getEnv("PUBLIC_BASE_URL", "http://localhost:8080"), "/")
|
||||
unit := strings.ToLower(getEnv("ZARINPAL_AMOUNT_UNIT", "rial"))
|
||||
if unit != "toman" {
|
||||
unit = "rial"
|
||||
}
|
||||
return Config{
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/flatrender?search_path=payment,public"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "change-me"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
PublicBaseURL: base,
|
||||
ZarinPalMerchantID: getEnv("ZARINPAL_MERCHANT_ID", ""),
|
||||
ZarinPalSandbox: getEnv("ZARINPAL_SANDBOX", "true") == "true",
|
||||
ZarinPalAmountUnit: unit,
|
||||
}
|
||||
}
|
||||
|
||||
// CallbackURL is the single ZarinPal-verified callback endpoint on this broker.
|
||||
func (c Config) CallbackURL() string {
|
||||
return c.PublicBaseURL + "/callback/zarinpal"
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/flatrender/payment-svc/internal/models"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) }
|
||||
|
||||
// ── Client apps ───────────────────────────────────────────────────────────────
|
||||
|
||||
const clientCols = `id, tenant_id, name, slug, api_key, secret,
|
||||
zarinpal_merchant_id, zarinpal_sandbox, allowed_return_origins, webhook_url,
|
||||
is_active, created_at, updated_at`
|
||||
|
||||
func scanClient(row pgx.Row, withSecret bool) (*models.ClientApp, error) {
|
||||
var c models.ClientApp
|
||||
var secret string
|
||||
if err := row.Scan(
|
||||
&c.ID, &c.TenantID, &c.Name, &c.Slug, &c.APIKey, &secret,
|
||||
&c.ZarinPalMerchantID, &c.ZarinPalSandbox, &c.AllowedReturnOrigins, &c.WebhookURL,
|
||||
&c.IsActive, &c.CreatedAt, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if withSecret {
|
||||
c.Secret = secret
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateClientApp(ctx context.Context, c *models.ClientApp) (*models.ClientApp, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO payment.client_apps
|
||||
(tenant_id, name, slug, api_key, secret, zarinpal_merchant_id, zarinpal_sandbox,
|
||||
allowed_return_origins, webhook_url, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
RETURNING `+clientCols,
|
||||
c.TenantID, c.Name, c.Slug, c.APIKey, c.Secret, c.ZarinPalMerchantID, c.ZarinPalSandbox,
|
||||
c.AllowedReturnOrigins, c.WebhookURL, c.IsActive)
|
||||
return scanClient(row, true)
|
||||
}
|
||||
|
||||
func (s *Store) ListClientApps(ctx context.Context) ([]*models.ClientApp, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT `+clientCols+` FROM payment.client_apps ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.ClientApp
|
||||
for rows.Next() {
|
||||
c, err := scanClient(rows, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetClientApp(ctx context.Context, id uuid.UUID) (*models.ClientApp, error) {
|
||||
row := s.pool.QueryRow(ctx, `SELECT `+clientCols+` FROM payment.client_apps WHERE id = $1`, id)
|
||||
c, err := scanClient(row, false)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// GetClientByAPIKey returns the client incl. its secret (for auth + signing).
|
||||
func (s *Store) GetClientByAPIKey(ctx context.Context, apiKey string) (*models.ClientApp, error) {
|
||||
row := s.pool.QueryRow(ctx, `SELECT `+clientCols+` FROM payment.client_apps WHERE api_key = $1 AND is_active = TRUE`, apiKey)
|
||||
c, err := scanClient(row, true)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateClientApp(ctx context.Context, id uuid.UUID, c *models.ClientApp) (*models.ClientApp, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE payment.client_apps SET
|
||||
name = $2, zarinpal_merchant_id = $3, zarinpal_sandbox = $4,
|
||||
allowed_return_origins = $5, webhook_url = $6, is_active = $7
|
||||
WHERE id = $1
|
||||
RETURNING `+clientCols,
|
||||
id, c.Name, c.ZarinPalMerchantID, c.ZarinPalSandbox,
|
||||
c.AllowedReturnOrigins, c.WebhookURL, c.IsActive)
|
||||
res, err := scanClient(row, false)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (s *Store) RotateSecret(ctx context.Context, id uuid.UUID, newSecret string) (*models.ClientApp, error) {
|
||||
row := s.pool.QueryRow(ctx, `UPDATE payment.client_apps SET secret = $2 WHERE id = $1 RETURNING `+clientCols, id, newSecret)
|
||||
c, err := scanClient(row, true)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteClientApp(ctx context.Context, id uuid.UUID) error {
|
||||
ct, err := s.pool.Exec(ctx, `DELETE FROM payment.client_apps WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Transactions ──────────────────────────────────────────────────────────────
|
||||
|
||||
const txnCols = `id, client_app_id, status, gateway, amount_rial, currency, description,
|
||||
client_ref, return_url, metadata, payer_mobile, payer_email,
|
||||
authority, ref_id, card_pan, fee_rial, gateway_response, failure_reason,
|
||||
paid_at, failed_at, expires_at, created_at, updated_at`
|
||||
|
||||
func scanTxn(row pgx.Row) (*models.Transaction, error) {
|
||||
var t models.Transaction
|
||||
var meta, gwResp []byte
|
||||
if err := row.Scan(
|
||||
&t.ID, &t.ClientAppID, &t.Status, &t.Gateway, &t.AmountRial, &t.Currency, &t.Description,
|
||||
&t.ClientRef, &t.ReturnURL, &meta, &t.PayerMobile, &t.PayerEmail,
|
||||
&t.Authority, &t.RefID, &t.CardPan, &t.FeeRial, &gwResp, &t.FailureReason,
|
||||
&t.PaidAt, &t.FailedAt, &t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Metadata = meta
|
||||
t.GatewayResponse = gwResp
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateTransaction(ctx context.Context, t *models.Transaction) (*models.Transaction, error) {
|
||||
var meta any
|
||||
if len(t.Metadata) > 0 {
|
||||
meta = []byte(t.Metadata)
|
||||
}
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO payment.transactions
|
||||
(client_app_id, status, gateway, amount_rial, currency, description,
|
||||
client_ref, return_url, metadata, payer_mobile, payer_email, expires_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING `+txnCols,
|
||||
t.ClientAppID, t.Status, t.Gateway, t.AmountRial, t.Currency, t.Description,
|
||||
t.ClientRef, t.ReturnURL, meta, t.PayerMobile, t.PayerEmail, t.ExpiresAt)
|
||||
return scanTxn(row)
|
||||
}
|
||||
|
||||
func (s *Store) SetAuthority(ctx context.Context, id uuid.UUID, authority string) error {
|
||||
_, err := s.pool.Exec(ctx, `UPDATE payment.transactions SET authority = $2, status = $3 WHERE id = $1`,
|
||||
id, authority, models.StatusPending)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetTransaction(ctx context.Context, id uuid.UUID) (*models.Transaction, error) {
|
||||
row := s.pool.QueryRow(ctx, `SELECT `+txnCols+` FROM payment.transactions WHERE id = $1`, id)
|
||||
t, err := scanTxn(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (s *Store) GetTransactionByAuthority(ctx context.Context, authority string) (*models.Transaction, error) {
|
||||
row := s.pool.QueryRow(ctx, `SELECT `+txnCols+` FROM payment.transactions WHERE authority = $1`, authority)
|
||||
t, err := scanTxn(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// MarkPaid transitions a pending txn → Paid (idempotent: only if not already terminal).
|
||||
func (s *Store) MarkPaid(ctx context.Context, id uuid.UUID, refID, cardPan string, fee int64, gwResp []byte) (*models.Transaction, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE payment.transactions
|
||||
SET status = $2, ref_id = $3, card_pan = NULLIF($4,''), fee_rial = $5,
|
||||
gateway_response = $6, paid_at = NOW()
|
||||
WHERE id = $1 AND status IN ($7,$8)
|
||||
RETURNING `+txnCols,
|
||||
id, models.StatusPaid, refID, cardPan, fee, gwResp, models.StatusPending, models.StatusCreated)
|
||||
t, err := scanTxn(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound // already terminal or missing
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (s *Store) MarkFailed(ctx context.Context, id uuid.UUID, reason string, gwResp []byte) (*models.Transaction, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
UPDATE payment.transactions
|
||||
SET status = $2, failure_reason = $3, gateway_response = $4, failed_at = NOW()
|
||||
WHERE id = $1 AND status IN ($5,$6)
|
||||
RETURNING `+txnCols,
|
||||
id, models.StatusFailed, reason, gwResp, models.StatusPending, models.StatusCreated)
|
||||
t, err := scanTxn(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
func (s *Store) ListTransactions(ctx context.Context, clientID *uuid.UUID, status string, page, pageSize int) ([]*models.Transaction, int64, error) {
|
||||
where := "TRUE"
|
||||
args := []any{}
|
||||
i := 1
|
||||
if clientID != nil {
|
||||
where += fmt.Sprintf(" AND t.client_app_id = $%d", i)
|
||||
args = append(args, *clientID)
|
||||
i++
|
||||
}
|
||||
if status != "" {
|
||||
where += fmt.Sprintf(" AND t.status = $%d", i)
|
||||
args = append(args, status)
|
||||
i++
|
||||
}
|
||||
|
||||
var total int64
|
||||
_ = s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM payment.transactions t WHERE "+where, args...).Scan(&total)
|
||||
|
||||
args = append(args, pageSize, (page-1)*pageSize)
|
||||
q := fmt.Sprintf(`
|
||||
SELECT t.id, t.client_app_id, t.status, t.gateway, t.amount_rial, t.currency, t.description,
|
||||
t.client_ref, t.return_url, t.metadata, t.payer_mobile, t.payer_email,
|
||||
t.authority, t.ref_id, t.card_pan, t.fee_rial, t.gateway_response, t.failure_reason,
|
||||
t.paid_at, t.failed_at, t.expires_at, t.created_at, t.updated_at, c.slug
|
||||
FROM payment.transactions t
|
||||
JOIN payment.client_apps c ON c.id = t.client_app_id
|
||||
WHERE %s ORDER BY t.created_at DESC LIMIT $%d OFFSET $%d`, where, i, i+1)
|
||||
rows, err := s.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*models.Transaction
|
||||
for rows.Next() {
|
||||
var t models.Transaction
|
||||
var meta, gwResp []byte
|
||||
if err := rows.Scan(
|
||||
&t.ID, &t.ClientAppID, &t.Status, &t.Gateway, &t.AmountRial, &t.Currency, &t.Description,
|
||||
&t.ClientRef, &t.ReturnURL, &meta, &t.PayerMobile, &t.PayerEmail,
|
||||
&t.Authority, &t.RefID, &t.CardPan, &t.FeeRial, &gwResp, &t.FailureReason,
|
||||
&t.PaidAt, &t.FailedAt, &t.ExpiresAt, &t.CreatedAt, &t.UpdatedAt, &t.ClientSlug,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
t.Metadata = meta
|
||||
t.GatewayResponse = gwResp
|
||||
out = append(out, &t)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// ── Webhook deliveries ────────────────────────────────────────────────────────
|
||||
|
||||
type WebhookDelivery struct {
|
||||
ID uuid.UUID
|
||||
TransactionID uuid.UUID
|
||||
URL string
|
||||
Payload []byte
|
||||
Signature string
|
||||
Attempts int
|
||||
}
|
||||
|
||||
func (s *Store) EnqueueWebhook(ctx context.Context, txnID uuid.UUID, url string, payload []byte, signature string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO payment.webhook_deliveries (transaction_id, url, payload, signature, next_attempt_at)
|
||||
VALUES ($1,$2,$3,$4, NOW()) RETURNING id`,
|
||||
txnID, url, payload, signature).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ClaimDueWebhooks returns undelivered webhooks whose next_attempt_at has passed.
|
||||
func (s *Store) ClaimDueWebhooks(ctx context.Context, limit int) ([]*WebhookDelivery, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, transaction_id, url, payload, signature, attempts
|
||||
FROM payment.webhook_deliveries
|
||||
WHERE delivered = FALSE AND attempts < 8 AND next_attempt_at <= NOW()
|
||||
ORDER BY next_attempt_at ASC LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*WebhookDelivery
|
||||
for rows.Next() {
|
||||
var w WebhookDelivery
|
||||
if err := rows.Scan(&w.ID, &w.TransactionID, &w.URL, &w.Payload, &w.Signature, &w.Attempts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &w)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) MarkWebhookDelivered(ctx context.Context, id uuid.UUID, statusCode int) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE payment.webhook_deliveries
|
||||
SET delivered = TRUE, last_status = $2, attempts = attempts + 1
|
||||
WHERE id = $1`, id, statusCode)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) MarkWebhookFailed(ctx context.Context, id uuid.UUID, statusCode int, errMsg string, backoff time.Duration) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE payment.webhook_deliveries
|
||||
SET attempts = attempts + 1, last_status = $2, last_error = $3,
|
||||
next_attempt_at = NOW() + $4::interval
|
||||
WHERE id = $1`, id, statusCode, errMsg, fmt.Sprintf("%d seconds", int(backoff.Seconds())))
|
||||
return err
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/flatrender/payment-svc/internal/db"
|
||||
"github.com/flatrender/payment-svc/internal/models"
|
||||
"github.com/flatrender/payment-svc/internal/signing"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
CtxIsAdmin = "is_admin"
|
||||
CtxClient = "client_app"
|
||||
CtxRawBody = "raw_body"
|
||||
)
|
||||
|
||||
// JWTAuth validates a FlatRender identity JWT (same secret) for admin endpoints.
|
||||
func JWTAuth(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
hdr := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(hdr, "Bearer ") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "unauthorized", Message: "missing bearer token"})
|
||||
return
|
||||
}
|
||||
token, err := jwt.Parse(hdr[7:], func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "unauthorized", Message: "invalid token"})
|
||||
return
|
||||
}
|
||||
claims, _ := token.Claims.(jwt.MapClaims)
|
||||
isAdmin := false
|
||||
switch v := claims["is_admin"].(type) {
|
||||
case bool:
|
||||
isAdmin = v
|
||||
case string:
|
||||
isAdmin = v == "true"
|
||||
}
|
||||
c.Set(CtxIsAdmin, isAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
v, _ := c.Get(CtxIsAdmin)
|
||||
if b, _ := v.(bool); !b {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, models.APIError{Code: "forbidden", Message: "admin required"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ClientAuth authenticates a client site by API key + HMAC body signature.
|
||||
//
|
||||
// X-Api-Key: the client's public key
|
||||
// X-Signature: hex( HMAC-SHA256(client.secret, raw_request_body) )
|
||||
//
|
||||
// The verified ClientApp and the raw body are stashed in the gin context.
|
||||
func ClientAuth(store *db.Store) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
apiKey := c.GetHeader("X-Api-Key")
|
||||
if apiKey == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "unauthorized", Message: "missing X-Api-Key"})
|
||||
return
|
||||
}
|
||||
client, err := store.GetClientByAPIKey(c.Request.Context(), apiKey)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "unauthorized", Message: "unknown or inactive api key"})
|
||||
return
|
||||
}
|
||||
|
||||
raw, _ := io.ReadAll(c.Request.Body)
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(raw)) // restore for the handler
|
||||
|
||||
sig := c.GetHeader("X-Signature")
|
||||
if sig == "" || !signing.Verify(client.Secret, raw, sig) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, models.APIError{Code: "bad_signature", Message: "invalid request signature"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(CtxClient, client)
|
||||
c.Set(CtxRawBody, raw)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func GetClient(c *gin.Context) *models.ClientApp {
|
||||
v, _ := c.Get(CtxClient)
|
||||
cl, _ := v.(*models.ClientApp)
|
||||
return cl
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// APIError is the standard error envelope across FlatRender services.
|
||||
type APIError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ── Client apps (tenants of the broker — each site that pays through it) ───────
|
||||
|
||||
type ClientApp struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
TenantID *uuid.UUID `json:"tenant_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
APIKey string `json:"api_key"`
|
||||
Secret string `json:"secret,omitempty"` // returned only on create / rotate
|
||||
ZarinPalMerchantID *string `json:"zarinpal_merchant_id,omitempty"`
|
||||
ZarinPalSandbox *bool `json:"zarinpal_sandbox,omitempty"`
|
||||
AllowedReturnOrigins []string `json:"allowed_return_origins"`
|
||||
WebhookURL *string `json:"webhook_url,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ── Transactions ──────────────────────────────────────────────────────────────
|
||||
|
||||
type Transaction struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ClientAppID uuid.UUID `json:"client_app_id"`
|
||||
ClientSlug string `json:"client_slug,omitempty"` // joined for admin views
|
||||
Status string `json:"status"`
|
||||
Gateway string `json:"gateway"`
|
||||
AmountRial int64 `json:"amount_rial"`
|
||||
Currency string `json:"currency"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
ClientRef *string `json:"client_ref,omitempty"`
|
||||
ReturnURL string `json:"return_url"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
PayerMobile *string `json:"payer_mobile,omitempty"`
|
||||
PayerEmail *string `json:"payer_email,omitempty"`
|
||||
Authority *string `json:"authority,omitempty"`
|
||||
RefID *string `json:"ref_id,omitempty"`
|
||||
CardPan *string `json:"card_pan,omitempty"`
|
||||
FeeRial *int64 `json:"fee_rial,omitempty"`
|
||||
GatewayResponse json.RawMessage `json:"gateway_response,omitempty"`
|
||||
FailureReason *string `json:"failure_reason,omitempty"`
|
||||
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||||
FailedAt *time.Time `json:"failed_at,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Transaction status values (kept as plain text — no PG enum, for flexibility).
|
||||
const (
|
||||
StatusCreated = "Created"
|
||||
StatusPending = "Pending"
|
||||
StatusPaid = "Paid"
|
||||
StatusFailed = "Failed"
|
||||
StatusCancelled = "Cancelled"
|
||||
StatusExpired = "Expired"
|
||||
)
|
||||
|
||||
// ── Client-facing request/response shapes ─────────────────────────────────────
|
||||
|
||||
// PayRequest is the body a client site POSTs to /v1/pay/request.
|
||||
type PayRequest struct {
|
||||
Amount int64 `json:"amount"` // in `currency` units
|
||||
Currency string `json:"currency,omitempty"` // "IRR" (Rial, default) or "IRT" (Toman)
|
||||
Description string `json:"description,omitempty"`
|
||||
ClientRef string `json:"client_ref,omitempty"`
|
||||
ReturnURL string `json:"return_url"`
|
||||
Mobile string `json:"mobile,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// PayResponse is returned from /v1/pay/request.
|
||||
type PayResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
PaymentURL string `json:"payment_url"`
|
||||
Authority string `json:"authority"`
|
||||
AmountRial int64 `json:"amount_rial"`
|
||||
}
|
||||
|
||||
// WebhookPayload is the signed body POSTed to a client's webhook_url.
|
||||
type WebhookPayload struct {
|
||||
Event string `json:"event"` // payment.paid | payment.failed
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
AmountRial int64 `json:"amount_rial"`
|
||||
Currency string `json:"currency"`
|
||||
ClientRef string `json:"client_ref,omitempty"`
|
||||
RefID string `json:"ref_id,omitempty"`
|
||||
Authority string `json:"authority,omitempty"`
|
||||
CardPan string `json:"card_pan,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
PaidAt *time.Time `json:"paid_at,omitempty"`
|
||||
CreatedAtTs int64 `json:"ts"`
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package signing provides the HMAC-SHA256 helpers the broker uses to (a) verify
|
||||
// inbound client requests and (b) sign outbound webhooks + return redirects so a
|
||||
// client site can trust a result came from the broker untampered.
|
||||
package signing
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// Sign returns the lowercase hex HMAC-SHA256 of message keyed by secret.
|
||||
func Sign(secret string, message []byte) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(message)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// Verify compares an expected hex signature against the computed one in constant time.
|
||||
func Verify(secret string, message []byte, provided string) bool {
|
||||
expected := Sign(secret, message)
|
||||
// hmac.Equal is constant-time; compare the raw bytes after decoding.
|
||||
pb, err := hex.DecodeString(provided)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
eb, _ := hex.DecodeString(expected)
|
||||
return hmac.Equal(eb, pb)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package web serves the broker's minimal hosted pages (RTL Persian). Clients
|
||||
// normally redirect users back to their OWN return_url; these pages are a
|
||||
// branded landing + a fallback result screen for direct visits.
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const shell = `<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>%s</title>
|
||||
<style>
|
||||
:root{color-scheme:dark}
|
||||
body{margin:0;font-family:Tahoma,Vazirmatn,system-ui,sans-serif;background:#0b0d17;color:#e7e9f3;
|
||||
display:flex;min-height:100vh;align-items:center;justify-content:center;text-align:center}
|
||||
.card{background:#141726;border:1px solid #232742;border-radius:20px;padding:40px 32px;max-width:420px;width:90%%}
|
||||
.logo{font-weight:800;font-size:22px;letter-spacing:.5px;color:#7c8cff}
|
||||
h1{font-size:20px;margin:18px 0 8px}
|
||||
p{color:#9aa0bd;line-height:1.9;font-size:14px;margin:6px 0}
|
||||
.badge{display:inline-block;padding:6px 16px;border-radius:999px;font-size:13px;margin-top:14px}
|
||||
.ok{background:rgba(34,197,94,.15);color:#4ade80}
|
||||
.fail{background:rgba(239,68,68,.15);color:#f87171}
|
||||
.muted{font-size:12px;color:#6a708f;margin-top:18px}
|
||||
</style></head><body><div class="card">%s</div></body></html>`
|
||||
|
||||
func page(title, inner string) string {
|
||||
return "<!--FlatPay-->" + fmt.Sprintf(shell, html.EscapeString(title), inner)
|
||||
}
|
||||
|
||||
func Landing(c *gin.Context) {
|
||||
inner := `<div class="logo">FlatRender Pay</div>
|
||||
<h1>درگاه پرداخت امن</h1>
|
||||
<p>این سرویس پرداختهای آنلاین را از طریق زرینپال پردازش میکند.</p>
|
||||
<p class="muted">برای پرداخت، از طریق وبسایت مربوطه اقدام کنید.</p>`
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, page("FlatRender Pay", inner))
|
||||
}
|
||||
|
||||
// Result is an optional fallback screen a client may point its return_url at.
|
||||
func Result(c *gin.Context) {
|
||||
status := c.Query("status")
|
||||
ref := c.Query("ref_id")
|
||||
var inner string
|
||||
if status == "Paid" {
|
||||
inner = `<div class="logo">FlatRender Pay</div>
|
||||
<h1>پرداخت موفق</h1>
|
||||
<span class="badge ok">پرداخت با موفقیت انجام شد</span>`
|
||||
if ref != "" {
|
||||
inner += `<p class="muted">کد پیگیری: ` + html.EscapeString(ref) + `</p>`
|
||||
}
|
||||
} else {
|
||||
inner = `<div class="logo">FlatRender Pay</div>
|
||||
<h1>پرداخت ناموفق</h1>
|
||||
<span class="badge fail">پرداخت انجام نشد یا لغو شد</span>`
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, page("نتیجه پرداخت", inner))
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package zarinpal is a thin client for ZarinPal Payment Gateway v4.
|
||||
// Ported from the proven implementation in the identity service
|
||||
// (services/identity/.../PaymentService.cs).
|
||||
//
|
||||
// Flow:
|
||||
// request.json → { authority } → redirect user to StartPay/{authority}
|
||||
// user pays, ZarinPal calls back → verify.json → { code, ref_id }
|
||||
// code 100 = success, 101 = already verified (idempotent).
|
||||
package zarinpal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
prodAPI = "https://api.zarinpal.com/pg/v4/payment"
|
||||
prodStart = "https://www.zarinpal.com/pg/StartPay/"
|
||||
sandboxAPI = "https://sandbox.zarinpal.com/pg/v4/payment"
|
||||
sandStart = "https://sandbox.zarinpal.com/pg/StartPay/"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New() *Client {
|
||||
return &Client{http: &http.Client{Timeout: 20 * time.Second}}
|
||||
}
|
||||
|
||||
// RequestResult is the outcome of a payment request.
|
||||
type RequestResult struct {
|
||||
Authority string
|
||||
StartPay string
|
||||
Code int
|
||||
Raw json.RawMessage
|
||||
}
|
||||
|
||||
// VerifyResult is the outcome of a verify call.
|
||||
type VerifyResult struct {
|
||||
Code int
|
||||
RefID string
|
||||
CardPan string
|
||||
Fee int64
|
||||
Raw json.RawMessage
|
||||
}
|
||||
|
||||
func apiBase(sandbox bool) (string, string) {
|
||||
if sandbox {
|
||||
return sandboxAPI, sandStart
|
||||
}
|
||||
return prodAPI, prodStart
|
||||
}
|
||||
|
||||
// Request creates a ZarinPal payment authority. amount is in the unit the merchant
|
||||
// expects (caller converts Rial↔Toman per config).
|
||||
func (c *Client) Request(ctx context.Context, sandbox bool, merchantID string, amount int64, callbackURL, description string, metadata map[string]string) (*RequestResult, error) {
|
||||
base, start := apiBase(sandbox)
|
||||
body := map[string]any{
|
||||
"merchant_id": merchantID,
|
||||
"amount": amount,
|
||||
"callback_url": callbackURL,
|
||||
"description": description,
|
||||
}
|
||||
if len(metadata) > 0 {
|
||||
body["metadata"] = metadata
|
||||
}
|
||||
|
||||
root, raw, err := c.post(ctx, base+"/request.json", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, ok := root["data"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("zarinpal request: missing data (errors=%v)", root["errors"])
|
||||
}
|
||||
code := toInt(data["code"])
|
||||
if code != 100 {
|
||||
return &RequestResult{Code: code, Raw: raw}, fmt.Errorf("zarinpal request failed (code=%d): %v", code, root["errors"])
|
||||
}
|
||||
authority, _ := data["authority"].(string)
|
||||
return &RequestResult{Authority: authority, StartPay: start + authority, Code: code, Raw: raw}, nil
|
||||
}
|
||||
|
||||
// Verify confirms a payment by authority. Codes 100/101 mean success.
|
||||
func (c *Client) Verify(ctx context.Context, sandbox bool, merchantID string, amount int64, authority string) (*VerifyResult, error) {
|
||||
base, _ := apiBase(sandbox)
|
||||
body := map[string]any{
|
||||
"merchant_id": merchantID,
|
||||
"amount": amount,
|
||||
"authority": authority,
|
||||
}
|
||||
root, raw, err := c.post(ctx, base+"/verify.json", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, ok := root["data"].(map[string]any)
|
||||
if !ok {
|
||||
return &VerifyResult{Code: 0, Raw: raw}, fmt.Errorf("zarinpal verify: missing data (errors=%v)", root["errors"])
|
||||
}
|
||||
res := &VerifyResult{Code: toInt(data["code"]), Raw: raw}
|
||||
if ref, ok := data["ref_id"]; ok {
|
||||
res.RefID = fmt.Sprintf("%v", toInt64(ref))
|
||||
}
|
||||
if pan, ok := data["card_pan"].(string); ok {
|
||||
res.CardPan = pan
|
||||
}
|
||||
if fee, ok := data["fee"]; ok {
|
||||
res.Fee = toInt64(fee)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, url string, body map[string]any) (map[string]any, json.RawMessage, error) {
|
||||
buf, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var raw json.RawMessage
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if err := dec.Decode(&raw); err != nil {
|
||||
return nil, nil, fmt.Errorf("zarinpal: decode response: %w", err)
|
||||
}
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(raw, &root); err != nil {
|
||||
return nil, raw, fmt.Errorf("zarinpal: parse response: %w", err)
|
||||
}
|
||||
return root, raw, nil
|
||||
}
|
||||
|
||||
func toInt(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return int(i)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func toInt64(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user