feat: aggregate daily payment history by hour

This commit is contained in:
2026-08-21 16:28:13 +08:00
parent 927319807b
commit 7140213857
4 changed files with 64 additions and 11 deletions

View File

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

View File

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

View File

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

View File

@@ -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)
}
}