From 71402138574b1ac1c09e65c6b31e8cfa09b3a2db Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Fri, 21 Aug 2026 16:28:13 +0800 Subject: [PATCH] feat: aggregate daily payment history by hour --- docs/ANALYTICS-SPEC.md | 4 ++-- docs/openapi.yaml | 5 ++++ internal/analytics/service.go | 37 +++++++++++++++++++++++------- internal/analytics/service_test.go | 29 ++++++++++++++++++++++- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/docs/ANALYTICS-SPEC.md b/docs/ANALYTICS-SPEC.md index 1380701..eb4324e 100644 --- a/docs/ANALYTICS-SPEC.md +++ b/docs/ANALYTICS-SPEC.md @@ -81,8 +81,8 @@ Every section uses a versioned typed contract and has independent `queued`, `cal ## Terminal scope and payment history - `terminalId` is optional and is resolved only under the merchant from the authenticated server-side session. Omission means all merchant terminals; unknown and other-merchant identifiers fail identically. -- Overview, payment-performance, fees, and daily payment-history calculations use the selected terminal predicate when present. -- Daily payment-history points use Tehran-local dates and return total sessions, bank-paid sessions, and bank-paid amount in Rial for each date with source sessions in the selected period/scope. +- Overview, payment-performance, fees, and payment-history calculations use the selected terminal predicate when present. +- Payment-history points use Tehran-local buckets and return total sessions, bank-paid sessions, and bank-paid amount in Rial. `DAY` periods are grouped by hour and include `localHour` from 0 through 23; longer periods remain grouped by date. - The payment-history array is bounded by the maximum approved period length and never contains payer tokens, peer rows, or attempt-grain money. ## Transaction evidence diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e5e60ed..cf64215 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1639,6 +1639,11 @@ components: localDate: type: string format: date + localHour: + type: integer + minimum: 0 + maximum: 23 + description: Tehran-local hour bucket, present only for DAY periods. totalSessions: type: integer format: int64 diff --git a/internal/analytics/service.go b/internal/analytics/service.go index 1cdea6f..f1d796e 100644 --- a/internal/analytics/service.go +++ b/internal/analytics/service.go @@ -43,6 +43,7 @@ type RunScope struct { type PaymentHistoryPoint struct { LocalDate string `json:"localDate"` + LocalHour *int `json:"localHour,omitempty"` TotalSessions int64 `json:"totalSessions"` BankPaidSessions int64 `json:"bankPaidSessions"` BankPaidAmountRial int64 `json:"bankPaidAmountRial"` @@ -209,24 +210,25 @@ func (m metricValues) overview(period Period, history []PaymentHistoryPoint) map } 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) + query, capacity := paymentHistoryQuery(period.PeriodType) + rows, err := s.pool.Query(ctx, query, 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) + points := make([]PaymentHistoryPoint, 0, capacity) for rows.Next() { var date time.Time + var localHour *int16 var point PaymentHistoryPoint - if err := rows.Scan(&date, &point.TotalSessions, &point.BankPaidSessions, &point.BankPaidAmountRial); err != nil { + if err := rows.Scan(&date, &localHour, &point.TotalSessions, &point.BankPaidSessions, &point.BankPaidAmountRial); err != nil { return nil, fmt.Errorf("scanning payment history: %w", err) } point.LocalDate = date.Format(time.DateOnly) + if localHour != nil { + hour := int(*localHour) + point.LocalHour = &hour + } points = append(points, point) } if err := rows.Err(); err != nil { @@ -234,6 +236,25 @@ func (s *Service) paymentHistory(ctx context.Context, merchantID int64, period P } return points, nil } + +func paymentHistoryQuery(periodType string) (string, int) { + if periodType == "DAY" { + return `SELECT (ps.created_at AT TIME ZONE 'Asia/Tehran')::date local_date, + EXTRACT(HOUR FROM ps.created_at AT TIME ZONE 'Asia/Tehran')::smallint local_hour, + 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, local_hour ORDER BY local_date, local_hour`, 24 + } + return `SELECT (ps.created_at AT TIME ZONE 'Asia/Tehran')::date local_date, + NULL::smallint local_hour, + 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`, 93 +} func timing(id string, p50, p95 *float64, samples, eligible int64) map[string]any { var a, b any if p50 != nil { diff --git a/internal/analytics/service_test.go b/internal/analytics/service_test.go index 2ad03ea..2f334b8 100644 --- a/internal/analytics/service_test.go +++ b/internal/analytics/service_test.go @@ -1,6 +1,9 @@ package analytics -import "testing" +import ( + "strings" + "testing" +) func TestPeerComparisonSupported(t *testing.T) { t.Parallel() @@ -11,3 +14,27 @@ func TestPeerComparisonSupported(t *testing.T) { t.Fatal("peerComparisonSupported() = false for merchant scope") } } + +func TestPaymentHistoryQueryUsesHoursForDay(t *testing.T) { + t.Parallel() + + query, capacity := paymentHistoryQuery("DAY") + if !strings.Contains(query, "local_hour") || !strings.Contains(query, "EXTRACT(HOUR") { + t.Fatalf("paymentHistoryQuery(DAY) does not group by local hour:\n%s", query) + } + if capacity != 24 { + t.Fatalf("paymentHistoryQuery(DAY) capacity = %d, want 24", capacity) + } +} + +func TestPaymentHistoryQueryKeepsDatesForLongerPeriods(t *testing.T) { + t.Parallel() + + query, capacity := paymentHistoryQuery("WEEK") + if strings.Contains(query, "EXTRACT(HOUR") { + t.Fatalf("paymentHistoryQuery(WEEK) unexpectedly groups by hour:\n%s", query) + } + if capacity != 93 { + t.Fatalf("paymentHistoryQuery(WEEK) capacity = %d, want 93", capacity) + } +}