feat: aggregate daily payment history by hour
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user