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
|
## 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.
|
- `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.
|
- Overview, payment-performance, fees, and 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.
|
- 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.
|
- 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
|
## Transaction evidence
|
||||||
|
|||||||
@@ -1639,6 +1639,11 @@ components:
|
|||||||
localDate:
|
localDate:
|
||||||
type: string
|
type: string
|
||||||
format: date
|
format: date
|
||||||
|
localHour:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
maximum: 23
|
||||||
|
description: Tehran-local hour bucket, present only for DAY periods.
|
||||||
totalSessions:
|
totalSessions:
|
||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type RunScope struct {
|
|||||||
|
|
||||||
type PaymentHistoryPoint struct {
|
type PaymentHistoryPoint struct {
|
||||||
LocalDate string `json:"localDate"`
|
LocalDate string `json:"localDate"`
|
||||||
|
LocalHour *int `json:"localHour,omitempty"`
|
||||||
TotalSessions int64 `json:"totalSessions"`
|
TotalSessions int64 `json:"totalSessions"`
|
||||||
BankPaidSessions int64 `json:"bankPaidSessions"`
|
BankPaidSessions int64 `json:"bankPaidSessions"`
|
||||||
BankPaidAmountRial int64 `json:"bankPaidAmountRial"`
|
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) {
|
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,
|
query, capacity := paymentHistoryQuery(period.PeriodType)
|
||||||
count(*),count(*) FILTER (WHERE ps.status IN ('Paid','Verified')),
|
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, terminalID)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying payment history: %w", err)
|
return nil, fmt.Errorf("querying payment history: %w", err)
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
points := make([]PaymentHistoryPoint, 0, 93)
|
points := make([]PaymentHistoryPoint, 0, capacity)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var date time.Time
|
var date time.Time
|
||||||
|
var localHour *int16
|
||||||
var point PaymentHistoryPoint
|
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)
|
return nil, fmt.Errorf("scanning payment history: %w", err)
|
||||||
}
|
}
|
||||||
point.LocalDate = date.Format(time.DateOnly)
|
point.LocalDate = date.Format(time.DateOnly)
|
||||||
|
if localHour != nil {
|
||||||
|
hour := int(*localHour)
|
||||||
|
point.LocalHour = &hour
|
||||||
|
}
|
||||||
points = append(points, point)
|
points = append(points, point)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -234,6 +236,25 @@ func (s *Service) paymentHistory(ctx context.Context, merchantID int64, period P
|
|||||||
}
|
}
|
||||||
return points, nil
|
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 {
|
func timing(id string, p50, p95 *float64, samples, eligible int64) map[string]any {
|
||||||
var a, b any
|
var a, b any
|
||||||
if p50 != nil {
|
if p50 != nil {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package analytics
|
package analytics
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestPeerComparisonSupported(t *testing.T) {
|
func TestPeerComparisonSupported(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -11,3 +14,27 @@ func TestPeerComparisonSupported(t *testing.T) {
|
|||||||
t.Fatal("peerComparisonSupported() = false for merchant scope")
|
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