feat: add terminal-scoped transaction analytics

This commit is contained in:
2026-08-20 20:56:15 +03:30
parent f710762b90
commit 857f29635e
8 changed files with 571 additions and 55 deletions

View File

@@ -6,18 +6,48 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type DetailFilter struct {
View string
FailureStage string
TerminalID string
Cursor string
Limit int
View string
FailureStage string
TerminalID string
Status string
MinAmountRial *int64
MaxAmountRial *int64
Cursor string
Limit int
}
func (f DetailFilter) Validate() error {
views := map[string]bool{"ALL": true, "UNVERIFIED_PAID": true, "FAILED": true, "RETRIED": true}
if !views[f.View] {
return errors.New("analytics: invalid detail view")
}
statuses := map[string]bool{"": true, "Paid": true, "Verified": true, "Failed": true, "Reversed": true}
if !statuses[f.Status] {
return errors.New("analytics: invalid session status")
}
stages := map[string]bool{"": true, "NO_ATTEMPT": true, "REACHED_BANK": true, "OTHER_OBSERVED": true}
if !stages[f.FailureStage] || (f.FailureStage != "" && f.View != "FAILED") {
return errors.New("analytics: invalid failure stage")
}
if f.MinAmountRial != nil && *f.MinAmountRial < 0 || f.MaxAmountRial != nil && *f.MaxAmountRial < 0 {
return errors.New("analytics: amount bounds must be non-negative")
}
if f.MinAmountRial != nil && f.MaxAmountRial != nil && *f.MinAmountRial > *f.MaxAmountRial {
return errors.New("analytics: minimum amount exceeds maximum")
}
if f.Limit < 1 || f.Limit > 100 {
return errors.New("analytics: detail limit outside range")
}
return nil
}
type Attempt struct {
Sequence int `json:"sequence"`
Status string `json:"status"`
@@ -44,8 +74,8 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
if filter.Limit <= 0 {
filter.Limit = 25
}
if filter.Limit > 100 {
return nil, "", errors.New("analytics: detail limit exceeds 100")
if err := filter.Validate(); err != nil {
return nil, "", err
}
offset := 0
if filter.Cursor != "" {
@@ -58,28 +88,35 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
return nil, "", errors.New("analytics: invalid cursor")
}
}
statusPredicate := "ps.status='Paid'"
statusPredicate := "TRUE"
switch filter.View {
case "ALL":
statusPredicate = "TRUE"
case "UNVERIFIED_PAID":
statusPredicate = "ps.status='Paid'"
case "FAILED":
statusPredicate = "ps.status='Failed'"
case "RETRIED":
statusPredicate = "(SELECT count(*) FROM payment_attempts x WHERE x.session_id=ps.id)>1"
default:
return nil, "", errors.New("analytics: invalid detail view")
}
query := `SELECT ps.id,ps.source_key,t.source_key,ps.status,ps.amount_rial,ps.fee_amount_rial,ps.created_at,ps.expires_at,ps.verified_at,ps.settled_at,
count(pa.id)::int,coalesce(bool_or(pa.status='InBank'),false) FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
WHERE ps.merchant_id=$1 AND ps.created_at>=$2 AND ps.created_at<$3 AND ` + statusPredicate + `
AND ($4='' OR t.source_key=$4) GROUP BY ps.id,t.source_key ORDER BY ps.created_at DESC,ps.id DESC LIMIT $5 OFFSET $6`
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.TerminalID, filter.Limit+1, offset)
AND ($4='' OR t.source_key=$4)
AND ($5='' OR
($5='NO_ATTEMPT' AND NOT EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id)) OR
($5='REACHED_BANK' AND EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id AND fa.status='InBank')) OR
($5='OTHER_OBSERVED' AND EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id) AND NOT EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id AND fa.status='InBank')))
AND ($6='' OR ps.status=$6) AND ($7::bigint IS NULL OR ps.amount_rial >= $7) AND ($8::bigint IS NULL OR ps.amount_rial <= $8)
GROUP BY ps.id,t.source_key ORDER BY ps.created_at DESC,ps.id DESC LIMIT $9 OFFSET $10`
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.TerminalID, filter.FailureStage, filter.Status, filter.MinAmountRial, filter.MaxAmountRial, filter.Limit+1, offset)
if err != nil {
return nil, "", fmt.Errorf("querying session details: %w", err)
}
defer rows.Close()
details := make([]Detail, 0, filter.Limit)
ids := make([]int64, 0, filter.Limit)
hasMore := false
for rows.Next() {
var id int64
var reachedBank bool
@@ -89,44 +126,99 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
}
d.SessionID = "ses_" + d.SessionID
d.Attempts = []Attempt{}
if d.SessionStatus == "Failed" {
stage := "OTHER_OBSERVED"
if d.AttemptCount == 0 {
stage = "NO_ATTEMPT"
} else if reachedBank {
stage = "REACHED_BANK"
}
d.FailureStage = &stage
if filter.FailureStage != "" && filter.FailureStage != stage {
continue
}
}
if len(details) < filter.Limit {
details = append(details, d)
ids = append(ids, id)
setFailureStage(&d, reachedBank)
if len(details) == filter.Limit {
hasMore = true
break
}
details = append(details, d)
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, "", fmt.Errorf("iterating session details: %w", err)
}
for i, id := range ids {
attemptRows, err := s.pool.Query(ctx, `SELECT sequence,status,created_at,init_time_ms,verify_time_ms FROM payment_attempts WHERE session_id=$1 ORDER BY sequence`, id)
attempts, err := s.attempts(ctx, id)
if err != nil {
return nil, "", fmt.Errorf("querying attempts: %w", err)
}
attempts, err := pgx.CollectRows(attemptRows, func(row pgx.CollectableRow) (Attempt, error) {
var a Attempt
err := row.Scan(&a.Sequence, &a.Status, &a.CreatedAt, &a.InitTimeMS, &a.VerifyTimeMS)
return a, err
})
if err != nil {
return nil, "", fmt.Errorf("collecting attempts: %w", err)
return nil, "", err
}
details[i].Attempts = attempts
}
next := ""
if len(details) == filter.Limit {
if hasMore {
next = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + filter.Limit)))
}
return details, next, nil
}
func (s *Service) Session(ctx context.Context, merchantID int64, sessionID string) (Detail, bool, error) {
sourceKey, valid := sessionSourceKey(sessionID)
if !valid {
return Detail{}, false, nil
}
query := `SELECT ps.id,ps.source_key,t.source_key,ps.status,ps.amount_rial,ps.fee_amount_rial,ps.created_at,ps.expires_at,ps.verified_at,ps.settled_at,
count(pa.id)::int,coalesce(bool_or(pa.status='InBank'),false) FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
WHERE ps.merchant_id=$1 AND ps.source_key=$2 GROUP BY ps.id,t.source_key`
var id int64
var reachedBank bool
var detail Detail
err := s.pool.QueryRow(ctx, query, merchantID, sourceKey).Scan(&id, &detail.SessionID, &detail.TerminalID, &detail.SessionStatus, &detail.AmountRial, &detail.FeeAmountRial, &detail.CreatedAt, &detail.ExpiresAt, &detail.VerifiedAt, &detail.SettledAt, &detail.AttemptCount, &reachedBank)
if errors.Is(err, pgx.ErrNoRows) {
return Detail{}, false, nil
}
if err != nil {
return Detail{}, false, fmt.Errorf("querying session: %w", err)
}
detail.SessionID = "ses_" + detail.SessionID
detail.Attempts, err = s.attempts(ctx, id)
if err != nil {
return Detail{}, false, err
}
setFailureStage(&detail, reachedBank)
return detail, true, nil
}
func (s *Service) attempts(ctx context.Context, sessionID int64) ([]Attempt, error) {
rows, err := s.pool.Query(ctx, `SELECT sequence,status,created_at,init_time_ms,verify_time_ms FROM payment_attempts WHERE session_id=$1 ORDER BY sequence`, sessionID)
if err != nil {
return nil, fmt.Errorf("querying attempts: %w", err)
}
attempts, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (Attempt, error) {
var attempt Attempt
err := row.Scan(&attempt.Sequence, &attempt.Status, &attempt.CreatedAt, &attempt.InitTimeMS, &attempt.VerifyTimeMS)
return attempt, err
})
if err != nil {
return nil, fmt.Errorf("collecting attempts: %w", err)
}
return attempts, nil
}
func setFailureStage(detail *Detail, reachedBank bool) {
if detail.SessionStatus != "Failed" {
return
}
stage := "OTHER_OBSERVED"
if detail.AttemptCount == 0 {
stage = "NO_ATTEMPT"
} else if reachedBank {
stage = "REACHED_BANK"
}
detail.FailureStage = &stage
}
func sessionSourceKey(sessionID string) (string, bool) {
if !strings.HasPrefix(sessionID, "ses_") || len(sessionID) <= len("ses_") {
return "", false
}
return strings.TrimPrefix(sessionID, "ses_"), true
}
func terminalDisplayName(terminalID string) string {
suffix := terminalID
if len(suffix) > 4 {
suffix = suffix[len(suffix)-4:]
}
digits := strings.NewReplacer("0", "۰", "1", "۱", "2", "۲", "3", "۳", "4", "۴", "5", "۵", "6", "۶", "7", "۷", "8", "۸", "9", "۹")
return "پایانه " + digits.Replace(suffix)
}