feat: add terminal-scoped transaction analytics

This commit is contained in:
2026-08-20 20:56:15 +03:30
parent f710762b90
commit 857f29635e
8 changed files with 571 additions and 55 deletions

View File

@@ -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 {