diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 31fa096..b24dd76 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -172,6 +172,26 @@ paths: '503': $ref: '#/components/responses/DependencyUnavailable' + /analytics/terminals: + get: + tags: [Reporting periods] + operationId: listMerchantTerminals + summary: List terminals owned by the authenticated merchant + description: Returns display-safe terminal choices scoped exclusively from the authenticated server session. + parameters: + - $ref: '#/components/parameters/RequestIdHeader' + responses: + '200': + description: Merchant-owned terminal choices ordered by session volume. + content: + application/json: + schema: + $ref: '#/components/schemas/TerminalList' + '401': + $ref: '#/components/responses/Unauthenticated' + '503': + $ref: '#/components/responses/AnalyticsUnavailable' + /analytics/runs: post: tags: [Analytics runs] @@ -415,6 +435,25 @@ paths: - $ref: '#/components/parameters/DetailViewQuery' - $ref: '#/components/parameters/FailureStageQuery' - $ref: '#/components/parameters/TerminalIdQuery' + - name: status + in: query + required: false + schema: + $ref: '#/components/schemas/SessionStatus' + - name: minAmountRial + in: query + required: false + schema: + type: integer + format: int64 + minimum: 0 + - name: maxAmountRial + in: query + required: false + schema: + type: integer + format: int64 + minimum: 0 - $ref: '#/components/parameters/CursorQuery' - $ref: '#/components/parameters/LimitQuery' - $ref: '#/components/parameters/RequestIdHeader' @@ -445,6 +484,34 @@ paths: '503': $ref: '#/components/responses/AnalyticsUnavailable' + /analytics/sessions/{sessionId}: + get: + tags: [Analytics details] + operationId: getAnalyticsSession + summary: Get one merchant-owned payment session and attempt history + parameters: + - name: sessionId + in: path + required: true + schema: + type: string + minLength: 8 + maxLength: 128 + - $ref: '#/components/parameters/RequestIdHeader' + responses: + '200': + description: Authorized merchant-owned session. + content: + application/json: + schema: + $ref: '#/components/schemas/SessionDetail' + '401': + $ref: '#/components/responses/Unauthenticated' + '404': + $ref: '#/components/responses/ResourceNotFound' + '503': + $ref: '#/components/responses/AnalyticsUnavailable' + /analytics/session-details.csv: get: tags: [Analytics details] @@ -1025,6 +1092,52 @@ components: properties: periodId: $ref: '#/components/schemas/PeriodId' + terminalId: + type: string + minLength: 1 + maxLength: 128 + pattern: '^[A-Za-z0-9._:-]+$' + + Terminal: + type: object + additionalProperties: false + required: [terminalId, displayName, sessionCount, isDefault] + properties: + terminalId: + type: string + minLength: 1 + maxLength: 128 + displayName: + type: string + minLength: 1 + maxLength: 80 + sessionCount: + type: integer + format: int64 + minimum: 0 + isDefault: + type: boolean + + TerminalList: + type: object + additionalProperties: false + required: [terminals] + properties: + terminals: + type: array + maxItems: 1000 + items: + $ref: '#/components/schemas/Terminal' + + AnalyticsScope: + type: object + additionalProperties: false + required: [terminalId] + properties: + terminalId: + type: [string, 'null'] + minLength: 1 + maxLength: 128 RunId: type: string @@ -1057,6 +1170,7 @@ components: - createdAt - updatedAt - eventsUrl + - scope - sections properties: runId: @@ -1081,6 +1195,8 @@ components: eventsUrl: type: string format: uri-reference + scope: + $ref: '#/components/schemas/AnalyticsScope' sections: type: object additionalProperties: false @@ -1246,6 +1362,7 @@ components: - paymentSuccessRate - verificationCompletionRate - comparisons + - paymentHistory properties: bankPaidSessions: allOf: @@ -1265,6 +1382,32 @@ components: $ref: '#/components/schemas/PreviousPeriodComparison' propertyNames: enum: [bank_paid_sessions, bank_paid_amount_rial, payment_success_rate, verification_completion_rate] + paymentHistory: + type: array + maxItems: 93 + items: + $ref: '#/components/schemas/PaymentHistoryPoint' + + PaymentHistoryPoint: + type: object + additionalProperties: false + required: [localDate, totalSessions, bankPaidSessions, bankPaidAmountRial] + properties: + localDate: + type: string + format: date + totalSessions: + type: integer + format: int64 + minimum: 0 + bankPaidSessions: + type: integer + format: int64 + minimum: 0 + bankPaidAmountRial: + type: integer + format: int64 + minimum: 0 PaymentPerformanceData: type: object @@ -1449,7 +1592,7 @@ components: DetailView: type: string - enum: [UNVERIFIED_PAID, FAILED, RETRIED] + enum: [ALL, UNVERIFIED_PAID, FAILED, RETRIED] FailureStage: type: string enum: [NO_ATTEMPT, REACHED_BANK, OTHER_OBSERVED] @@ -1573,6 +1716,18 @@ components: oneOf: - $ref: '#/components/schemas/FailureStage' - type: 'null' + status: + oneOf: + - $ref: '#/components/schemas/SessionStatus' + - type: 'null' + minAmountRial: + type: [integer, 'null'] + format: int64 + minimum: 0 + maxAmountRial: + type: [integer, 'null'] + format: int64 + minimum: 0 items: type: array maxItems: 100 diff --git a/internal/analytics/details.go b/internal/analytics/details.go index feab9b8..a5a2af7 100644 --- a/internal/analytics/details.go +++ b/internal/analytics/details.go @@ -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) +} diff --git a/internal/analytics/details_test.go b/internal/analytics/details_test.go new file mode 100644 index 0000000..16e431b --- /dev/null +++ b/internal/analytics/details_test.go @@ -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) + } +} diff --git a/internal/analytics/service.go b/internal/analytics/service.go index 0dd6760..dfff37d 100644 --- a/internal/analytics/service.go +++ b/internal/analytics/service.go @@ -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 { diff --git a/internal/analytics/terminals.go b/internal/analytics/terminals.go new file mode 100644 index 0000000..4abe4f9 --- /dev/null +++ b/internal/analytics/terminals.go @@ -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 +} diff --git a/internal/api/server.go b/internal/api/server.go index 929e3c3..aec3b2a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 "" diff --git a/internal/api/server_test.go b/internal/api/server_test.go index f8ced11..3438819 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -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 +} diff --git a/internal/identity/seed.go b/internal/identity/seed.go index 3ad9608..a9e5683 100644 --- a/internal/identity/seed.go +++ b/internal/identity/seed.go @@ -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 {