feat: add terminal-scoped transaction analytics
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user