feat: deliver authenticated merchant analytics API

This commit is contained in:
2026-08-20 19:57:32 +03:30
parent b30e3439a4
commit d1c7b34388
8 changed files with 914 additions and 2 deletions

View File

@@ -8,5 +8,5 @@ IMPORT_PATH=data/incoming/challenge_data.csv.gz
DEMO_USERNAME=merchant-0001 DEMO_USERNAME=merchant-0001
DEMO_PASSWORD=replace-with-at-least-12-characters DEMO_PASSWORD=replace-with-at-least-12-characters
SESSION_SECRET=replace-with-at-least-32-random-characters SESSION_SECRET=replace-with-at-least-32-random-characters
FRONTEND_ORIGIN=http://localhost:5173 FRONTEND_ORIGIN=http://127.0.0.1:5173
COOKIE_SECURE=false COOKIE_SECURE=false

View File

@@ -2,7 +2,27 @@
Go backend for merchant identity, dataset ingestion, deterministic analytics, authenticated APIs, and later AvalAI integration. Go backend for merchant identity, dataset ingestion, deterministic analytics, authenticated APIs, and later AvalAI integration.
Current state: specifications, architecture, implementation plan, and task list are approved. Phase 4 implementation is authorized; the Go scaffold begins with Task F1. Current state: the local MVP implements database migration, streaming dataset import,
secure demo identity, deterministic dashboard analytics, merchant-scoped details/CSV,
the authenticated HTTP API, and a Redis-connected worker boundary.
## Local development
Copy `.env.example` to the ignored `.env`, load its variables in your shell, and run:
```text
go run ./cmd/migrate
go run ./cmd/importer
go run ./cmd/admin
go run ./cmd/worker
go run ./cmd/api
```
`cmd/admin` idempotently creates or resets `DEMO_USERNAME` using `DEMO_PASSWORD`
and assigns it to the imported merchant with the largest session population. It
never prints the password or stores it outside the Argon2id hash. The local API
uses the `zpa_session` HttpOnly cookie when `COOKIE_SECURE=false`; secure
deployments use the contract's `__Host-zpa_session` cookie.
## Local dataset input ## Local dataset input

51
cmd/api/main.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/api"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/database"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
logger.Error("configuration failed", "error", err)
os.Exit(1)
}
if err := cfg.ValidateAPI(); err != nil {
logger.Error("api configuration failed", "error", err)
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
pool, err := database.Open(ctx, cfg.DatabaseURL)
if err != nil {
logger.Error("database connection failed", "error", err)
os.Exit(1)
}
defer pool.Close()
handler := api.NewServer(identity.New(pool), analytics.New(pool), logger, cfg.FrontendOrigin, cfg.CookieSecure).Handler()
server := &http.Server{Addr: cfg.HTTPAddr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 65 * time.Second, IdleTimeout: 60 * time.Second}
go func() {
<-ctx.Done()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
_ = server.Shutdown(shutdownCtx)
}()
logger.Info("api listening", "address", cfg.HTTPAddr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("api failed", "error", err)
os.Exit(1)
}
}

33
cmd/worker/main.go Normal file
View File

@@ -0,0 +1,33 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/config"
"github.com/redis/go-redis/v9"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := config.Load()
if err != nil {
logger.Error("configuration failed", "error", err)
os.Exit(1)
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
client := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr, Password: cfg.RedisPassword})
defer client.Close()
pingCtx, pingCancel := context.WithTimeout(ctx, 5*time.Second)
defer pingCancel()
if err := client.Ping(pingCtx).Err(); err != nil {
logger.Error("redis connection failed", "error", err)
os.Exit(1)
}
logger.Info("analytics worker ready", "redis_address", cfg.RedisAddr)
<-ctx.Done()
}

View File

@@ -0,0 +1,132 @@
package analytics
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strconv"
"time"
"github.com/jackc/pgx/v5"
)
type DetailFilter struct {
View string
FailureStage string
TerminalID string
Cursor string
Limit int
}
type Attempt struct {
Sequence int `json:"sequence"`
Status string `json:"status"`
CreatedAt *time.Time `json:"createdAt"`
InitTimeMS *int `json:"initTimeMs"`
VerifyTimeMS *int `json:"verifyTimeMs"`
}
type Detail struct {
SessionID string `json:"sessionId"`
TerminalID string `json:"terminalId"`
SessionStatus string `json:"sessionStatus"`
AmountRial int64 `json:"amountRial"`
FeeAmountRial int64 `json:"feeAmountRial"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt *time.Time `json:"expiresAt"`
VerifiedAt *time.Time `json:"verifiedAt"`
SettledAt *time.Time `json:"settledAt"`
AttemptCount int `json:"attemptCount"`
FailureStage *string `json:"failureStage"`
Attempts []Attempt `json:"attempts"`
}
func (s *Service) Details(ctx context.Context, merchantID int64, period Period, filter DetailFilter) ([]Detail, string, error) {
if filter.Limit <= 0 {
filter.Limit = 25
}
if filter.Limit > 100 {
return nil, "", errors.New("analytics: detail limit exceeds 100")
}
offset := 0
if filter.Cursor != "" {
decoded, err := base64.RawURLEncoding.DecodeString(filter.Cursor)
if err != nil {
return nil, "", errors.New("analytics: invalid cursor")
}
offset, err = strconv.Atoi(string(decoded))
if err != nil || offset < 0 {
return nil, "", errors.New("analytics: invalid cursor")
}
}
statusPredicate := "ps.status='Paid'"
switch filter.View {
case "UNVERIFIED_PAID":
statusPredicate = "ps.status='Paid'"
case "FAILED":
statusPredicate = "ps.status='Failed'"
case "RETRIED":
statusPredicate = "(SELECT count(*) FROM payment_attempts x WHERE x.session_id=ps.id)>1"
default:
return nil, "", errors.New("analytics: invalid detail view")
}
query := `SELECT ps.id,ps.source_key,t.source_key,ps.status,ps.amount_rial,ps.fee_amount_rial,ps.created_at,ps.expires_at,ps.verified_at,ps.settled_at,
count(pa.id)::int,coalesce(bool_or(pa.status='InBank'),false) FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
WHERE ps.merchant_id=$1 AND ps.created_at>=$2 AND ps.created_at<$3 AND ` + statusPredicate + `
AND ($4='' OR t.source_key=$4) GROUP BY ps.id,t.source_key ORDER BY ps.created_at DESC,ps.id DESC LIMIT $5 OFFSET $6`
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.TerminalID, filter.Limit+1, offset)
if err != nil {
return nil, "", fmt.Errorf("querying session details: %w", err)
}
defer rows.Close()
details := make([]Detail, 0, filter.Limit)
ids := make([]int64, 0, filter.Limit)
for rows.Next() {
var id int64
var reachedBank bool
var d Detail
if err := rows.Scan(&id, &d.SessionID, &d.TerminalID, &d.SessionStatus, &d.AmountRial, &d.FeeAmountRial, &d.CreatedAt, &d.ExpiresAt, &d.VerifiedAt, &d.SettledAt, &d.AttemptCount, &reachedBank); err != nil {
return nil, "", fmt.Errorf("scanning session detail: %w", err)
}
d.SessionID = "ses_" + d.SessionID
d.Attempts = []Attempt{}
if d.SessionStatus == "Failed" {
stage := "OTHER_OBSERVED"
if d.AttemptCount == 0 {
stage = "NO_ATTEMPT"
} else if reachedBank {
stage = "REACHED_BANK"
}
d.FailureStage = &stage
if filter.FailureStage != "" && filter.FailureStage != stage {
continue
}
}
if len(details) < filter.Limit {
details = append(details, d)
ids = append(ids, id)
}
}
if err := rows.Err(); err != nil {
return nil, "", fmt.Errorf("iterating session details: %w", err)
}
for i, id := range ids {
attemptRows, err := s.pool.Query(ctx, `SELECT sequence,status,created_at,init_time_ms,verify_time_ms FROM payment_attempts WHERE session_id=$1 ORDER BY sequence`, id)
if err != nil {
return nil, "", fmt.Errorf("querying attempts: %w", err)
}
attempts, err := pgx.CollectRows(attemptRows, func(row pgx.CollectableRow) (Attempt, error) {
var a Attempt
err := row.Scan(&a.Sequence, &a.Status, &a.CreatedAt, &a.InitTimeMS, &a.VerifyTimeMS)
return a, err
})
if err != nil {
return nil, "", fmt.Errorf("collecting attempts: %w", err)
}
details[i].Attempts = attempts
}
next := ""
if len(details) == filter.Limit {
next = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + filter.Limit)))
}
return details, next, nil
}

View File

@@ -0,0 +1,284 @@
package analytics
import (
"context"
"fmt"
"math"
"slices"
"sync"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Period struct {
PeriodID string `json:"periodId"`
PeriodType string `json:"periodType"`
StartAt time.Time `json:"startAt"`
EndAt time.Time `json:"endAt"`
LocalStartAt time.Time `json:"localStartAt"`
LocalEndAt time.Time `json:"localEndAt"`
Timezone string `json:"timezone"`
JalaliLabelFA string `json:"jalaliLabelFa"`
IsComplete bool `json:"isComplete"`
IsDefault bool `json:"isDefault"`
}
type Run struct {
RunID string `json:"runId"`
State string `json:"state"`
Period Period `json:"period"`
DatasetSnapshotID string `json:"datasetSnapshotId"`
MetricDefinitionVersion string `json:"metricDefinitionVersion"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
EventsURL string `json:"eventsUrl"`
Sections map[string]any `json:"sections"`
}
type Service struct {
pool *pgxpool.Pool
mu sync.RWMutex
runs map[string]storedRun
}
type storedRun struct {
merchantID int64
run Run
}
func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool, runs: map[string]storedRun{}} }
func (s *Service) Periods(ctx context.Context) ([]Period, error) {
var start, end time.Time
err := s.pool.QueryRow(ctx, `SELECT date_trunc('day',min(created_at)),date_trunc('day',max(created_at))+interval '1 day' FROM payment_sessions`).Scan(&start, &end)
if err != nil {
return nil, fmt.Errorf("loading dataset period: %w", err)
}
location, err := time.LoadLocation("Asia/Tehran")
if err != nil {
return nil, fmt.Errorf("loading tehran timezone: %w", err)
}
period := Period{PeriodID: "dataset-all", PeriodType: "DAY", StartAt: start.UTC(), EndAt: end.UTC(),
LocalStartAt: start.In(location), LocalEndAt: end.In(location), Timezone: "Asia/Tehran",
JalaliLabelFA: "کل داده‌های واردشده", IsComplete: true, IsDefault: true}
return []Period{period}, nil
}
func (s *Service) Start(ctx context.Context, merchantID int64, period Period) (Run, error) {
key := fmt.Sprintf("%d:%s", merchantID, period.PeriodID)
s.mu.RLock()
existing, ok := s.runs[key]
s.mu.RUnlock()
if ok {
return existing.run, nil
}
now := time.Now().UTC()
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{}}
run.EventsURL = "/api/v1/analytics/runs/" + run.RunID + "/events"
metrics, err := s.metrics(ctx, merchantID, period)
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["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)
if err != nil {
return Run{}, err
}
if peerCount < 10 || peerSessions < 1000 {
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "SUPPRESSED", "metadata": metadata,
"reason": "INSUFFICIENT_COHORT", "eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions,
"requiredPeerCount": 10, "requiredPeerSessionCount": 1000}
} else {
peerMetrics, err := s.peerMetrics(ctx, merchantID, period)
if err != nil {
return Run{}, err
}
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "READY", "metadata": metadata,
"data": map[string]any{"eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions, "metrics": peerMetrics}}
}
s.mu.Lock()
s.runs[key] = storedRun{merchantID: merchantID, run: run}
s.runs[run.RunID] = storedRun{merchantID: merchantID, run: run}
s.mu.Unlock()
return run, nil
}
func (s *Service) Run(merchantID int64, runID string) (Run, bool) {
s.mu.RLock()
stored, ok := s.runs[runID]
s.mu.RUnlock()
return stored.run, ok && stored.merchantID == merchantID
}
type metricValues struct {
total, paid, verified, unverified, failed, reversed, noAttempt, reachedBank, retried, recovered int64
paidAmount, feeAmount int64
initP50, initP95, verifyP50, verifyP95 *float64
initSamples, verifySamples int64
}
func (s *Service) metrics(ctx context.Context, merchantID int64, p Period) (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
), 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,
percentile_disc(0.5) within group(order by pa.verify_time_ms)::float8 verify_p50,
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)
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),
count(*) filter(where attempts>1),count(*) filter(where attempts>1 and status in ('Paid','Verified')),
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,
&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 {
return metricValues{}, fmt.Errorf("calculating merchant metrics: %w", err)
}
return m, nil
}
func integerMetric(id string, value int64, unit string) map[string]any {
return map[string]any{"metricId": id, "value": value, "unit": unit}
}
func decimalMetric(id string, numerator, denominator int64, unit string) map[string]any {
var value any
if denominator > 0 {
value = fmt.Sprintf("%.2f", float64(numerator)*100/float64(denominator))
}
return map[string]any{"metricId": id, "value": value, "unit": unit, "numerator": numerator, "denominator": denominator}
}
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 {
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)},
}
}
func timing(id string, p50, p95 *float64, samples, eligible int64) map[string]any {
var a, b any
if p50 != nil {
a = int64(math.Round(*p50))
}
if p95 != nil {
b = int64(math.Round(*p95))
}
var coverage any
if eligible > 0 {
coverage = fmt.Sprintf("%.2f", float64(samples)*100/float64(eligible))
}
return map[string]any{"metricId": id, "p50Ms": a, "p95Ms": b, "sampleSize": samples, "eligibleSampleSize": eligible, "coveragePercent": coverage}
}
func (m metricValues) performance() map[string]any {
return map[string]any{
"bankPaidSessions": integerMetric("bank_paid_sessions", m.paid, "SESSIONS"), "verifiedSessions": integerMetric("verified_sessions", m.verified, "SESSIONS"), "unverifiedPaidSessions": integerMetric("unverified_paid_sessions", m.unverified, "SESSIONS"),
"failedSessions": integerMetric("failed_sessions", m.failed, "SESSIONS"), "reversedSessions": integerMetric("reversed_sessions", m.reversed, "SESSIONS"), "noAttemptSessions": integerMetric("no_attempt_sessions", m.noAttempt, "SESSIONS"),
"reachedBankSessions": integerMetric("reached_bank_sessions", m.reachedBank, "SESSIONS"), "retriedSessions": integerMetric("retried_sessions", m.retried, "SESSIONS"), "retryRecoveryRate": decimalMetric("retry_recovery_rate", m.recovered, m.retried, "PERCENT"),
"initTiming": timing("init_time_ms", m.initP50, m.initP95, m.initSamples, m.total), "verifyTiming": timing("verify_time_ms", m.verifyP50, m.verifyP95, m.verifySamples, m.paid),
}
}
func (m metricValues) fees(period Period) map[string]any {
return map[string]any{"paidFeeAmountRial": integerMetric("paid_fee_amount_rial", m.feeAmount, "RIAL"), "bankPaidAmountRial": integerMetric("bank_paid_amount_rial", m.paidAmount, "RIAL"), "paidFeeRate": decimalMetric("paid_fee_rate", m.feeAmount, m.paidAmount, "RATIO"), "comparison": unavailableComparison(period)}
}
func (s *Service) peerEligibility(ctx context.Context, merchantID int64, p Period) (int64, int64, error) {
var peers, sessions int64
err := s.pool.QueryRow(ctx, `SELECT count(distinct ps.merchant_id),count(*) FROM payment_sessions ps JOIN merchants m ON m.id=ps.merchant_id
WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.merchant_id<>$1 AND ps.created_at>=$2 AND ps.created_at<$3`, merchantID, p.StartAt, p.EndAt).Scan(&peers, &sessions)
if err != nil {
return 0, 0, fmt.Errorf("calculating peer eligibility: %w", err)
}
return peers, sessions, nil
}
type peerRow struct {
merchantID int64
values [8]*float64
}
func (s *Service) peerMetrics(ctx context.Context, merchantID int64, p Period) ([]map[string]any, error) {
query := `WITH session_stats AS (SELECT ps.merchant_id,ps.id,ps.status,ps.amount_rial,ps.fee_amount_rial,count(pa.id) attempts
FROM payment_sessions ps LEFT JOIN payment_attempts pa ON pa.session_id=ps.id JOIN merchants m ON m.id=ps.merchant_id
WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.created_at>=$2 AND ps.created_at<$3 GROUP BY ps.id),
aggregate AS (SELECT merchant_id,100.0*count(*) filter(where status in ('Paid','Verified'))/nullif(count(*),0) success,
100.0*count(*) filter(where status='Verified')/nullif(count(*) filter(where status in ('Paid','Verified')),0) verification,
100.0*count(*) filter(where attempts>1 and status in ('Paid','Verified'))/nullif(count(*) filter(where attempts>1),0) recovery,
100.0*sum(fee_amount_rial) filter(where status in ('Paid','Verified'))/nullif(sum(amount_rial) filter(where status in ('Paid','Verified')),0) fee FROM session_stats GROUP BY merchant_id),
timing AS (SELECT ps.merchant_id,percentile_disc(.5) within group(order by pa.init_time_ms)::float8 ip50,percentile_disc(.95) within group(order by pa.init_time_ms)::float8 ip95,
percentile_disc(.5) within group(order by pa.verify_time_ms)::float8 vp50,percentile_disc(.95) within group(order by pa.verify_time_ms)::float8 vp95
FROM payment_sessions ps JOIN payment_attempts pa ON pa.session_id=ps.id JOIN merchants m ON m.id=ps.merchant_id WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.created_at>=$2 AND ps.created_at<$3 GROUP BY ps.merchant_id)
SELECT a.merchant_id,a.success::float8,a.verification::float8,a.recovery::float8,t.ip50,t.ip95,t.vp50,t.vp95,a.fee::float8 FROM aggregate a JOIN timing t USING(merchant_id)`
rows, err := s.pool.Query(ctx, query, merchantID, p.StartAt, p.EndAt)
if err != nil {
return nil, fmt.Errorf("calculating peer metrics: %w", err)
}
defer rows.Close()
all := []peerRow{}
for rows.Next() {
var row peerRow
if err := rows.Scan(&row.merchantID, &row.values[0], &row.values[1], &row.values[2], &row.values[3], &row.values[4], &row.values[5], &row.values[6], &row.values[7]); err != nil {
return nil, fmt.Errorf("scanning peer metrics: %w", err)
}
all = append(all, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating peer metrics: %w", err)
}
ids := []string{"payment_success_rate", "verification_completion_rate", "retry_recovery_rate", "init_time_ms_p50", "init_time_ms_p95", "verify_time_ms_p50", "verify_time_ms_p95", "paid_fee_rate"}
metrics := make([]map[string]any, 0, 8)
for index, id := range ids {
active := 0.0
peers := []float64{}
for _, row := range all {
if row.values[index] == nil {
continue
}
if row.merchantID == merchantID {
active = *row.values[index]
} else {
peers = append(peers, *row.values[index])
}
}
if len(peers) == 0 {
peers = append(peers, 0)
}
slices.Sort(peers)
median := peers[len(peers)/2]
if len(peers)%2 == 0 {
median = (peers[len(peers)/2-1] + median) / 2
}
lowerIsBetter := index >= 3
better := 0
for _, value := range peers {
if (!lowerIsBetter && active >= value) || (lowerIsBetter && active <= value) {
better++
}
}
percentile := float64(better) * 100 / float64(len(peers))
direction := "HIGHER_IS_BETTER"
if lowerIsBetter {
direction = "LOWER_IS_BETTER"
}
metrics = append(metrics, map[string]any{"metricId": id, "cohortMedian": fmt.Sprintf("%.2f", median), "merchantPerformancePercentile": fmt.Sprintf("%.2f", percentile), "direction": direction})
}
return metrics, nil
}

362
internal/api/server.go Normal file
View File

@@ -0,0 +1,362 @@
package api
import (
"context"
"crypto/rand"
"encoding/csv"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
)
type Server struct {
identity *identity.Service
analytics *analytics.Service
logger *slog.Logger
frontendOrigin string
cookieSecure bool
}
type sessionKey struct{}
type tokenKey struct{}
func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool) *Server {
return &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("POST /api/v1/auth/sessions", s.login)
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("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)))
return s.boundary(mux)
}
func (s *Server) boundary(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = randomID()
}
w.Header().Set("X-Request-ID", requestID)
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func (s *Server) authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(s.cookieName())
if err != nil {
s.problem(w, r, http.StatusUnauthorized, "UNAUTHENTICATED", "Authentication required")
return
}
session, err := s.identity.Resolve(r.Context(), cookie.Value)
if errors.Is(err, identity.ErrUnauthenticated) {
s.problem(w, r, http.StatusUnauthorized, "UNAUTHENTICATED", "Authentication required")
return
}
if err != nil {
s.logger.Error("session resolution failed", "error", err)
s.problem(w, r, http.StatusServiceUnavailable, "DEPENDENCY_UNAVAILABLE", "Service unavailable")
return
}
ctx := context.WithValue(r.Context(), sessionKey{}, session)
ctx = context.WithValue(ctx, tokenKey{}, cookie.Value)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) csrf(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := sessionFrom(r.Context())
origin := r.Header.Get("Origin")
if r.Header.Get("X-CSRF-Token") != session.CSRFToken || (origin != "" && origin != s.frontendOrigin) {
s.problem(w, r, http.StatusForbidden, "FORBIDDEN", "Request could not be verified")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" && origin != s.frontendOrigin {
s.problem(w, r, http.StatusForbidden, "FORBIDDEN", "Request could not be verified")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
var input struct {
Username string `json:"username"`
Password string `json:"password"`
}
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil || input.Username == "" || input.Password == "" {
s.problem(w, r, http.StatusUnprocessableEntity, "VALIDATION_FAILED", "Invalid login request")
return
}
session, token, err := s.identity.Login(r.Context(), input.Username, input.Password)
if errors.Is(err, identity.ErrUnauthenticated) {
s.problem(w, r, http.StatusUnauthorized, "UNAUTHENTICATED", "Invalid username or password")
return
}
if err != nil {
s.logger.Error("login failed", "error", err)
s.problem(w, r, http.StatusServiceUnavailable, "DEPENDENCY_UNAVAILABLE", "Service unavailable")
return
}
http.SetCookie(w, &http.Cookie{Name: s.cookieName(), Value: token, Path: "/", HttpOnly: true, Secure: s.cookieSecure, SameSite: http.SameSiteLaxMode, Expires: session.AbsoluteExpiresAt})
s.writeJSON(w, http.StatusCreated, sessionResponse(session))
}
func (s *Server) currentSession(w http.ResponseWriter, r *http.Request) {
s.writeJSON(w, http.StatusOK, sessionResponse(sessionFrom(r.Context())))
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if err := s.identity.Logout(r.Context(), r.Context().Value(tokenKey{}).(string)); err != nil {
s.logger.Error("logout failed", "error", err)
s.problem(w, r, http.StatusServiceUnavailable, "DEPENDENCY_UNAVAILABLE", "Service unavailable")
return
}
http.SetCookie(w, &http.Cookie{Name: s.cookieName(), Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1})
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) periods(w http.ResponseWriter, r *http.Request) {
periods, err := s.analytics.Periods(r.Context())
if err != nil {
s.logger.Error("period discovery failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
s.writeJSON(w, http.StatusOK, map[string]any{"defaultPeriodId": periods[0].PeriodID, "periods": periods})
}
func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
var input struct {
PeriodID string `json:"periodId"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid analytics request")
return
}
periods, err := s.analytics.Periods(r.Context())
if err != nil {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
var period analytics.Period
found := false
for _, candidate := range periods {
if candidate.PeriodID == input.PeriodID {
period = candidate
found = true
break
}
}
if !found {
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
return
}
session := sessionFrom(r.Context())
run, err := s.analytics.Start(r.Context(), session.MerchantID, period)
if err != nil {
s.logger.Error("analytics calculation failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
w.Header().Set("Location", "/api/v1/analytics/runs/"+run.RunID)
s.writeJSON(w, http.StatusOK, run)
}
func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
run, ok := s.analytics.Run(sessionFrom(r.Context()).MerchantID, r.PathValue("runId"))
if !ok {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
s.writeJSON(w, 200, run)
}
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
run, ok := s.analytics.Run(sessionFrom(r.Context()).MerchantID, r.PathValue("runId"))
if !ok {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("X-Accel-Buffering", "no")
eventID := "evt_" + randomID()
data := map[string]any{"eventId": eventID, "runId": run.RunID, "occurredAt": time.Now().UTC(), "recovery": "RESET_TO_SNAPSHOT", "run": run}
payload, _ := json.Marshal(data)
fmt.Fprintf(w, "id: %s\nevent: run.snapshot\ndata: %s\n\n", eventID, payload)
}
func (s *Server) details(w http.ResponseWriter, r *http.Request) {
period, ok := s.requestPeriod(r)
if !ok {
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
return
}
limit := 25
if raw := r.URL.Query().Get("limit"); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 100 {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid page limit")
return
}
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}
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)
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid detail request")
return
}
var nextValue any
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}})
}
func (s *Server) exportCSV(w http.ResponseWriter, r *http.Request) {
period, ok := s.requestPeriod(r)
if !ok {
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
return
}
filter := analytics.DetailFilter{View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"), TerminalID: r.URL.Query().Get("terminalId"), Limit: 100}
items, _, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
if err != nil {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid export request")
return
}
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", strings.ToLower(filter.View)+"-"+period.PeriodID+".csv"))
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
writer := csv.NewWriter(w)
writer.UseCRLF = true
_ = writer.Write([]string{"شناسه نشست پرداخت", "شناسه پایانه", "وضعیت پرداخت", "مبلغ ریال", "کارمزد ریال", "زمان ایجاد", "زمان انقضا", "تعداد تلاش", "وضعیت آخرین تلاش", "زمان آخرین تلاش", "زمان تأیید", "زمان تسویه", "مرحله مشاهده شده شکست"})
for _, item := range items {
lastStatus, lastAt := "", ""
if len(item.Attempts) > 0 {
last := item.Attempts[len(item.Attempts)-1]
lastStatus = persianStatus(last.Status)
if last.CreatedAt != nil {
lastAt = last.CreatedAt.UTC().Format(time.RFC3339)
}
}
_ = writer.Write([]string{item.SessionID, item.TerminalID, persianStatus(item.SessionStatus), strconv.FormatInt(item.AmountRial, 10), strconv.FormatInt(item.FeeAmountRial, 10), item.CreatedAt.UTC().Format(time.RFC3339), formatTime(item.ExpiresAt), strconv.Itoa(item.AttemptCount), lastStatus, lastAt, formatTime(item.VerifiedAt), formatTime(item.SettledAt), stringValue(item.FailureStage)})
}
writer.Flush()
}
func (s *Server) requestPeriod(r *http.Request) (analytics.Period, bool) {
periods, err := s.analytics.Periods(r.Context())
if err != nil {
return analytics.Period{}, false
}
id := r.URL.Query().Get("periodId")
for _, p := range periods {
if p.PeriodID == id {
return p, true
}
}
return analytics.Period{}, false
}
func nullable(value string) any {
if value == "" {
return nil
}
return value
}
func formatTime(value *time.Time) string {
if value == nil {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func persianStatus(value string) string {
switch value {
case "Paid":
return "پرداخت شده و تأیید نشده"
case "Verified":
return "تأیید شده"
case "Failed":
return "ناموفق"
case "Reversed":
return "برگشت خورده"
case "InBank":
return "در بانک"
default:
return value
}
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
s.writeJSON(w, 200, map[string]string{"status": "ok"})
}
func (s *Server) cookieName() string {
if s.cookieSecure {
return "__Host-zpa_session"
}
return "zpa_session"
}
func sessionFrom(ctx context.Context) identity.Session {
return ctx.Value(sessionKey{}).(identity.Session)
}
func sessionResponse(v identity.Session) map[string]any {
return map[string]any{"user": map[string]any{"userId": fmt.Sprintf("user_%d", v.UserID), "username": v.Username}, "merchant": map[string]any{"merchantId": fmt.Sprintf("merchant_%d", v.MerchantID), "displayName": v.MerchantDisplayName, "profileIsSynthetic": v.ProfileIsSynthetic}, "createdAt": v.CreatedAt, "idleExpiresAt": v.IdleExpiresAt, "absoluteExpiresAt": v.AbsoluteExpiresAt, "csrfToken": v.CSRFToken}
}
func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
s.logger.Error("response encoding failed", "error", err)
}
}
func (s *Server) problem(w http.ResponseWriter, r *http.Request, status int, code, detail string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
value := map[string]any{"type": "https://zarinpal.amiirkhl.ir/docs/errors/" + strings.ToLower(strings.ReplaceAll(code, "_", "-")), "title": http.StatusText(status), "status": status, "code": code, "detail": detail, "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID")}
if err := json.NewEncoder(w).Encode(value); err != nil {
s.logger.Error("problem encoding failed", "error", err)
}
}
func randomID() string {
data := make([]byte, 12)
if _, err := rand.Read(data); err != nil {
return fmt.Sprint(time.Now().UnixNano())
}
return hex.EncodeToString(data)
}

View File

@@ -0,0 +1,30 @@
package api
import "testing"
func TestServerCookieName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
secure bool
expected string
}{
{name: "local development", secure: false, expected: "zpa_session"},
{name: "secure deployment", secure: true, expected: "__Host-zpa_session"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := &Server{cookieSecure: tt.secure}
if got := server.cookieName(); got != tt.expected {
t.Fatalf("cookieName() = %q, want %q", got, tt.expected)
}
})
}
}
func TestPersianStatus(t *testing.T) {
t.Parallel()
if got := persianStatus("Verified"); got != "تأیید شده" {
t.Fatalf("persianStatus() = %q", got)
}
}