feat: add terminal-scoped transaction analytics
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user