feat: add terminal-scoped transaction analytics

This commit is contained in:
2026-08-20 20:56:15 +03:30
parent f710762b90
commit 857f29635e
8 changed files with 571 additions and 55 deletions

View File

@@ -40,11 +40,13 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/v1/auth/session", s.authenticate(http.HandlerFunc(s.currentSession)))
mux.Handle("DELETE /api/v1/auth/session", s.authenticate(s.csrf(http.HandlerFunc(s.logout))))
mux.Handle("GET /api/v1/analytics/periods", s.authenticate(http.HandlerFunc(s.periods)))
mux.Handle("GET /api/v1/analytics/terminals", s.authenticate(http.HandlerFunc(s.terminals)))
mux.Handle("POST /api/v1/analytics/runs", s.authenticate(s.csrf(http.HandlerFunc(s.startRun))))
mux.Handle("GET /api/v1/analytics/runs/{runId}", s.authenticate(http.HandlerFunc(s.getRun)))
mux.Handle("GET /api/v1/analytics/runs/{runId}/events", s.authenticate(http.HandlerFunc(s.events)))
mux.Handle("GET /api/v1/analytics/session-details", s.authenticate(http.HandlerFunc(s.details)))
mux.Handle("GET /api/v1/analytics/session-details.csv", s.authenticate(http.HandlerFunc(s.exportCSV)))
mux.Handle("GET /api/v1/analytics/sessions/{sessionId}", s.authenticate(http.HandlerFunc(s.sessionDetail)))
return s.boundary(mux)
}
@@ -148,12 +150,34 @@ func (s *Server) periods(w http.ResponseWriter, r *http.Request) {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
s.writeJSON(w, http.StatusOK, map[string]any{"defaultPeriodId": periods[0].PeriodID, "periods": periods})
if len(periods) == 0 {
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
defaultPeriodID := periods[0].PeriodID
for _, period := range periods {
if period.IsDefault {
defaultPeriodID = period.PeriodID
break
}
}
s.writeJSON(w, http.StatusOK, map[string]any{"defaultPeriodId": defaultPeriodID, "periods": periods})
}
func (s *Server) terminals(w http.ResponseWriter, r *http.Request) {
terminals, err := s.analytics.Terminals(r.Context(), sessionFrom(r.Context()).MerchantID)
if err != nil {
s.logger.Error("terminal discovery failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
s.writeJSON(w, http.StatusOK, map[string]any{"terminals": terminals})
}
func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
var input struct {
PeriodID string `json:"periodId"`
PeriodID string `json:"periodId"`
TerminalID string `json:"terminalId"`
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
@@ -180,7 +204,11 @@ func (s *Server) startRun(w http.ResponseWriter, r *http.Request) {
return
}
session := sessionFrom(r.Context())
run, err := s.analytics.Start(r.Context(), session.MerchantID, period)
run, err := s.analytics.Start(r.Context(), session.MerchantID, period, input.TerminalID)
if errors.Is(err, analytics.ErrUnknownTerminal) {
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown terminal")
return
}
if err != nil {
s.logger.Error("analytics calculation failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
@@ -229,7 +257,22 @@ func (s *Server) details(w http.ResponseWriter, r *http.Request) {
}
limit = parsed
}
filter := analytics.DetailFilter{View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"), TerminalID: r.URL.Query().Get("terminalId"), Cursor: r.URL.Query().Get("cursor"), Limit: limit}
minAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("minAmountRial"))
if !valid {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid minimum amount")
return
}
maxAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("maxAmountRial"))
if !valid {
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid maximum amount")
return
}
filter := analytics.DetailFilter{
View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"),
TerminalID: r.URL.Query().Get("terminalId"), Status: r.URL.Query().Get("status"),
MinAmountRial: minAmount, MaxAmountRial: maxAmount,
Cursor: r.URL.Query().Get("cursor"), Limit: limit,
}
items, next, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
if err != nil {
s.logger.Error("detail query failed", "error", err)
@@ -240,7 +283,24 @@ func (s *Server) details(w http.ResponseWriter, r *http.Request) {
if next != "" {
nextValue = next
}
s.writeJSON(w, 200, map[string]any{"period": period, "view": filter.View, "appliedFilters": map[string]any{"terminalId": nullable(filter.TerminalID), "failureStage": nullable(filter.FailureStage)}, "items": items, "pageInfo": map[string]any{"limit": limit, "hasNextPage": next != "", "nextCursor": nextValue}})
s.writeJSON(w, 200, map[string]any{"period": period, "view": filter.View, "appliedFilters": map[string]any{
"terminalId": nullable(filter.TerminalID), "failureStage": nullable(filter.FailureStage), "status": nullable(filter.Status),
"minAmountRial": filter.MinAmountRial, "maxAmountRial": filter.MaxAmountRial,
}, "items": items, "pageInfo": map[string]any{"limit": limit, "hasNextPage": next != "", "nextCursor": nextValue}})
}
func (s *Server) sessionDetail(w http.ResponseWriter, r *http.Request) {
detail, found, err := s.analytics.Session(r.Context(), sessionFrom(r.Context()).MerchantID, r.PathValue("sessionId"))
if err != nil {
s.logger.Error("session detail query failed", "error", err)
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
return
}
if !found {
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
return
}
s.writeJSON(w, 200, detail)
}
func (s *Server) exportCSV(w http.ResponseWriter, r *http.Request) {
@@ -294,6 +354,16 @@ func nullable(value string) any {
}
return value
}
func optionalNonNegativeInt64(value string) (*int64, bool) {
if value == "" {
return nil, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return nil, false
}
return &parsed, true
}
func formatTime(value *time.Time) string {
if value == nil {
return ""

View File

@@ -28,3 +28,35 @@ func TestPersianStatus(t *testing.T) {
t.Fatalf("persianStatus() = %q", got)
}
}
func TestOptionalNonNegativeInt64(t *testing.T) {
t.Parallel()
tests := []struct {
name, input string
want *int64
valid bool
}{
{name: "omitted", valid: true},
{name: "zero", input: "0", want: int64Pointer(0), valid: true},
{name: "positive", input: "125000", want: int64Pointer(125000), valid: true},
{name: "negative", input: "-1"},
{name: "not a number", input: "یک"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, valid := optionalNonNegativeInt64(tt.input)
if valid != tt.valid || !equalInt64Pointers(got, tt.want) {
t.Fatalf("optionalNonNegativeInt64(%q) = %v,%v, want %v,%v", tt.input, got, valid, tt.want, tt.valid)
}
})
}
}
func int64Pointer(value int64) *int64 { return &value }
func equalInt64Pointers(left, right *int64) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}