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)
}

View File

@@ -0,0 +1,61 @@
package analytics
import "testing"
func TestDetailFilterValidate(t *testing.T) {
t.Parallel()
zero := int64(0)
hundred := int64(100)
twoHundred := int64(200)
tests := []struct {
name string
filter DetailFilter
wantErr bool
}{
{name: "all transactions", filter: DetailFilter{View: "ALL", Limit: 25}},
{name: "allowlisted status", filter: DetailFilter{View: "ALL", Status: "Verified", Limit: 25}},
{name: "amount range", filter: DetailFilter{View: "ALL", MinAmountRial: &zero, MaxAmountRial: &hundred, Limit: 25}},
{name: "failed observed stage", filter: DetailFilter{View: "FAILED", FailureStage: "REACHED_BANK", Limit: 25}},
{name: "unknown view", filter: DetailFilter{View: "EVERYTHING", Limit: 25}, wantErr: true},
{name: "unknown status", filter: DetailFilter{View: "ALL", Status: "Successful", Limit: 25}, wantErr: true},
{name: "stage outside failed view", filter: DetailFilter{View: "ALL", FailureStage: "NO_ATTEMPT", Limit: 25}, wantErr: true},
{name: "descending amount range", filter: DetailFilter{View: "ALL", MinAmountRial: &twoHundred, MaxAmountRial: &hundred, Limit: 25}, wantErr: true},
{name: "page too large", filter: DetailFilter{View: "ALL", Limit: 101}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.filter.Validate()
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestSessionSourceKey(t *testing.T) {
t.Parallel()
tests := []struct {
name, input, want string
valid bool
}{
{name: "valid opaque id", input: "ses_abc-123", want: "abc-123", valid: true},
{name: "missing prefix", input: "abc-123"},
{name: "empty source", input: "ses_"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, valid := sessionSourceKey(tt.input)
if got != tt.want || valid != tt.valid {
t.Fatalf("sessionSourceKey(%q) = %q,%v, want %q,%v", tt.input, got, valid, tt.want, tt.valid)
}
})
}
}
func TestTerminalDisplayName(t *testing.T) {
t.Parallel()
if got := terminalDisplayName("terminal-12345678"); got != "پایانه ۵۶۷۸" {
t.Fatalf("terminalDisplayName() = %q", got)
}
}

View File

@@ -33,9 +33,21 @@ type Run struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
EventsURL string `json:"eventsUrl"`
Scope RunScope `json:"scope"`
Sections map[string]any `json:"sections"`
}
type RunScope struct {
TerminalID *string `json:"terminalId"`
}
type PaymentHistoryPoint struct {
LocalDate string `json:"localDate"`
TotalSessions int64 `json:"totalSessions"`
BankPaidSessions int64 `json:"bankPaidSessions"`
BankPaidAmountRial int64 `json:"bankPaidAmountRial"`
}
type Service struct {
pool *pgxpool.Pool
mu sync.RWMutex
@@ -62,8 +74,11 @@ func (s *Service) Periods(ctx context.Context) ([]Period, error) {
return latestCompletePeriods(start, end, location), nil
}
func (s *Service) Start(ctx context.Context, merchantID int64, period Period) (Run, error) {
key := fmt.Sprintf("%d:%s", merchantID, period.PeriodID)
func (s *Service) Start(ctx context.Context, merchantID int64, period Period, terminalID string) (Run, error) {
if err := s.validateTerminal(ctx, merchantID, terminalID); err != nil {
return Run{}, err
}
key := fmt.Sprintf("%d:%s:%s", merchantID, period.PeriodID, terminalID)
s.mu.RLock()
existing, ok := s.runs[key]
s.mu.RUnlock()
@@ -74,13 +89,20 @@ func (s *Service) Start(ctx context.Context, merchantID int64, period Period) (R
run := Run{RunID: fmt.Sprintf("run_%d%d", merchantID, now.UnixNano()), State: "COMPLETE", Period: period,
DatasetSnapshotID: "ds_localimport", MetricDefinitionVersion: "0.4.0", CreatedAt: now, UpdatedAt: now,
Sections: map[string]any{}}
if terminalID != "" {
run.Scope.TerminalID = &terminalID
}
run.EventsURL = "/api/v1/analytics/runs/" + run.RunID + "/events"
metrics, err := s.metrics(ctx, merchantID, period)
metrics, err := s.metrics(ctx, merchantID, period, terminalID)
if err != nil {
return Run{}, err
}
history, err := s.paymentHistory(ctx, merchantID, period, terminalID)
if err != nil {
return Run{}, err
}
metadata := map[string]any{"calculatedAt": now, "datasetSnapshotId": run.DatasetSnapshotID, "metricDefinitionVersion": "0.4.0"}
run.Sections["overview"] = map[string]any{"sectionId": "overview", "state": "READY", "metadata": metadata, "data": metrics.overview(period)}
run.Sections["overview"] = map[string]any{"sectionId": "overview", "state": "READY", "metadata": metadata, "data": metrics.overview(period, history)}
run.Sections["paymentPerformance"] = map[string]any{"sectionId": "payment_performance", "state": "READY", "metadata": metadata, "data": metrics.performance()}
run.Sections["fees"] = map[string]any{"sectionId": "fees", "state": "READY", "metadata": metadata, "data": metrics.fees(period)}
peerCount, peerSessions, err := s.peerEligibility(ctx, merchantID, period)
@@ -120,12 +142,13 @@ type metricValues struct {
initSamples, verifySamples int64
}
func (s *Service) metrics(ctx context.Context, merchantID int64, p Period) (metricValues, error) {
func (s *Service) metrics(ctx context.Context, merchantID int64, p Period, terminalID string) (metricValues, error) {
var m metricValues
query := `WITH scoped AS (
SELECT ps.*,count(pa.id) attempts,bool_or(pa.status='InBank') reached_bank
FROM payment_sessions ps 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 GROUP BY ps.id
WHERE ps.merchant_id=$1 AND ps.created_at >= $2 AND ps.created_at < $3
AND ($4='' OR ps.terminal_id=(SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$4)) GROUP BY ps.id
), timings AS (
SELECT percentile_disc(0.5) within group(order by pa.init_time_ms)::float8 init_p50,
percentile_disc(0.95) within group(order by pa.init_time_ms)::float8 init_p95,
@@ -133,7 +156,8 @@ func (s *Service) metrics(ctx context.Context, merchantID int64, p Period) (metr
percentile_disc(0.95) within group(order by pa.verify_time_ms)::float8 verify_p95,
count(pa.init_time_ms) init_samples,count(pa.verify_time_ms) verify_samples
FROM payment_attempts pa JOIN payment_sessions ps ON ps.id=pa.session_id
WHERE ps.merchant_id=$1 AND ps.created_at >= $2 AND ps.created_at < $3)
WHERE ps.merchant_id=$1 AND ps.created_at >= $2 AND ps.created_at < $3
AND ($4='' OR ps.terminal_id=(SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$4)))
SELECT count(*),count(*) filter(where status in ('Paid','Verified')),
count(*) filter(where status='Verified'),count(*) filter(where status='Paid'),count(*) filter(where status='Failed'),
count(*) filter(where status='Reversed'),count(*) filter(where attempts=0),count(*) filter(where reached_bank),
@@ -141,7 +165,7 @@ func (s *Service) metrics(ctx context.Context, merchantID int64, p Period) (metr
coalesce(sum(amount_rial) filter(where status in ('Paid','Verified')),0),coalesce(sum(fee_amount_rial) filter(where status in ('Paid','Verified')),0),
t.init_p50,t.init_p95,t.verify_p50,t.verify_p95,t.init_samples,t.verify_samples
FROM scoped CROSS JOIN timings t GROUP BY t.init_p50,t.init_p95,t.verify_p50,t.verify_p95,t.init_samples,t.verify_samples`
err := s.pool.QueryRow(ctx, query, merchantID, p.StartAt, p.EndAt).Scan(&m.total, &m.paid, &m.verified, &m.unverified,
err := s.pool.QueryRow(ctx, query, merchantID, p.StartAt, p.EndAt, terminalID).Scan(&m.total, &m.paid, &m.verified, &m.unverified,
&m.failed, &m.reversed, &m.noAttempt, &m.reachedBank, &m.retried, &m.recovered, &m.paidAmount,
&m.feeAmount, &m.initP50, &m.initP95, &m.verifyP50, &m.verifyP95, &m.initSamples, &m.verifySamples)
if err != nil {
@@ -164,13 +188,41 @@ func unavailableComparison(period Period) map[string]any {
return map[string]any{"state": "UNAVAILABLE", "baselinePeriod": period, "absoluteChange": nil, "relativeChangePercent": nil, "unavailableReason": "SOURCE_UNAVAILABLE", "isActionableInsightEligible": false}
}
func (m metricValues) overview(period Period) map[string]any {
func (m metricValues) overview(period Period, history []PaymentHistoryPoint) map[string]any {
return map[string]any{
"bankPaidSessions": integerMetric("bank_paid_sessions", m.paid, "SESSIONS"), "bankPaidAmountRial": integerMetric("bank_paid_amount_rial", m.paidAmount, "RIAL"),
"paymentSuccessRate": decimalMetric("payment_success_rate", m.paid, m.total, "PERCENT"), "verificationCompletionRate": decimalMetric("verification_completion_rate", m.verified, m.paid, "PERCENT"),
"comparisons": map[string]any{"bank_paid_sessions": unavailableComparison(period), "bank_paid_amount_rial": unavailableComparison(period), "payment_success_rate": unavailableComparison(period), "verification_completion_rate": unavailableComparison(period)},
"comparisons": map[string]any{"bank_paid_sessions": unavailableComparison(period), "bank_paid_amount_rial": unavailableComparison(period), "payment_success_rate": unavailableComparison(period), "verification_completion_rate": unavailableComparison(period)},
"paymentHistory": history,
}
}
func (s *Service) paymentHistory(ctx context.Context, merchantID int64, period Period, terminalID string) ([]PaymentHistoryPoint, error) {
rows, err := s.pool.Query(ctx, `SELECT (ps.created_at AT TIME ZONE 'Asia/Tehran')::date local_date,
count(*),count(*) FILTER (WHERE ps.status IN ('Paid','Verified')),
coalesce(sum(ps.amount_rial) FILTER (WHERE ps.status IN ('Paid','Verified')),0)
FROM payment_sessions ps WHERE ps.merchant_id=$1 AND ps.created_at >= $2 AND ps.created_at < $3
AND ($4='' OR ps.terminal_id=(SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$4))
GROUP BY local_date ORDER BY local_date`, merchantID, period.StartAt, period.EndAt, terminalID)
if err != nil {
return nil, fmt.Errorf("querying payment history: %w", err)
}
defer rows.Close()
points := make([]PaymentHistoryPoint, 0, 93)
for rows.Next() {
var date time.Time
var point PaymentHistoryPoint
if err := rows.Scan(&date, &point.TotalSessions, &point.BankPaidSessions, &point.BankPaidAmountRial); err != nil {
return nil, fmt.Errorf("scanning payment history: %w", err)
}
point.LocalDate = date.Format(time.DateOnly)
points = append(points, point)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating payment history: %w", err)
}
return points, nil
}
func timing(id string, p50, p95 *float64, samples, eligible int64) map[string]any {
var a, b any
if p50 != nil {

View File

@@ -0,0 +1,54 @@
package analytics
import (
"context"
"errors"
"fmt"
)
var ErrUnknownTerminal = errors.New("analytics: unknown terminal")
type Terminal struct {
TerminalID string `json:"terminalId"`
DisplayName string `json:"displayName"`
SessionCount int64 `json:"sessionCount"`
IsDefault bool `json:"isDefault"`
}
func (s *Service) Terminals(ctx context.Context, merchantID int64) ([]Terminal, error) {
rows, err := s.pool.Query(ctx, `SELECT t.source_key,count(ps.id) FROM terminals t
LEFT JOIN payment_sessions ps ON ps.terminal_id=t.id
WHERE t.merchant_id=$1 GROUP BY t.id ORDER BY count(ps.id) DESC,t.source_key`, merchantID)
if err != nil {
return nil, fmt.Errorf("querying merchant terminals: %w", err)
}
defer rows.Close()
terminals := make([]Terminal, 0, 8)
for rows.Next() {
var terminal Terminal
if err := rows.Scan(&terminal.TerminalID, &terminal.SessionCount); err != nil {
return nil, fmt.Errorf("scanning merchant terminal: %w", err)
}
terminal.DisplayName = terminalDisplayName(terminal.TerminalID)
terminal.IsDefault = len(terminals) == 0
terminals = append(terminals, terminal)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating merchant terminals: %w", err)
}
return terminals, nil
}
func (s *Service) validateTerminal(ctx context.Context, merchantID int64, terminalID string) error {
if terminalID == "" {
return nil
}
var exists bool
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM terminals WHERE merchant_id=$1 AND source_key=$2)`, merchantID, terminalID).Scan(&exists); err != nil {
return fmt.Errorf("validating merchant terminal: %w", err)
}
if !exists {
return ErrUnknownTerminal
}
return nil
}

View File

@@ -40,11 +40,13 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/auth/session", s.authenticate(http.HandlerFunc(s.currentSession)))
mux.Handle("DELETE /api/v1/auth/session", s.authenticate(s.csrf(http.HandlerFunc(s.logout))))
mux.Handle("GET /api/v1/analytics/periods", s.authenticate(http.HandlerFunc(s.periods)))
mux.Handle("GET /api/v1/analytics/terminals", s.authenticate(http.HandlerFunc(s.terminals)))
mux.Handle("POST /api/v1/analytics/runs", s.authenticate(s.csrf(http.HandlerFunc(s.startRun))))
mux.Handle("GET /api/v1/analytics/runs/{runId}", s.authenticate(http.HandlerFunc(s.getRun)))
mux.Handle("GET /api/v1/analytics/runs/{runId}/events", s.authenticate(http.HandlerFunc(s.events)))
mux.Handle("GET /api/v1/analytics/session-details", s.authenticate(http.HandlerFunc(s.details)))
mux.Handle("GET /api/v1/analytics/session-details.csv", s.authenticate(http.HandlerFunc(s.exportCSV)))
mux.Handle("GET /api/v1/analytics/sessions/{sessionId}", s.authenticate(http.HandlerFunc(s.sessionDetail)))
return s.boundary(mux)
}
@@ -148,12 +150,34 @@ func (s *Server) periods(w http.ResponseWriter, r *http.Request) {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
s.writeJSON(w, http.StatusOK, map[string]any{"defaultPeriodId": periods[0].PeriodID, "periods": periods})
if len(periods) == 0 {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
defaultPeriodID := periods[0].PeriodID
for _, period := range periods {
if period.IsDefault {
defaultPeriodID = period.PeriodID
break
}
}
s.writeJSON(w, http.StatusOK, map[string]any{"defaultPeriodId": defaultPeriodID, "periods": periods})
}
func (s *Server) terminals(w http.ResponseWriter, r *http.Request) {
terminals, err := s.analytics.Terminals(r.Context(), sessionFrom(r.Context()).MerchantID)
if err != nil {
s.logger.Error("terminal discovery failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
s.writeJSON(w, http.StatusOK, map[string]any{"terminals": terminals})
}
func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
var input struct {
PeriodID string `json:"periodId"`
PeriodID string `json:"periodId"`
TerminalID string `json:"terminalId"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
@@ -180,7 +204,11 @@ func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
return
}
session := sessionFrom(r.Context())
run, err := s.analytics.Start(r.Context(), session.MerchantID, period)
run, err := s.analytics.Start(r.Context(), session.MerchantID, period, input.TerminalID)
if errors.Is(err, analytics.ErrUnknownTerminal) {
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown terminal")
return
}
if err != nil {
s.logger.Error("analytics calculation failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
@@ -229,7 +257,22 @@ func (s *Server) details(w http.ResponseWriter, r *http.Request) {
}
limit = parsed
}
filter := analytics.DetailFilter{View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"), TerminalID: r.URL.Query().Get("terminalId"), Cursor: r.URL.Query().Get("cursor"), Limit: limit}
minAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("minAmountRial"))
if !valid {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid minimum amount")
return
}
maxAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("maxAmountRial"))
if !valid {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid maximum amount")
return
}
filter := analytics.DetailFilter{
View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"),
TerminalID: r.URL.Query().Get("terminalId"), Status: r.URL.Query().Get("status"),
MinAmountRial: minAmount, MaxAmountRial: maxAmount,
Cursor: r.URL.Query().Get("cursor"), Limit: limit,
}
items, next, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
if err != nil {
s.logger.Error("detail query failed", "error", err)
@@ -240,7 +283,24 @@ func (s *Server) details(w http.ResponseWriter, r *http.Request) {
if next != "" {
nextValue = next
}
s.writeJSON(w, 200, map[string]any{"period": period, "view": filter.View, "appliedFilters": map[string]any{"terminalId": nullable(filter.TerminalID), "failureStage": nullable(filter.FailureStage)}, "items": items, "pageInfo": map[string]any{"limit": limit, "hasNextPage": next != "", "nextCursor": nextValue}})
s.writeJSON(w, 200, map[string]any{"period": period, "view": filter.View, "appliedFilters": map[string]any{
"terminalId": nullable(filter.TerminalID), "failureStage": nullable(filter.FailureStage), "status": nullable(filter.Status),
"minAmountRial": filter.MinAmountRial, "maxAmountRial": filter.MaxAmountRial,
}, "items": items, "pageInfo": map[string]any{"limit": limit, "hasNextPage": next != "", "nextCursor": nextValue}})
}
func (s *Server) sessionDetail(w http.ResponseWriter, r *http.Request) {
detail, found, err := s.analytics.Session(r.Context(), sessionFrom(r.Context()).MerchantID, r.PathValue("sessionId"))
if err != nil {
s.logger.Error("session detail query failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
if !found {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
s.writeJSON(w, 200, detail)
}
func (s *Server) exportCSV(w http.ResponseWriter, r *http.Request) {
@@ -294,6 +354,16 @@ func nullable(value string) any {
}
return value
}
func optionalNonNegativeInt64(value string) (*int64, bool) {
if value == "" {
return nil, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return nil, false
}
return &parsed, true
}
func formatTime(value *time.Time) string {
if value == nil {
return ""

View File

@@ -28,3 +28,35 @@ func TestPersianStatus(t *testing.T) {
t.Fatalf("persianStatus() = %q", got)
}
}
func TestOptionalNonNegativeInt64(t *testing.T) {
t.Parallel()
tests := []struct {
name, input string
want *int64
valid bool
}{
{name: "omitted", valid: true},
{name: "zero", input: "0", want: int64Pointer(0), valid: true},
{name: "positive", input: "125000", want: int64Pointer(125000), valid: true},
{name: "negative", input: "-1"},
{name: "not a number", input: "یک"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, valid := optionalNonNegativeInt64(tt.input)
if valid != tt.valid || !equalInt64Pointers(got, tt.want) {
t.Fatalf("optionalNonNegativeInt64(%q) = %v,%v, want %v,%v", tt.input, got, valid, tt.want, tt.valid)
}
})
}
}
func int64Pointer(value int64) *int64 { return &value }
func equalInt64Pointers(left, right *int64) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}

View File

@@ -22,7 +22,7 @@ func SeedDemo(ctx context.Context, pool *pgxpool.Pool, username, password string
var merchantID int64
err = pool.QueryRow(ctx, `SELECT m.id,m.source_key,m.display_name
FROM merchants m JOIN payment_sessions ps ON ps.merchant_id=m.id
GROUP BY m.id ORDER BY count(*) DESC,m.source_key LIMIT 1`).Scan(
GROUP BY m.id ORDER BY (count(DISTINCT ps.terminal_id)>1) DESC,count(*) DESC,m.source_key LIMIT 1`).Scan(
&merchantID, &result.MerchantSourceKey, &result.MerchantName,
)
if err != nil {