fix: harden terminal-scoped analytics contracts

Keep JSON and bounded CSV exports on one canonical predicate, replace replayable offsets with encrypted tenant/filter-bound keyset cursors, and suppress merchant-grain peers under terminal scope. Validate period filters and align OpenAPI and capability specs with runtime behavior.
This commit is contained in:
2026-08-20 21:29:18 +03:30
parent 857f29635e
commit d6de48fd15
14 changed files with 577 additions and 103 deletions

View File

@@ -2,16 +2,33 @@ package analytics
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const MaxExportRows = 100_000
var ErrExportTooLarge = errors.New("analytics: export exceeds row limit")
const detailFilterPredicate = `
ps.merchant_id=$1 AND ps.created_at>=$2 AND ps.created_at<$3
AND ($4='ALL'
OR ($4='UNVERIFIED_PAID' AND ps.status='Paid')
OR ($4='FAILED' AND ps.status='Failed')
OR ($4='RETRIED' AND (SELECT count(*) FROM payment_attempts retry_attempts WHERE retry_attempts.session_id=ps.id)>1))
AND ($5='' OR t.source_key=$5)
AND ($6='' OR
($6='NO_ATTEMPT' AND NOT EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id)) OR
($6='REACHED_BANK' AND EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id AND failure_attempts.status='InBank')) OR
($6='OTHER_OBSERVED' AND EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id) AND NOT EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id AND failure_attempts.status='InBank')))
AND ($7='' OR ps.status=$7)
AND ($8::bigint IS NULL OR ps.amount_rial >= $8)
AND ($9::bigint IS NULL OR ps.amount_rial <= $9)`
type DetailFilter struct {
View string
FailureStage string
@@ -70,6 +87,12 @@ type Detail struct {
Attempts []Attempt `json:"attempts"`
}
type ExportDetail struct {
Detail
LastAttemptStatus *string
LastAttemptAt *time.Time
}
func (s *Service) Details(ctx context.Context, merchantID int64, period Period, filter DetailFilter) ([]Detail, string, error) {
if filter.Limit <= 0 {
filter.Limit = 25
@@ -77,39 +100,22 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
if err := filter.Validate(); err != nil {
return nil, "", err
}
offset := 0
var cursorCreatedAt *time.Time
var cursorSessionID int64
if filter.Cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(filter.Cursor)
position, err := s.decodeDetailCursor(merchantID, period, filter, filter.Cursor)
if err != nil {
return nil, "", errors.New("analytics: invalid cursor")
return nil, "", err
}
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"
cursorCreatedAt = &position.CreatedAt
cursorSessionID = position.SessionID
}
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)
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 ` + detailFilterPredicate + `
AND ($10::timestamptz IS NULL OR ps.created_at < $10 OR (ps.created_at=$10 AND ps.id<$11))
GROUP BY ps.id,t.source_key ORDER BY ps.created_at DESC,ps.id DESC LIMIT $12`
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.View, filter.TerminalID, filter.FailureStage, filter.Status, filter.MinAmountRial, filter.MaxAmountRial, cursorCreatedAt, cursorSessionID, filter.Limit+1)
if err != nil {
return nil, "", fmt.Errorf("querying session details: %w", err)
}
@@ -146,11 +152,53 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
}
next := ""
if hasMore {
next = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + filter.Limit)))
next, err = s.encodeDetailCursor(merchantID, period, filter, details[len(details)-1].CreatedAt, ids[len(ids)-1])
if err != nil {
return nil, "", fmt.Errorf("encoding detail cursor: %w", err)
}
}
return details, next, nil
}
func (s *Service) ExportDetails(ctx context.Context, merchantID int64, period Period, filter DetailFilter) ([]ExportDetail, error) {
filter.Cursor = ""
filter.Limit = 100
if err := filter.Validate(); err != nil {
return nil, err
}
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,
(SELECT count(*)::int FROM payment_attempts attempts WHERE attempts.session_id=ps.id),
EXISTS(SELECT 1 FROM payment_attempts attempts WHERE attempts.session_id=ps.id AND attempts.status='InBank'),
last_attempt.status,last_attempt.created_at
FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id
LEFT JOIN LATERAL (SELECT status,created_at FROM payment_attempts WHERE session_id=ps.id ORDER BY sequence DESC LIMIT 1) last_attempt ON true
WHERE ` + detailFilterPredicate + ` ORDER BY ps.created_at DESC,ps.id DESC LIMIT $10`
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.View, filter.TerminalID, filter.FailureStage, filter.Status, filter.MinAmountRial, filter.MaxAmountRial, MaxExportRows+1)
if err != nil {
return nil, fmt.Errorf("querying session export: %w", err)
}
defer rows.Close()
items := make([]ExportDetail, 0, 1024)
for rows.Next() {
var id int64
var reachedBank bool
var item ExportDetail
if err := rows.Scan(&id, &item.SessionID, &item.TerminalID, &item.SessionStatus, &item.AmountRial, &item.FeeAmountRial, &item.CreatedAt, &item.ExpiresAt, &item.VerifiedAt, &item.SettledAt, &item.AttemptCount, &reachedBank, &item.LastAttemptStatus, &item.LastAttemptAt); err != nil {
return nil, fmt.Errorf("scanning session export: %w", err)
}
item.SessionID = "ses_" + item.SessionID
setFailureStage(&item.Detail, reachedBank)
items = append(items, item)
if len(items) > MaxExportRows {
return nil, ErrExportTooLarge
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating session export: %w", err)
}
return items, nil
}
func (s *Service) Session(ctx context.Context, merchantID int64, sessionID string) (Detail, bool, error) {
sourceKey, valid := sessionSourceKey(sessionID)
if !valid {