555 lines
20 KiB
Go
555 lines
20 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/csv"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
|
|
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/identity"
|
|
)
|
|
|
|
const maxExportBytes = 50 << 20
|
|
|
|
var errExportBytesExceeded = errors.New("export exceeds byte limit")
|
|
|
|
type Server struct {
|
|
identity *identity.Service
|
|
analytics *analytics.Service
|
|
growth growthReportService
|
|
logger *slog.Logger
|
|
frontendOrigin string
|
|
cookieSecure bool
|
|
}
|
|
|
|
type sessionKey struct{}
|
|
type tokenKey struct{}
|
|
|
|
func NewServer(identityService *identity.Service, analyticsService *analytics.Service, logger *slog.Logger, frontendOrigin string, cookieSecure bool, growthService ...growthReportService) *Server {
|
|
server := &Server{identity: identityService, analytics: analyticsService, logger: logger, frontendOrigin: frontendOrigin, cookieSecure: cookieSecure}
|
|
if len(growthService) > 0 {
|
|
server.growth = growthService[0]
|
|
}
|
|
return server
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /healthz", s.health)
|
|
mux.HandleFunc("POST /api/v1/auth/sessions", s.login)
|
|
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)))
|
|
mux.Handle("POST /api/v1/analytics/growth-reports", s.authenticate(s.csrf(http.HandlerFunc(s.createGrowthReport))))
|
|
mux.Handle("GET /api/v1/analytics/growth-reports/latest", s.authenticate(http.HandlerFunc(s.latestGrowthReport)))
|
|
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}", s.authenticate(http.HandlerFunc(s.getGrowthReport)))
|
|
mux.Handle("GET /api/v1/analytics/growth-reports/{reportId}/events", s.authenticate(http.HandlerFunc(s.growthReportEvents)))
|
|
return s.boundary(mux)
|
|
}
|
|
|
|
func (s *Server) boundary(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestID := r.Header.Get("X-Request-ID")
|
|
if requestID == "" {
|
|
requestID = randomID()
|
|
}
|
|
w.Header().Set("X-Request-ID", requestID)
|
|
w.Header().Set("Cache-Control", "private, no-store")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) authenticate(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cookie, err := r.Cookie(s.cookieName())
|
|
if err != nil {
|
|
s.problem(w, r, http.StatusUnauthorized, "UNAUTHENTICATED", "Authentication required")
|
|
return
|
|
}
|
|
session, err := s.identity.Resolve(r.Context(), cookie.Value)
|
|
if errors.Is(err, identity.ErrUnauthenticated) {
|
|
s.problem(w, r, http.StatusUnauthorized, "UNAUTHENTICATED", "Authentication required")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.logger.Error("session resolution failed", "error", err)
|
|
s.problem(w, r, http.StatusServiceUnavailable, "DEPENDENCY_UNAVAILABLE", "Service unavailable")
|
|
return
|
|
}
|
|
ctx := context.WithValue(r.Context(), sessionKey{}, session)
|
|
ctx = context.WithValue(ctx, tokenKey{}, cookie.Value)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
func (s *Server) csrf(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
session := sessionFrom(r.Context())
|
|
origin := r.Header.Get("Origin")
|
|
if r.Header.Get("X-CSRF-Token") != session.CSRFToken || (origin != "" && origin != s.frontendOrigin) {
|
|
s.problem(w, r, http.StatusForbidden, "FORBIDDEN", "Request could not be verified")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|
if origin := r.Header.Get("Origin"); origin != "" && origin != s.frontendOrigin {
|
|
s.problem(w, r, http.StatusForbidden, "FORBIDDEN", "Request could not be verified")
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
|
var input struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
decoder := json.NewDecoder(r.Body)
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil || input.Username == "" || input.Password == "" {
|
|
s.problem(w, r, http.StatusUnprocessableEntity, "VALIDATION_FAILED", "Invalid login request")
|
|
return
|
|
}
|
|
session, token, err := s.identity.Login(r.Context(), input.Username, input.Password)
|
|
if errors.Is(err, identity.ErrUnauthenticated) {
|
|
s.problem(w, r, http.StatusUnauthorized, "UNAUTHENTICATED", "Invalid username or password")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.logger.Error("login failed", "error", err)
|
|
s.problem(w, r, http.StatusServiceUnavailable, "DEPENDENCY_UNAVAILABLE", "Service unavailable")
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: s.cookieName(), Value: token, Path: "/", HttpOnly: true, Secure: s.cookieSecure, SameSite: http.SameSiteLaxMode, Expires: session.AbsoluteExpiresAt})
|
|
s.writeJSON(w, http.StatusCreated, sessionResponse(session))
|
|
}
|
|
|
|
func (s *Server) currentSession(w http.ResponseWriter, r *http.Request) {
|
|
s.writeJSON(w, http.StatusOK, sessionResponse(sessionFrom(r.Context())))
|
|
}
|
|
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.identity.Logout(r.Context(), r.Context().Value(tokenKey{}).(string)); err != nil {
|
|
s.logger.Error("logout failed", "error", err)
|
|
s.problem(w, r, http.StatusServiceUnavailable, "DEPENDENCY_UNAVAILABLE", "Service unavailable")
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{Name: s.cookieName(), Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) periods(w http.ResponseWriter, r *http.Request) {
|
|
periods, err := s.analytics.Periods(r.Context())
|
|
if err != nil {
|
|
s.logger.Error("period discovery failed", "error", err)
|
|
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
|
return
|
|
}
|
|
periods, err = filterPeriodsByType(periods, r.URL.Query().Get("periodType"))
|
|
if err != nil {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid reporting period type")
|
|
return
|
|
}
|
|
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"`
|
|
TerminalID string `json:"terminalId"`
|
|
}
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&input); err != nil {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid analytics request")
|
|
return
|
|
}
|
|
periods, err := s.analytics.Periods(r.Context())
|
|
if err != nil {
|
|
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
|
return
|
|
}
|
|
var period analytics.Period
|
|
found := false
|
|
for _, candidate := range periods {
|
|
if candidate.PeriodID == input.PeriodID {
|
|
period = candidate
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
|
return
|
|
}
|
|
session := sessionFrom(r.Context())
|
|
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")
|
|
return
|
|
}
|
|
w.Header().Set("Location", "/api/v1/analytics/runs/"+run.RunID)
|
|
s.writeJSON(w, http.StatusOK, run)
|
|
}
|
|
|
|
func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
|
|
run, ok := s.analytics.Run(sessionFrom(r.Context()).MerchantID, r.PathValue("runId"))
|
|
if !ok {
|
|
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
|
return
|
|
}
|
|
s.writeJSON(w, 200, run)
|
|
}
|
|
|
|
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
|
run, ok := s.analytics.Run(sessionFrom(r.Context()).MerchantID, r.PathValue("runId"))
|
|
if !ok {
|
|
s.problem(w, r, 404, "RESOURCE_NOT_FOUND", "Resource not found")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
eventID := "evt_" + randomID()
|
|
data := map[string]any{"eventId": eventID, "runId": run.RunID, "occurredAt": time.Now().UTC(), "recovery": "RESET_TO_SNAPSHOT", "run": run}
|
|
payload, _ := json.Marshal(data)
|
|
fmt.Fprintf(w, "id: %s\nevent: run.snapshot\ndata: %s\n\n", eventID, payload)
|
|
}
|
|
|
|
func (s *Server) details(w http.ResponseWriter, r *http.Request) {
|
|
period, ok := s.requestPeriod(r)
|
|
if !ok {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
|
return
|
|
}
|
|
limit := 25
|
|
if raw := r.URL.Query().Get("limit"); raw != "" {
|
|
parsed, err := strconv.Atoi(raw)
|
|
if err != nil || parsed < 1 || parsed > 100 {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid page limit")
|
|
return
|
|
}
|
|
limit = parsed
|
|
}
|
|
filter, err := detailFilterFromRequest(r, limit)
|
|
if err != nil {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid detail request")
|
|
return
|
|
}
|
|
items, next, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
|
|
if errors.Is(err, analytics.ErrInvalidCursor) {
|
|
s.problem(w, r, 400, "INVALID_CURSOR", "Invalid detail cursor")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.logger.Error("detail query failed", "error", err)
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid detail request")
|
|
return
|
|
}
|
|
var nextValue any
|
|
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), "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) {
|
|
period, ok := s.requestPeriod(r)
|
|
if !ok {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
|
return
|
|
}
|
|
filter, err := detailFilterFromRequest(r, 100)
|
|
if err != nil {
|
|
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid export request")
|
|
return
|
|
}
|
|
exportContext, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
|
defer cancel()
|
|
items, err := s.analytics.ExportDetails(exportContext, sessionFrom(r.Context()).MerchantID, period, filter)
|
|
if errors.Is(err, analytics.ErrExportTooLarge) {
|
|
s.problem(w, r, 413, "EXPORT_TOO_LARGE", "Export exceeds the allowed row limit")
|
|
return
|
|
}
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
s.problem(w, r, 504, "EXPORT_TIMEOUT", "Export timed out")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.logger.Error("session export failed", "error", err)
|
|
s.problem(w, r, 503, "ANALYTICS_UNAVAILABLE", "Analytics unavailable")
|
|
return
|
|
}
|
|
encoded, err := renderExportCSV(items, maxExportBytes)
|
|
if errors.Is(err, errExportBytesExceeded) {
|
|
s.problem(w, r, 413, "EXPORT_TOO_LARGE", "Export exceeds the allowed byte limit")
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.logger.Error("session export encoding failed", "error", err)
|
|
s.problem(w, r, 500, "INTERNAL_ERROR", "Export could not be encoded")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
filename := fmt.Sprintf("%s-%s-%s.csv", strings.ToLower(filter.View), period.PeriodID, time.Now().UTC().Format("20060102T150405Z"))
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q; filename*=UTF-8''%s", filename, filename))
|
|
if _, err := w.Write(encoded); err != nil {
|
|
s.logger.Warn("session export response interrupted", "error", err)
|
|
}
|
|
}
|
|
|
|
func renderExportCSV(items []analytics.ExportDetail, maxBytes int64) ([]byte, error) {
|
|
var buffer bytes.Buffer
|
|
limited := &limitedWriter{writer: &buffer, remaining: maxBytes}
|
|
if _, err := limited.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
|
return nil, err
|
|
}
|
|
writer := csv.NewWriter(limited)
|
|
writer.UseCRLF = true
|
|
if err := writer.Write([]string{"شناسه نشست پرداخت", "شناسه پایانه", "وضعیت پرداخت", "مبلغ ریال", "کارمزد ریال", "زمان ایجاد", "زمان انقضا", "تعداد تلاش", "وضعیت آخرین تلاش", "زمان آخرین تلاش", "زمان تأیید", "زمان تسویه", "مرحله مشاهده شده شکست"}); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, item := range items {
|
|
lastStatus, lastAt := "", ""
|
|
if item.LastAttemptStatus != nil {
|
|
lastStatus = persianStatus(*item.LastAttemptStatus)
|
|
}
|
|
if item.LastAttemptAt != nil {
|
|
lastAt = item.LastAttemptAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if err := writer.Write([]string{spreadsheetSafeText(item.SessionID), spreadsheetSafeText(item.TerminalID), spreadsheetSafeText(persianStatus(item.SessionStatus)), strconv.FormatInt(item.AmountRial, 10), strconv.FormatInt(item.FeeAmountRial, 10), item.CreatedAt.UTC().Format(time.RFC3339), formatTime(item.ExpiresAt), strconv.Itoa(item.AttemptCount), spreadsheetSafeText(lastStatus), lastAt, formatTime(item.VerifiedAt), formatTime(item.SettledAt), spreadsheetSafeText(stringValue(item.FailureStage))}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
writer.Flush()
|
|
if err := writer.Error(); err != nil {
|
|
return nil, err
|
|
}
|
|
return buffer.Bytes(), nil
|
|
}
|
|
|
|
type limitedWriter struct {
|
|
writer io.Writer
|
|
remaining int64
|
|
}
|
|
|
|
func (w *limitedWriter) Write(data []byte) (int, error) {
|
|
if int64(len(data)) > w.remaining {
|
|
return 0, errExportBytesExceeded
|
|
}
|
|
written, err := w.writer.Write(data)
|
|
w.remaining -= int64(written)
|
|
return written, err
|
|
}
|
|
|
|
func spreadsheetSafeText(value string) string {
|
|
trimmed := strings.TrimLeftFunc(value, unicode.IsSpace)
|
|
if value == "" || trimmed == "" {
|
|
return value
|
|
}
|
|
first := trimmed[0]
|
|
if first == '=' || first == '+' || first == '-' || first == '@' || value[0] == '\t' || value[0] == '\r' || value[0] == '\n' {
|
|
return "'" + value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func (s *Server) requestPeriod(r *http.Request) (analytics.Period, bool) {
|
|
periods, err := s.analytics.Periods(r.Context())
|
|
if err != nil {
|
|
return analytics.Period{}, false
|
|
}
|
|
id := r.URL.Query().Get("periodId")
|
|
for _, p := range periods {
|
|
if p.PeriodID == id {
|
|
return p, true
|
|
}
|
|
}
|
|
return analytics.Period{}, false
|
|
}
|
|
func nullable(value string) any {
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
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 detailFilterFromRequest(r *http.Request, limit int) (analytics.DetailFilter, error) {
|
|
minimum, valid := optionalNonNegativeInt64(r.URL.Query().Get("minAmountRial"))
|
|
if !valid {
|
|
return analytics.DetailFilter{}, errors.New("invalid minimum amount")
|
|
}
|
|
maximum, valid := optionalNonNegativeInt64(r.URL.Query().Get("maxAmountRial"))
|
|
if !valid {
|
|
return analytics.DetailFilter{}, errors.New("invalid maximum amount")
|
|
}
|
|
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: minimum, MaxAmountRial: maximum, Cursor: r.URL.Query().Get("cursor"), Limit: limit,
|
|
}
|
|
if err := filter.Validate(); err != nil {
|
|
return analytics.DetailFilter{}, err
|
|
}
|
|
return filter, nil
|
|
}
|
|
|
|
func filterPeriodsByType(periods []analytics.Period, requested string) ([]analytics.Period, error) {
|
|
if requested == "" {
|
|
return periods, nil
|
|
}
|
|
allowed := map[string]bool{"DAY": true, "WEEK": true, "JALALI_MONTH": true, "PERSIAN_SEASON": true}
|
|
if !allowed[requested] {
|
|
return nil, errors.New("invalid reporting period type")
|
|
}
|
|
filtered := make([]analytics.Period, 0, 1)
|
|
for _, period := range periods {
|
|
if period.PeriodType == requested {
|
|
period.IsDefault = len(filtered) == 0
|
|
filtered = append(filtered, period)
|
|
}
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func formatTime(value *time.Time) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return value.UTC().Format(time.RFC3339)
|
|
}
|
|
func stringValue(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
func persianStatus(value string) string {
|
|
switch value {
|
|
case "Paid":
|
|
return "پرداخت شده و تأیید نشده"
|
|
case "Verified":
|
|
return "تأیید شده"
|
|
case "Failed":
|
|
return "ناموفق"
|
|
case "Reversed":
|
|
return "برگشت خورده"
|
|
case "InBank":
|
|
return "در بانک"
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
|
s.writeJSON(w, 200, map[string]string{"status": "ok"})
|
|
}
|
|
func (s *Server) cookieName() string {
|
|
if s.cookieSecure {
|
|
return "__Host-zpa_session"
|
|
}
|
|
return "zpa_session"
|
|
}
|
|
func sessionFrom(ctx context.Context) identity.Session {
|
|
return ctx.Value(sessionKey{}).(identity.Session)
|
|
}
|
|
func sessionResponse(v identity.Session) map[string]any {
|
|
return map[string]any{"user": map[string]any{"userId": fmt.Sprintf("user_%d", v.UserID), "username": v.Username}, "merchant": map[string]any{"merchantId": fmt.Sprintf("merchant_%d", v.MerchantID), "displayName": v.MerchantDisplayName, "profileIsSynthetic": v.ProfileIsSynthetic}, "createdAt": v.CreatedAt, "idleExpiresAt": v.IdleExpiresAt, "absoluteExpiresAt": v.AbsoluteExpiresAt, "csrfToken": v.CSRFToken}
|
|
}
|
|
func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
if err := json.NewEncoder(w).Encode(value); err != nil {
|
|
s.logger.Error("response encoding failed", "error", err)
|
|
}
|
|
}
|
|
func (s *Server) problem(w http.ResponseWriter, r *http.Request, status int, code, detail string) {
|
|
w.Header().Set("Content-Type", "application/problem+json")
|
|
w.WriteHeader(status)
|
|
value := map[string]any{"type": "https://zarinpal.amiirkhl.ir/docs/errors/" + strings.ToLower(strings.ReplaceAll(code, "_", "-")), "title": http.StatusText(status), "status": status, "code": code, "detail": detail, "instance": r.URL.Path, "requestId": w.Header().Get("X-Request-ID")}
|
|
if err := json.NewEncoder(w).Encode(value); err != nil {
|
|
s.logger.Error("problem encoding failed", "error", err)
|
|
}
|
|
}
|
|
func randomID() string {
|
|
data := make([]byte, 12)
|
|
if _, err := rand.Read(data); err != nil {
|
|
return fmt.Sprint(time.Now().UnixNano())
|
|
}
|
|
return hex.EncodeToString(data)
|
|
}
|