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,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
|
||||
}
|
||||
Reference in New Issue
Block a user