feat: add terminal-scoped transaction analytics
This commit is contained in:
@@ -172,6 +172,26 @@ paths:
|
|||||||
'503':
|
'503':
|
||||||
$ref: '#/components/responses/DependencyUnavailable'
|
$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:
|
/analytics/runs:
|
||||||
post:
|
post:
|
||||||
tags: [Analytics runs]
|
tags: [Analytics runs]
|
||||||
@@ -415,6 +435,25 @@ paths:
|
|||||||
- $ref: '#/components/parameters/DetailViewQuery'
|
- $ref: '#/components/parameters/DetailViewQuery'
|
||||||
- $ref: '#/components/parameters/FailureStageQuery'
|
- $ref: '#/components/parameters/FailureStageQuery'
|
||||||
- $ref: '#/components/parameters/TerminalIdQuery'
|
- $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/CursorQuery'
|
||||||
- $ref: '#/components/parameters/LimitQuery'
|
- $ref: '#/components/parameters/LimitQuery'
|
||||||
- $ref: '#/components/parameters/RequestIdHeader'
|
- $ref: '#/components/parameters/RequestIdHeader'
|
||||||
@@ -445,6 +484,34 @@ paths:
|
|||||||
'503':
|
'503':
|
||||||
$ref: '#/components/responses/AnalyticsUnavailable'
|
$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:
|
/analytics/session-details.csv:
|
||||||
get:
|
get:
|
||||||
tags: [Analytics details]
|
tags: [Analytics details]
|
||||||
@@ -1025,6 +1092,52 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
periodId:
|
periodId:
|
||||||
$ref: '#/components/schemas/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:
|
RunId:
|
||||||
type: string
|
type: string
|
||||||
@@ -1057,6 +1170,7 @@ components:
|
|||||||
- createdAt
|
- createdAt
|
||||||
- updatedAt
|
- updatedAt
|
||||||
- eventsUrl
|
- eventsUrl
|
||||||
|
- scope
|
||||||
- sections
|
- sections
|
||||||
properties:
|
properties:
|
||||||
runId:
|
runId:
|
||||||
@@ -1081,6 +1195,8 @@ components:
|
|||||||
eventsUrl:
|
eventsUrl:
|
||||||
type: string
|
type: string
|
||||||
format: uri-reference
|
format: uri-reference
|
||||||
|
scope:
|
||||||
|
$ref: '#/components/schemas/AnalyticsScope'
|
||||||
sections:
|
sections:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
@@ -1246,6 +1362,7 @@ components:
|
|||||||
- paymentSuccessRate
|
- paymentSuccessRate
|
||||||
- verificationCompletionRate
|
- verificationCompletionRate
|
||||||
- comparisons
|
- comparisons
|
||||||
|
- paymentHistory
|
||||||
properties:
|
properties:
|
||||||
bankPaidSessions:
|
bankPaidSessions:
|
||||||
allOf:
|
allOf:
|
||||||
@@ -1265,6 +1382,32 @@ components:
|
|||||||
$ref: '#/components/schemas/PreviousPeriodComparison'
|
$ref: '#/components/schemas/PreviousPeriodComparison'
|
||||||
propertyNames:
|
propertyNames:
|
||||||
enum: [bank_paid_sessions, bank_paid_amount_rial, payment_success_rate, verification_completion_rate]
|
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:
|
PaymentPerformanceData:
|
||||||
type: object
|
type: object
|
||||||
@@ -1449,7 +1592,7 @@ components:
|
|||||||
|
|
||||||
DetailView:
|
DetailView:
|
||||||
type: string
|
type: string
|
||||||
enum: [UNVERIFIED_PAID, FAILED, RETRIED]
|
enum: [ALL, UNVERIFIED_PAID, FAILED, RETRIED]
|
||||||
FailureStage:
|
FailureStage:
|
||||||
type: string
|
type: string
|
||||||
enum: [NO_ATTEMPT, REACHED_BANK, OTHER_OBSERVED]
|
enum: [NO_ATTEMPT, REACHED_BANK, OTHER_OBSERVED]
|
||||||
@@ -1573,6 +1716,18 @@ components:
|
|||||||
oneOf:
|
oneOf:
|
||||||
- $ref: '#/components/schemas/FailureStage'
|
- $ref: '#/components/schemas/FailureStage'
|
||||||
- type: 'null'
|
- 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:
|
items:
|
||||||
type: array
|
type: array
|
||||||
maxItems: 100
|
maxItems: 100
|
||||||
|
|||||||
@@ -6,18 +6,48 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DetailFilter struct {
|
type DetailFilter struct {
|
||||||
View string
|
View string
|
||||||
FailureStage string
|
FailureStage string
|
||||||
TerminalID string
|
TerminalID string
|
||||||
Cursor string
|
Status string
|
||||||
Limit int
|
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 {
|
type Attempt struct {
|
||||||
Sequence int `json:"sequence"`
|
Sequence int `json:"sequence"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -44,8 +74,8 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
|
|||||||
if filter.Limit <= 0 {
|
if filter.Limit <= 0 {
|
||||||
filter.Limit = 25
|
filter.Limit = 25
|
||||||
}
|
}
|
||||||
if filter.Limit > 100 {
|
if err := filter.Validate(); err != nil {
|
||||||
return nil, "", errors.New("analytics: detail limit exceeds 100")
|
return nil, "", err
|
||||||
}
|
}
|
||||||
offset := 0
|
offset := 0
|
||||||
if filter.Cursor != "" {
|
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")
|
return nil, "", errors.New("analytics: invalid cursor")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
statusPredicate := "ps.status='Paid'"
|
statusPredicate := "TRUE"
|
||||||
switch filter.View {
|
switch filter.View {
|
||||||
|
case "ALL":
|
||||||
|
statusPredicate = "TRUE"
|
||||||
case "UNVERIFIED_PAID":
|
case "UNVERIFIED_PAID":
|
||||||
statusPredicate = "ps.status='Paid'"
|
statusPredicate = "ps.status='Paid'"
|
||||||
case "FAILED":
|
case "FAILED":
|
||||||
statusPredicate = "ps.status='Failed'"
|
statusPredicate = "ps.status='Failed'"
|
||||||
case "RETRIED":
|
case "RETRIED":
|
||||||
statusPredicate = "(SELECT count(*) FROM payment_attempts x WHERE x.session_id=ps.id)>1"
|
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,
|
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
|
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 + `
|
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`
|
AND ($4='' OR t.source_key=$4)
|
||||||
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.TerminalID, filter.Limit+1, offset)
|
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 {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("querying session details: %w", err)
|
return nil, "", fmt.Errorf("querying session details: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
details := make([]Detail, 0, filter.Limit)
|
details := make([]Detail, 0, filter.Limit)
|
||||||
ids := make([]int64, 0, filter.Limit)
|
ids := make([]int64, 0, filter.Limit)
|
||||||
|
hasMore := false
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var id int64
|
||||||
var reachedBank bool
|
var reachedBank bool
|
||||||
@@ -89,44 +126,99 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
|
|||||||
}
|
}
|
||||||
d.SessionID = "ses_" + d.SessionID
|
d.SessionID = "ses_" + d.SessionID
|
||||||
d.Attempts = []Attempt{}
|
d.Attempts = []Attempt{}
|
||||||
if d.SessionStatus == "Failed" {
|
setFailureStage(&d, reachedBank)
|
||||||
stage := "OTHER_OBSERVED"
|
if len(details) == filter.Limit {
|
||||||
if d.AttemptCount == 0 {
|
hasMore = true
|
||||||
stage = "NO_ATTEMPT"
|
break
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
|
details = append(details, d)
|
||||||
|
ids = append(ids, id)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, "", fmt.Errorf("iterating session details: %w", err)
|
return nil, "", fmt.Errorf("iterating session details: %w", err)
|
||||||
}
|
}
|
||||||
for i, id := range ids {
|
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 {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("querying attempts: %w", err)
|
return nil, "", 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
|
details[i].Attempts = attempts
|
||||||
}
|
}
|
||||||
next := ""
|
next := ""
|
||||||
if len(details) == filter.Limit {
|
if hasMore {
|
||||||
next = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + filter.Limit)))
|
next = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + filter.Limit)))
|
||||||
}
|
}
|
||||||
return details, next, nil
|
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)
|
||||||
|
}
|
||||||
|
|||||||
61
internal/analytics/details_test.go
Normal file
61
internal/analytics/details_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,9 +33,21 @@ type Run struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
EventsURL string `json:"eventsUrl"`
|
EventsURL string `json:"eventsUrl"`
|
||||||
|
Scope RunScope `json:"scope"`
|
||||||
Sections map[string]any `json:"sections"`
|
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 {
|
type Service struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -62,8 +74,11 @@ func (s *Service) Periods(ctx context.Context) ([]Period, error) {
|
|||||||
return latestCompletePeriods(start, end, location), nil
|
return latestCompletePeriods(start, end, location), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Start(ctx context.Context, merchantID int64, period Period) (Run, error) {
|
func (s *Service) Start(ctx context.Context, merchantID int64, period Period, terminalID string) (Run, error) {
|
||||||
key := fmt.Sprintf("%d:%s", merchantID, period.PeriodID)
|
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()
|
s.mu.RLock()
|
||||||
existing, ok := s.runs[key]
|
existing, ok := s.runs[key]
|
||||||
s.mu.RUnlock()
|
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,
|
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,
|
DatasetSnapshotID: "ds_localimport", MetricDefinitionVersion: "0.4.0", CreatedAt: now, UpdatedAt: now,
|
||||||
Sections: map[string]any{}}
|
Sections: map[string]any{}}
|
||||||
|
if terminalID != "" {
|
||||||
|
run.Scope.TerminalID = &terminalID
|
||||||
|
}
|
||||||
run.EventsURL = "/api/v1/analytics/runs/" + run.RunID + "/events"
|
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 {
|
if err != nil {
|
||||||
return Run{}, err
|
return Run{}, err
|
||||||
}
|
}
|
||||||
metadata := map[string]any{"calculatedAt": now, "datasetSnapshotId": run.DatasetSnapshotID, "metricDefinitionVersion": "0.4.0"}
|
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["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)}
|
run.Sections["fees"] = map[string]any{"sectionId": "fees", "state": "READY", "metadata": metadata, "data": metrics.fees(period)}
|
||||||
peerCount, peerSessions, err := s.peerEligibility(ctx, merchantID, period)
|
peerCount, peerSessions, err := s.peerEligibility(ctx, merchantID, period)
|
||||||
@@ -120,12 +142,13 @@ type metricValues struct {
|
|||||||
initSamples, verifySamples int64
|
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
|
var m metricValues
|
||||||
query := `WITH scoped AS (
|
query := `WITH scoped AS (
|
||||||
SELECT ps.*,count(pa.id) attempts,bool_or(pa.status='InBank') reached_bank
|
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
|
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 (
|
), timings AS (
|
||||||
SELECT percentile_disc(0.5) within group(order by pa.init_time_ms)::float8 init_p50,
|
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,
|
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,
|
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
|
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
|
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')),
|
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='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),
|
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),
|
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
|
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`
|
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.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)
|
&m.feeAmount, &m.initP50, &m.initP95, &m.verifyP50, &m.verifyP95, &m.initSamples, &m.verifySamples)
|
||||||
if err != nil {
|
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}
|
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{
|
return map[string]any{
|
||||||
"bankPaidSessions": integerMetric("bank_paid_sessions", m.paid, "SESSIONS"), "bankPaidAmountRial": integerMetric("bank_paid_amount_rial", m.paidAmount, "RIAL"),
|
"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"),
|
"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 {
|
func timing(id string, p50, p95 *float64, samples, eligible int64) map[string]any {
|
||||||
var a, b any
|
var a, b any
|
||||||
if p50 != nil {
|
if p50 != nil {
|
||||||
|
|||||||
54
internal/analytics/terminals.go
Normal file
54
internal/analytics/terminals.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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("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("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/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("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}", 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/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", 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/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)
|
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")
|
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
||||||
return
|
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) {
|
func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
|
||||||
var input struct {
|
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 := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
@@ -180,7 +204,11 @@ func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
session := sessionFrom(r.Context())
|
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 {
|
if err != nil {
|
||||||
s.logger.Error("analytics calculation failed", "error", err)
|
s.logger.Error("analytics calculation failed", "error", err)
|
||||||
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
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
|
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)
|
items, next, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("detail query failed", "error", err)
|
s.logger.Error("detail query failed", "error", err)
|
||||||
@@ -240,7 +283,24 @@ func (s *Server) details(w http.ResponseWriter, r *http.Request) {
|
|||||||
if next != "" {
|
if next != "" {
|
||||||
nextValue = 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) {
|
func (s *Server) exportCSV(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -294,6 +354,16 @@ func nullable(value string) any {
|
|||||||
}
|
}
|
||||||
return value
|
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 {
|
func formatTime(value *time.Time) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -28,3 +28,35 @@ func TestPersianStatus(t *testing.T) {
|
|||||||
t.Fatalf("persianStatus() = %q", got)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func SeedDemo(ctx context.Context, pool *pgxpool.Pool, username, password string
|
|||||||
var merchantID int64
|
var merchantID int64
|
||||||
err = pool.QueryRow(ctx, `SELECT m.id,m.source_key,m.display_name
|
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
|
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,
|
&merchantID, &result.MerchantSourceKey, &result.MerchantName,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user