feat: deliver authenticated merchant analytics API
This commit is contained in:
132
internal/analytics/details.go
Normal file
132
internal/analytics/details.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type DetailFilter struct {
|
||||
View string
|
||||
FailureStage string
|
||||
TerminalID string
|
||||
Cursor string
|
||||
Limit int
|
||||
}
|
||||
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 filter.Limit > 100 {
|
||||
return nil, "", errors.New("analytics: detail limit exceeds 100")
|
||||
}
|
||||
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 := "ps.status='Paid'"
|
||||
switch filter.View {
|
||||
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)
|
||||
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)
|
||||
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{}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
details[i].Attempts = attempts
|
||||
}
|
||||
next := ""
|
||||
if len(details) == filter.Limit {
|
||||
next = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + filter.Limit)))
|
||||
}
|
||||
return details, next, nil
|
||||
}
|
||||
Reference in New Issue
Block a user