fix: deliver validated AI insight responses
This commit is contained in:
@@ -119,6 +119,16 @@ func nextJalaliMonth(jy, jm int) (int, int) {
|
||||
return jy, jm
|
||||
}
|
||||
|
||||
// JalaliMonthBounds returns the Tehran-local start and inclusive end of the
|
||||
// Jalali month containing at. It is shared by deterministic business rules.
|
||||
func JalaliMonthBounds(at time.Time, location *time.Location) (time.Time, time.Time) {
|
||||
local := at.In(location)
|
||||
year, month, _ := gregorianToJalali(local.Year(), int(local.Month()), local.Day())
|
||||
start := jalaliDate(year, month, 1, location)
|
||||
nextYear, nextMonth := nextJalaliMonth(year, month)
|
||||
return start, jalaliDate(nextYear, nextMonth, 1, location).AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
func completePeriods(datasetStart, datasetMax time.Time, location *time.Location) []Period {
|
||||
if datasetMax.Before(datasetStart) {
|
||||
return nil
|
||||
|
||||
@@ -49,6 +49,22 @@ type PaymentHistoryPoint struct {
|
||||
BankPaidAmountRial int64 `json:"bankPaidAmountRial"`
|
||||
}
|
||||
|
||||
type TransactionStatusDistribution struct {
|
||||
Status string `json:"status"`
|
||||
Count int64 `json:"count"`
|
||||
SharePercent *string `json:"sharePercent"`
|
||||
}
|
||||
|
||||
type TransactionStatusTrendPoint struct {
|
||||
LocalDate string `json:"localDate"`
|
||||
LocalHour *int `json:"localHour,omitempty"`
|
||||
PaidSessions int64 `json:"paidSessions"`
|
||||
VerifiedSessions int64 `json:"verifiedSessions"`
|
||||
FailedSessions int64 `json:"failedSessions"`
|
||||
ReversedSessions int64 `json:"reversedSessions"`
|
||||
TotalSessions int64 `json:"totalSessions"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
cursorSecret []byte
|
||||
@@ -91,7 +107,7 @@ func (s *Service) Start(ctx context.Context, merchantID int64, period Period, te
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
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,
|
||||
DatasetSnapshotID: "ds_localimport", MetricDefinitionVersion: "0.5.0", CreatedAt: now, UpdatedAt: now,
|
||||
Sections: map[string]any{}}
|
||||
if terminalID != "" {
|
||||
run.Scope.TerminalID = &terminalID
|
||||
@@ -105,10 +121,15 @@ func (s *Service) Start(ctx context.Context, merchantID int64, period Period, te
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
metadata := map[string]any{"calculatedAt": now, "datasetSnapshotId": run.DatasetSnapshotID, "metricDefinitionVersion": "0.4.0"}
|
||||
statusTrend, err := s.statusTrend(ctx, merchantID, period, terminalID)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
metadata := map[string]any{"calculatedAt": now, "datasetSnapshotId": run.DatasetSnapshotID, "metricDefinitionVersion": "0.5.0"}
|
||||
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)}
|
||||
run.Sections["transactions"] = map[string]any{"sectionId": "transactions", "state": "READY", "metadata": metadata, "data": metrics.transactions(statusTrend)}
|
||||
if !peerComparisonSupported(terminalID) {
|
||||
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "SUPPRESSED", "metadata": metadata,
|
||||
"reason": "TERMINAL_SCOPE_UNSUPPORTED", "eligiblePeerCount": 0, "combinedPeerSessionCount": 0,
|
||||
@@ -196,6 +217,23 @@ func decimalMetric(id string, numerator, denominator int64, unit string) map[str
|
||||
}
|
||||
return map[string]any{"metricId": id, "value": value, "unit": unit, "numerator": numerator, "denominator": denominator}
|
||||
}
|
||||
|
||||
func percentage(numerator, denominator int64) *string {
|
||||
if denominator == 0 {
|
||||
return nil
|
||||
}
|
||||
value := fmt.Sprintf("%.2f", float64(numerator)*100/float64(denominator))
|
||||
return &value
|
||||
}
|
||||
|
||||
func transactionStatusDistribution(m metricValues) []TransactionStatusDistribution {
|
||||
return []TransactionStatusDistribution{
|
||||
{Status: "Paid", Count: m.unverified, SharePercent: percentage(m.unverified, m.total)},
|
||||
{Status: "Verified", Count: m.verified, SharePercent: percentage(m.verified, m.total)},
|
||||
{Status: "Failed", Count: m.failed, SharePercent: percentage(m.failed, m.total)},
|
||||
{Status: "Reversed", Count: m.reversed, SharePercent: percentage(m.reversed, m.total)},
|
||||
}
|
||||
}
|
||||
func unavailableComparison(period Period) map[string]any {
|
||||
return map[string]any{"state": "UNAVAILABLE", "baselinePeriod": period, "absoluteChange": nil, "relativeChangePercent": nil, "unavailableReason": "SOURCE_UNAVAILABLE", "isActionableInsightEligible": false}
|
||||
}
|
||||
@@ -277,10 +315,86 @@ func (m metricValues) performance() map[string]any {
|
||||
"initTiming": timing("init_time_ms", m.initP50, m.initP95, m.initSamples, m.total), "verifyTiming": timing("verify_time_ms", m.verifyP50, m.verifyP95, m.verifySamples, m.paid),
|
||||
}
|
||||
}
|
||||
func (m metricValues) transactions(trend []TransactionStatusTrendPoint) map[string]any {
|
||||
return map[string]any{
|
||||
"totalSessions": integerMetric("total_sessions", m.total, "SESSIONS"),
|
||||
"bankSuccessRate": decimalMetric("bank_success_rate", m.paid, m.total, "PERCENT"),
|
||||
"verificationCompletionRate": decimalMetric("verification_completion_rate", m.verified, m.paid, "PERCENT"),
|
||||
"failureRate": decimalMetric("failure_rate", m.failed, m.total, "PERCENT"),
|
||||
"statusDistribution": transactionStatusDistribution(m),
|
||||
"statusTrend": trend,
|
||||
}
|
||||
}
|
||||
func (m metricValues) fees(period Period) map[string]any {
|
||||
return map[string]any{"paidFeeAmountRial": integerMetric("paid_fee_amount_rial", m.feeAmount, "RIAL"), "bankPaidAmountRial": integerMetric("bank_paid_amount_rial", m.paidAmount, "RIAL"), "paidFeeRate": decimalMetric("paid_fee_rate", m.feeAmount, m.paidAmount, "RATIO"), "comparison": unavailableComparison(period)}
|
||||
}
|
||||
|
||||
func (s *Service) statusTrend(ctx context.Context, merchantID int64, period Period, terminalID string) ([]TransactionStatusTrendPoint, error) {
|
||||
query, capacity := statusTrendQuery(period.PeriodType)
|
||||
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, terminalID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying transaction status trend: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
points := make([]TransactionStatusTrendPoint, 0, capacity)
|
||||
for rows.Next() {
|
||||
var localBucket time.Time
|
||||
var point TransactionStatusTrendPoint
|
||||
if err := rows.Scan(&localBucket, &point.PaidSessions, &point.VerifiedSessions, &point.FailedSessions, &point.ReversedSessions, &point.TotalSessions); err != nil {
|
||||
return nil, fmt.Errorf("scanning transaction status trend: %w", err)
|
||||
}
|
||||
point.LocalDate = localBucket.Format(time.DateOnly)
|
||||
if period.PeriodType == "DAY" {
|
||||
hour := localBucket.Hour()
|
||||
point.LocalHour = &hour
|
||||
}
|
||||
points = append(points, point)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating transaction status trend: %w", err)
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func statusTrendQuery(periodType string) (string, int) {
|
||||
const counts = `count(s.status) FILTER (WHERE s.status='Paid'),
|
||||
count(s.status) FILTER (WHERE s.status='Verified'),
|
||||
count(s.status) FILTER (WHERE s.status='Failed'),
|
||||
count(s.status) FILTER (WHERE s.status='Reversed'),count(s.status)`
|
||||
const scope = `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))`
|
||||
|
||||
if periodType == "DAY" {
|
||||
return `WITH buckets AS (
|
||||
SELECT generate_series($2::timestamptz AT TIME ZONE 'Asia/Tehran',($3::timestamptz AT TIME ZONE 'Asia/Tehran')-interval '1 hour',interval '1 hour') bucket_start
|
||||
), scoped AS (
|
||||
SELECT date_trunc('hour',ps.created_at AT TIME ZONE 'Asia/Tehran') bucket_start,ps.status ` + scope + `
|
||||
)
|
||||
SELECT b.bucket_start,` + counts + `
|
||||
FROM buckets b LEFT JOIN scoped s USING(bucket_start) GROUP BY b.bucket_start ORDER BY b.bucket_start`, 24
|
||||
}
|
||||
if periodType == "PERSIAN_SEASON" {
|
||||
return `WITH bounds AS (
|
||||
SELECT date_trunc('week',($2::timestamptz AT TIME ZONE 'Asia/Tehran')+interval '2 days')-interval '2 days' bucket_start,
|
||||
date_trunc('week',(($3::timestamptz-interval '1 microsecond') AT TIME ZONE 'Asia/Tehran')+interval '2 days')-interval '2 days' bucket_end
|
||||
), buckets AS (
|
||||
SELECT generate_series(bucket_start,bucket_end,interval '7 days') bucket_start FROM bounds
|
||||
), scoped AS (
|
||||
SELECT date_trunc('week',(ps.created_at AT TIME ZONE 'Asia/Tehran')+interval '2 days')-interval '2 days' bucket_start,ps.status ` + scope + `
|
||||
)
|
||||
SELECT b.bucket_start,` + counts + `
|
||||
FROM buckets b LEFT JOIN scoped s USING(bucket_start) GROUP BY b.bucket_start ORDER BY b.bucket_start`, 15
|
||||
}
|
||||
return `WITH buckets AS (
|
||||
SELECT generate_series($2::timestamptz AT TIME ZONE 'Asia/Tehran',($3::timestamptz AT TIME ZONE 'Asia/Tehran')-interval '1 day',interval '1 day') bucket_start
|
||||
), scoped AS (
|
||||
SELECT date_trunc('day',ps.created_at AT TIME ZONE 'Asia/Tehran') bucket_start,ps.status ` + scope + `
|
||||
)
|
||||
SELECT b.bucket_start,` + counts + `
|
||||
FROM buckets b LEFT JOIN scoped s USING(bucket_start) GROUP BY b.bucket_start ORDER BY b.bucket_start`, map[string]int{"WEEK": 7, "JALALI_MONTH": 31}[periodType]
|
||||
}
|
||||
|
||||
func (s *Service) peerEligibility(ctx context.Context, merchantID int64, p Period) (int64, int64, error) {
|
||||
var peers, sessions int64
|
||||
err := s.pool.QueryRow(ctx, `SELECT count(distinct ps.merchant_id),count(*) FROM payment_sessions ps JOIN merchants m ON m.id=ps.merchant_id
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -38,3 +39,61 @@ func TestPaymentHistoryQueryKeepsDatesForLongerPeriods(t *testing.T) {
|
||||
t.Fatalf("paymentHistoryQuery(WEEK) capacity = %d, want 93", capacity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionStatusDistributionUsesCanonicalOrderAndShares(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metrics := metricValues{total: 20, unverified: 3, verified: 12, failed: 4, reversed: 1}
|
||||
got := transactionStatusDistribution(metrics)
|
||||
want := []TransactionStatusDistribution{
|
||||
{Status: "Paid", Count: 3, SharePercent: stringPointer("15.00")},
|
||||
{Status: "Verified", Count: 12, SharePercent: stringPointer("60.00")},
|
||||
{Status: "Failed", Count: 4, SharePercent: stringPointer("20.00")},
|
||||
{Status: "Reversed", Count: 1, SharePercent: stringPointer("5.00")},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("transactionStatusDistribution() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransactionStatusDistributionHasUnavailableSharesWithoutSessions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, item := range transactionStatusDistribution(metricValues{}) {
|
||||
if item.SharePercent != nil {
|
||||
t.Fatalf("share for %s = %q, want nil", item.Status, *item.SharePercent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusTrendQueryUsesReportingGranularity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
periodType string
|
||||
wantStep string
|
||||
wantBucket string
|
||||
capacity int
|
||||
}{
|
||||
{name: "day uses hours", periodType: "DAY", wantStep: "interval '1 hour'", wantBucket: "date_trunc('hour'", capacity: 24},
|
||||
{name: "week uses days", periodType: "WEEK", wantStep: "interval '1 day'", wantBucket: "date_trunc('day'", capacity: 7},
|
||||
{name: "month uses days", periodType: "JALALI_MONTH", wantStep: "interval '1 day'", wantBucket: "date_trunc('day'", capacity: 31},
|
||||
{name: "season uses iranian weeks", periodType: "PERSIAN_SEASON", wantStep: "interval '7 days'", wantBucket: "interval '2 days'", capacity: 15},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
query, capacity := statusTrendQuery(tt.periodType)
|
||||
if !strings.Contains(query, tt.wantStep) || !strings.Contains(query, tt.wantBucket) {
|
||||
t.Fatalf("statusTrendQuery(%s) has unexpected granularity:\n%s", tt.periodType, query)
|
||||
}
|
||||
if capacity != tt.capacity {
|
||||
t.Fatalf("statusTrendQuery(%s) capacity = %d, want %d", tt.periodType, capacity, tt.capacity)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string { return &value }
|
||||
|
||||
Reference in New Issue
Block a user