Files
zarinpal-analytics-backend/internal/analytics/details.go

225 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package analytics
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type DetailFilter struct {
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"`
CreatedAt *time.Time `json:"createdAt"`
InitTimeMS *int `json:"initTimeMs"`
VerifyTimeMS *int `json:"verifyTimeMs"`
}
type Detail struct {
SessionID string `json:"sessionId"`
TerminalID string `json:"terminalId"`
SessionStatus string `json:"sessionStatus"`
AmountRial int64 `json:"amountRial"`
FeeAmountRial int64 `json:"feeAmountRial"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt"`
VerifiedAt *time.Time `json:"verifiedAt"`
SettledAt *time.Time `json:"settledAt"`
AttemptCount int `json:"attemptCount"`
FailureStage *string `json:"failureStage"`
Attempts []Attempt `json:"attempts"`
}
func (s *Service) Details(ctx context.Context, merchantID int64, period Period, filter DetailFilter) ([]Detail, string, error) {
if filter.Limit <= 0 {
filter.Limit = 25
}
if err := filter.Validate(); err != nil {
return nil, "", err
}
offset := 0
if filter.Cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(filter.Cursor)
if err != nil {
return nil, "", errors.New("analytics: invalid cursor")
}
offset, err = strconv.Atoi(string(decoded))
if err != nil || offset < 0 {
return nil, "", errors.New("analytics: invalid cursor")
}
}
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"
}
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)
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
var d Detail
if err := rows.Scan(&id, &d.SessionID, &d.TerminalID, &d.SessionStatus, &d.AmountRial, &d.FeeAmountRial, &d.CreatedAt, &d.ExpiresAt, &d.VerifiedAt, &d.SettledAt, &d.AttemptCount, &reachedBank); err != nil {
return nil, "", fmt.Errorf("scanning session detail: %w", err)
}
d.SessionID = "ses_" + d.SessionID
d.Attempts = []Attempt{}
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 {
attempts, err := s.attempts(ctx, id)
if err != nil {
return nil, "", err
}
details[i].Attempts = attempts
}
next := ""
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)
}