fix: harden terminal-scoped analytics contracts
Keep JSON and bounded CSV exports on one canonical predicate, replace replayable offsets with encrypted tenant/filter-bound keyset cursors, and suppress merchant-grain peers under terminal scope. Validate period filters and align OpenAPI and capability specs with runtime behavior.
This commit is contained in:
@@ -8,5 +8,6 @@ IMPORT_PATH=data/incoming/challenge_data.csv.gz
|
||||
DEMO_USERNAME=merchant-0001
|
||||
DEMO_PASSWORD=replace-with-at-least-12-characters
|
||||
SESSION_SECRET=replace-with-at-least-32-random-characters
|
||||
CURSOR_SECRET=replace-with-a-different-32-character-secret
|
||||
FRONTEND_ORIGIN=http://127.0.0.1:5173
|
||||
COOKIE_SECURE=false
|
||||
|
||||
@@ -35,7 +35,7 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
handler := api.NewServer(identity.New(pool), analytics.New(pool), logger, cfg.FrontendOrigin, cfg.CookieSecure).Handler()
|
||||
handler := api.NewServer(identity.New(pool), analytics.New(pool, cfg.CursorSecret), logger, cfg.FrontendOrigin, cfg.CookieSecure).Handler()
|
||||
server := &http.Server{Addr: cfg.HTTPAddr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 65 * time.Second, IdleTimeout: 60 * time.Second}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -31,6 +31,8 @@ Turn normalized ZarinPal payment facts into trustworthy, merchant-scoped analyti
|
||||
- Lazy, bounded parallel calculation with partial SSE delivery
|
||||
- Versioned 30-day Redis cache keyed by the immutable MVP dataset snapshot
|
||||
- Traceability from aggregate metrics to permitted merchant-owned evidence
|
||||
- Merchant-wide or authenticated merchant-terminal scope for deterministic merchant metrics
|
||||
- Bounded daily payment-history points and filtered read-only transaction evidence
|
||||
|
||||
## Out of scope for MVP
|
||||
|
||||
@@ -72,6 +74,22 @@ Every section uses a versioned typed contract and has independent `queued`, `cal
|
||||
- Return the cohort median and active merchant's performance percentile. Higher displayed percentile always means better, so processing-time and fee-rate ranking is inverted.
|
||||
- Benchmark payment success, verification completion, retry recovery, init/verify p50/p95, and paid fee rate.
|
||||
- Never return peer identities, individual values/ranks, rows, or arbitrary filters that enable differencing attacks.
|
||||
- Peer comparison remains merchant-grain. A terminal-scoped run suppresses this section with `TERMINAL_SCOPE_UNSUPPORTED` instead of presenting merchant-wide peer values as terminal-scoped evidence.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
- The `ALL`, `UNVERIFIED_PAID`, `FAILED`, and `RETRIED` views are scoped by authenticated merchant and approved period.
|
||||
- Optional terminal, status, failure-stage, and non-negative amount bounds are allowlisted and parameterized. Failure stage applies only to `FAILED`.
|
||||
- Keyset cursors are authenticated-encrypted, bound to merchant/period/canonical filters, and expire after 24 hours; replay under another binding is rejected.
|
||||
- One-session detail resolves the opaque session id under the merchant and returns ordered attempts without payer-card keys or response-code fields.
|
||||
- JSON and CSV reuse the same canonical predicate. CSV omits cursor/page limit, is capped at 100,000 rows, and applies a 60-second deadline.
|
||||
|
||||
## Calculation and cache lifecycle
|
||||
|
||||
@@ -135,8 +153,8 @@ Every section uses a versioned typed contract and has independent `queued`, `cal
|
||||
- Redis flush causes correct cold-cache rebuilding without source-data loss.
|
||||
- Timeouts, transient retry limits/backoff, failed-section isolation, manual retry, rate limits, heartbeat, replay, and snapshot fallback behave as specified.
|
||||
- Peer results exclude the active merchant, use equal merchant weighting, obey both suppression thresholds, and never expose individual peers.
|
||||
- Tenant-bound drill-downs reconcile to aggregates and cannot expose another merchant through parameter manipulation.
|
||||
- Read-only drill-downs cover unverified-paid, failed, and retried sessions. Failed-session filters may distinguish zero-attempt and reached-bank stages without exposing payer tokens or claiming undocumented causes.
|
||||
- Tenant-bound drill-downs reconcile to aggregates and cannot expose another merchant through parameter manipulation or cursor replay.
|
||||
- Read-only drill-downs cover all, unverified-paid, failed, and retried sessions. Failed-session filters may distinguish zero-attempt and reached-bank stages without exposing payer tokens or claiming undocumented causes.
|
||||
- No raw payer token, credential, cross-tenant row, or AvalAI secret enters results, logs, cache payloads, or browser data.
|
||||
|
||||
## Boundaries
|
||||
@@ -156,7 +174,8 @@ Every section uses a versioned typed contract and has independent `queued`, `cal
|
||||
- Default first view is the latest complete Jalali month containing source data, avoiding an empty current month in the static demo.
|
||||
- Compare with the immediately previous equivalent complete period. If it is empty or unavailable, return an explicit unavailable comparison rather than silently substituting another baseline.
|
||||
- Overview priority is bank-paid amount, bank-paid session count, payment success rate, then verification completion. Retry, timing, and fee analysis follow below.
|
||||
- MVP read-only drill-downs cover unverified-paid, failed, and retried sessions; payer tokens and peer-level rows are excluded.
|
||||
- MVP read-only drill-downs cover all, unverified-paid, failed, and retried sessions; payer tokens and peer-level rows are excluded.
|
||||
- Terminal-scoped merchant metrics are supported, while merchant-grain peer comparison is explicitly suppressed for terminal runs.
|
||||
- Actionable insight cards require at least 100 relevant sessions and either a 3-percentage-point rate change or a 20-percent amount/count change. Below-threshold comparisons remain visible as metrics but do not generate an action claim.
|
||||
|
||||
## Open product decisions before implementation planning
|
||||
|
||||
@@ -58,11 +58,13 @@ Every request, query parameter, cursor, run id, event id, and export request is
|
||||
| `GET` | `/api/v1/auth/session` | Return the current merchant-user session summary |
|
||||
| `DELETE` | `/api/v1/auth/session` | Revoke the current session and clear its cookie |
|
||||
| `GET` | `/api/v1/analytics/periods` | List approved period choices and the default period |
|
||||
| `GET` | `/api/v1/analytics/terminals` | List terminals owned by the authenticated merchant |
|
||||
| `POST` | `/api/v1/analytics/runs` | Idempotently create or return the current run |
|
||||
| `GET` | `/api/v1/analytics/runs/{runId}` | Return the current run/section snapshot |
|
||||
| `GET` | `/api/v1/analytics/runs/{runId}/events` | Stream authorized SSE run events |
|
||||
| `POST` | `/api/v1/analytics/runs/{runId}/sections/{sectionId}/retry-attempts` | Retry one exhausted failed section |
|
||||
| `GET` | `/api/v1/analytics/session-details` | List authorized merchant-owned session evidence |
|
||||
| `GET` | `/api/v1/analytics/sessions/{sessionId}` | Return one merchant-owned session and ordered attempts |
|
||||
| `GET` | `/api/v1/analytics/session-details.csv` | Export the same authorized detail view as CSV |
|
||||
|
||||
The request/response schemas, required headers, status codes, examples, and stable `operationId` values are defined in `docs/openapi.yaml`.
|
||||
@@ -77,11 +79,11 @@ Clients select one allowlisted `periodId` returned by `GET /analytics/periods`;
|
||||
- `timezone: Asia/Tehran` and `jalaliLabelFa`, ready for display;
|
||||
- `isComplete` and `isDefault` flags.
|
||||
|
||||
The default is the latest complete Jalali month containing source data. Weekly periods start Saturday at `00:00` and end at the following Saturday `00:00` in Tehran. The server rejects stale or fabricated period ids instead of accepting client-computed dates.
|
||||
The default is the latest complete Jalali month containing source data. Weekly periods start Saturday at `00:00` and end at the following Saturday `00:00` in Tehran. The optional `periodType` query is allowlisted and filters the returned descriptors. The server rejects stale or fabricated period ids and invalid period types instead of accepting client-computed dates.
|
||||
|
||||
## Analytics-run contract
|
||||
|
||||
`POST /analytics/runs` accepts `periodId` and an `Idempotency-Key` representing one dashboard-start intent. The server binds the key to the authenticated user, merchant, canonical request, immutable dataset snapshot, and metric-definition version.
|
||||
`POST /analytics/runs` accepts `periodId`, an optional merchant-owned `terminalId`, and an `Idempotency-Key` representing one dashboard-start intent. An omitted terminal selects the whole merchant. The server resolves a supplied terminal under the session merchant before calculation and binds the key to the authenticated user, merchant, canonical request, immutable dataset snapshot, and metric-definition version.
|
||||
|
||||
- The same key and same canonical request replays the original status/body without duplicate jobs.
|
||||
- The same key with a different request returns `422 IDEMPOTENCY_KEY_REUSED`.
|
||||
@@ -89,7 +91,7 @@ The default is the latest complete Jalali month containing source data. Weekly p
|
||||
- An in-flight duplicate returns the current `202` run snapshot rather than starting another effect.
|
||||
- Intent records outlive the one-hour event replay window and the maximum job retry chain; the concrete retention value is an implementation parameter that must be documented before implementation.
|
||||
|
||||
A run snapshot identifies the selected period, immutable dataset snapshot, metric-definition version, creation/update times, overall state, and all four sections. Section states are `QUEUED`, `CALCULATING`, `READY`, `FAILED`, or `SUPPRESSED`. A failure includes only a safe stable error code and retry eligibility. Ready section payloads use the typed schemas in OpenAPI. A suppressed peer section contains threshold evidence but no peer metric values.
|
||||
A run snapshot identifies the selected period, optional terminal scope, immutable dataset snapshot, metric-definition version, creation/update times, overall state, and all four sections. Section states are `QUEUED`, `CALCULATING`, `READY`, `FAILED`, or `SUPPRESSED`. A failure includes only a safe stable error code and retry eligibility. Ready section payloads use the typed schemas in OpenAPI. Because the approved peer cohort is merchant-grain, terminal-scoped runs return peer comparison as `SUPPRESSED` with `TERMINAL_SCOPE_UNSUPPORTED`; they never place merchant-wide peer values under a terminal scope.
|
||||
|
||||
## Metric response rules
|
||||
|
||||
@@ -104,13 +106,14 @@ A run snapshot identifies the selected period, immutable dataset snapshot, metri
|
||||
|
||||
`GET /analytics/session-details` requires an approved `view`:
|
||||
|
||||
- `ALL`: all merchant-owned sessions in the selected scope;
|
||||
- `UNVERIFIED_PAID`: sessions currently in `Paid`;
|
||||
- `FAILED`: sessions currently in `Failed`, optionally filtered by `failureStage=NO_ATTEMPT|REACHED_BANK|OTHER_OBSERVED`;
|
||||
- `RETRIED`: sessions with more than one normalized attempt.
|
||||
|
||||
All views require one approved `periodId`. Optional terminal and observable status/stage filters are validated inside the merchant boundary. There is no merchant-id, peer, payer-token, free-text, response-code, PSP, bank, or arbitrary date filter in the MVP.
|
||||
All views require one approved `periodId`. Optional `terminalId`, `status`, `failureStage`, `minAmountRial`, and `maxAmountRial` filters are validated inside the merchant boundary. The failure-stage filter is valid only for `FAILED`, and amount bounds must be non-negative and ascending. There is no merchant-id, peer, payer-token, free-text, response-code, PSP, bank, or arbitrary date filter in the MVP.
|
||||
|
||||
Ordering is stable and deterministic: `createdAt DESC, sessionId DESC`. The opaque cursor binds the merchant, view, canonical filters, ordering, and last row. It must be integrity-protected, expire after a bounded interval, and reveal no internal ids or filter data when decoded by a client.
|
||||
Ordering is stable and deterministic: `createdAt DESC, sessionId DESC`. The authenticated-encrypted cursor binds the merchant, period, view, canonical filters, ordering, and last row, expires after 24 hours, and reveals no internal ids or filter data when decoded by a client. The encryption key comes from the independently configured `CURSOR_SECRET` and is never returned or logged.
|
||||
|
||||
- `limit` defaults to 25 and is limited to 1–100.
|
||||
- Responses contain `items`, `pageInfo.limit`, `pageInfo.hasNextPage`, and nullable `pageInfo.nextCursor`.
|
||||
@@ -119,6 +122,8 @@ Ordering is stable and deterministic: `createdAt DESC, sessionId DESC`. The opaq
|
||||
|
||||
Detail rows expose only fields available in the authorized view: opaque merchant-local `sessionId`, the existing opaque source terminal reference labeled `شناسه پایانه`, status, amount/fee in Rial, timestamps, attempt count, and a bounded ordered attempt summary where relevant. The terminal reference is resolved and displayed only inside the authenticated merchant boundary and never grants authorization. Rows exclude source merchant keys, payer tokens, credentials, raw session tokens, peer data, internal database keys, and undocumented response-code meanings.
|
||||
|
||||
`GET /analytics/sessions/{sessionId}` applies the same session-derived merchant predicate before resolving the opaque session id. Missing and other-merchant ids return the same safe `404`; successful responses include ordered attempt summaries but never payer tokens or response-code fields.
|
||||
|
||||
## CSV export
|
||||
|
||||
`GET /analytics/session-details.csv` accepts exactly the same approved view and filters as the JSON detail endpoint, except cursor and page limit. The server reauthorizes and re-resolves the merchant at export time. It executes one immutable logical snapshot so rows do not drift between authorization, limit validation, and streaming.
|
||||
|
||||
@@ -435,25 +435,9 @@ paths:
|
||||
- $ref: '#/components/parameters/DetailViewQuery'
|
||||
- $ref: '#/components/parameters/FailureStageQuery'
|
||||
- $ref: '#/components/parameters/TerminalIdQuery'
|
||||
- name: status
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionStatus'
|
||||
- name: minAmountRial
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
- name: maxAmountRial
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
- $ref: '#/components/parameters/SessionStatusQuery'
|
||||
- $ref: '#/components/parameters/MinAmountRialQuery'
|
||||
- $ref: '#/components/parameters/MaxAmountRialQuery'
|
||||
- $ref: '#/components/parameters/CursorQuery'
|
||||
- $ref: '#/components/parameters/LimitQuery'
|
||||
- $ref: '#/components/parameters/RequestIdHeader'
|
||||
@@ -543,6 +527,9 @@ paths:
|
||||
- $ref: '#/components/parameters/DetailViewQuery'
|
||||
- $ref: '#/components/parameters/FailureStageQuery'
|
||||
- $ref: '#/components/parameters/TerminalIdQuery'
|
||||
- $ref: '#/components/parameters/SessionStatusQuery'
|
||||
- $ref: '#/components/parameters/MinAmountRialQuery'
|
||||
- $ref: '#/components/parameters/MaxAmountRialQuery'
|
||||
- $ref: '#/components/parameters/RequestIdHeader'
|
||||
responses:
|
||||
'200':
|
||||
@@ -681,6 +668,28 @@ components:
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
pattern: '^[A-Za-z0-9._:-]+$'
|
||||
SessionStatusQuery:
|
||||
name: status
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/SessionStatus'
|
||||
MinAmountRialQuery:
|
||||
name: minAmountRial
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
MaxAmountRialQuery:
|
||||
name: maxAmountRial
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
CursorQuery:
|
||||
name: cursor
|
||||
in: query
|
||||
@@ -1550,7 +1559,11 @@ components:
|
||||
metadata: {$ref: '#/components/schemas/ReadySectionMetadata'}
|
||||
reason:
|
||||
type: string
|
||||
const: INSUFFICIENT_COHORT
|
||||
enum: [INSUFFICIENT_COHORT, TERMINAL_SCOPE_UNSUPPORTED]
|
||||
description: |
|
||||
`TERMINAL_SCOPE_UNSUPPORTED` is returned for terminal-scoped runs because
|
||||
the approved peer cohort is merchant-grain and must not be presented as
|
||||
if it benchmarked one terminal.
|
||||
eligiblePeerCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
|
||||
108
internal/analytics/cursor.go
Normal file
108
internal/analytics/cursor.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidCursor = errors.New("analytics: invalid cursor")
|
||||
|
||||
type detailCursorPosition struct {
|
||||
CreatedAt time.Time
|
||||
SessionID int64
|
||||
}
|
||||
|
||||
type detailCursorPayload struct {
|
||||
Version int `json:"v"`
|
||||
Binding string `json:"b"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
SessionID int64 `json:"sessionId"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type detailCursorBinding struct {
|
||||
MerchantID int64 `json:"merchantId"`
|
||||
PeriodID string `json:"periodId"`
|
||||
View string `json:"view"`
|
||||
FailureStage string `json:"failureStage"`
|
||||
TerminalID string `json:"terminalId"`
|
||||
Status string `json:"status"`
|
||||
Minimum *int64 `json:"minimum"`
|
||||
Maximum *int64 `json:"maximum"`
|
||||
}
|
||||
|
||||
func (s *Service) encodeDetailCursor(merchantID int64, period Period, filter DetailFilter, createdAt time.Time, sessionID int64) (string, error) {
|
||||
payload := detailCursorPayload{
|
||||
Version: 1,
|
||||
Binding: detailCursorBindingDigest(merchantID, period, filter),
|
||||
CreatedAt: createdAt.UTC(),
|
||||
SessionID: sessionID,
|
||||
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
aead, err := s.cursorAEAD()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed := aead.Seal(nonce, nonce, encoded, nil)
|
||||
return base64.RawURLEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
func (s *Service) decodeDetailCursor(merchantID int64, period Period, filter DetailFilter, cursor string) (detailCursorPosition, error) {
|
||||
sealed, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
aead, err := s.cursorAEAD()
|
||||
if err != nil || len(sealed) < aead.NonceSize() {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
nonce, ciphertext := sealed[:aead.NonceSize()], sealed[aead.NonceSize():]
|
||||
payloadBytes, err := aead.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
var payload detailCursorPayload
|
||||
if err := json.Unmarshal(payloadBytes, &payload); err != nil || payload.Version != 1 || payload.SessionID <= 0 || payload.CreatedAt.IsZero() || time.Now().UTC().After(payload.ExpiresAt) {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
wantBinding := detailCursorBindingDigest(merchantID, period, filter)
|
||||
if payload.Binding != wantBinding {
|
||||
return detailCursorPosition{}, ErrInvalidCursor
|
||||
}
|
||||
return detailCursorPosition{CreatedAt: payload.CreatedAt.UTC(), SessionID: payload.SessionID}, nil
|
||||
}
|
||||
|
||||
func (s *Service) cursorAEAD() (cipher.AEAD, error) {
|
||||
key := sha256.Sum256(s.cursorSecret)
|
||||
block, err := aes.NewCipher(key[:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
func detailCursorBindingDigest(merchantID int64, period Period, filter DetailFilter) string {
|
||||
binding := detailCursorBinding{
|
||||
MerchantID: merchantID, PeriodID: period.PeriodID, View: filter.View,
|
||||
FailureStage: filter.FailureStage, TerminalID: filter.TerminalID, Status: filter.Status,
|
||||
Minimum: filter.MinAmountRial, Maximum: filter.MaxAmountRial,
|
||||
}
|
||||
encoded, _ := json.Marshal(binding)
|
||||
digest := sha256.Sum256(encoded)
|
||||
return base64.RawURLEncoding.EncodeToString(digest[:])
|
||||
}
|
||||
@@ -2,16 +2,33 @@ package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const MaxExportRows = 100_000
|
||||
|
||||
var ErrExportTooLarge = errors.New("analytics: export exceeds row limit")
|
||||
|
||||
const detailFilterPredicate = `
|
||||
ps.merchant_id=$1 AND ps.created_at>=$2 AND ps.created_at<$3
|
||||
AND ($4='ALL'
|
||||
OR ($4='UNVERIFIED_PAID' AND ps.status='Paid')
|
||||
OR ($4='FAILED' AND ps.status='Failed')
|
||||
OR ($4='RETRIED' AND (SELECT count(*) FROM payment_attempts retry_attempts WHERE retry_attempts.session_id=ps.id)>1))
|
||||
AND ($5='' OR t.source_key=$5)
|
||||
AND ($6='' OR
|
||||
($6='NO_ATTEMPT' AND NOT EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id)) OR
|
||||
($6='REACHED_BANK' AND EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id AND failure_attempts.status='InBank')) OR
|
||||
($6='OTHER_OBSERVED' AND EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id) AND NOT EXISTS (SELECT 1 FROM payment_attempts failure_attempts WHERE failure_attempts.session_id=ps.id AND failure_attempts.status='InBank')))
|
||||
AND ($7='' OR ps.status=$7)
|
||||
AND ($8::bigint IS NULL OR ps.amount_rial >= $8)
|
||||
AND ($9::bigint IS NULL OR ps.amount_rial <= $9)`
|
||||
|
||||
type DetailFilter struct {
|
||||
View string
|
||||
FailureStage string
|
||||
@@ -70,6 +87,12 @@ type Detail struct {
|
||||
Attempts []Attempt `json:"attempts"`
|
||||
}
|
||||
|
||||
type ExportDetail struct {
|
||||
Detail
|
||||
LastAttemptStatus *string
|
||||
LastAttemptAt *time.Time
|
||||
}
|
||||
|
||||
func (s *Service) Details(ctx context.Context, merchantID int64, period Period, filter DetailFilter) ([]Detail, string, error) {
|
||||
if filter.Limit <= 0 {
|
||||
filter.Limit = 25
|
||||
@@ -77,39 +100,22 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
|
||||
if err := filter.Validate(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
offset := 0
|
||||
var cursorCreatedAt *time.Time
|
||||
var cursorSessionID int64
|
||||
if filter.Cursor != "" {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(filter.Cursor)
|
||||
position, err := s.decodeDetailCursor(merchantID, period, filter, filter.Cursor)
|
||||
if err != nil {
|
||||
return nil, "", errors.New("analytics: invalid cursor")
|
||||
return nil, "", err
|
||||
}
|
||||
offset, err = strconv.Atoi(string(decoded))
|
||||
if err != nil || offset < 0 {
|
||||
return nil, "", errors.New("analytics: invalid cursor")
|
||||
}
|
||||
}
|
||||
statusPredicate := "TRUE"
|
||||
switch filter.View {
|
||||
case "ALL":
|
||||
statusPredicate = "TRUE"
|
||||
case "UNVERIFIED_PAID":
|
||||
statusPredicate = "ps.status='Paid'"
|
||||
case "FAILED":
|
||||
statusPredicate = "ps.status='Failed'"
|
||||
case "RETRIED":
|
||||
statusPredicate = "(SELECT count(*) FROM payment_attempts x WHERE x.session_id=ps.id)>1"
|
||||
cursorCreatedAt = &position.CreatedAt
|
||||
cursorSessionID = position.SessionID
|
||||
}
|
||||
query := `SELECT ps.id,ps.source_key,t.source_key,ps.status,ps.amount_rial,ps.fee_amount_rial,ps.created_at,ps.expires_at,ps.verified_at,ps.settled_at,
|
||||
count(pa.id)::int,coalesce(bool_or(pa.status='InBank'),false) FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
|
||||
WHERE ps.merchant_id=$1 AND ps.created_at>=$2 AND ps.created_at<$3 AND ` + statusPredicate + `
|
||||
AND ($4='' OR t.source_key=$4)
|
||||
AND ($5='' OR
|
||||
($5='NO_ATTEMPT' AND NOT EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id)) OR
|
||||
($5='REACHED_BANK' AND EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id AND fa.status='InBank')) OR
|
||||
($5='OTHER_OBSERVED' AND EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id) AND NOT EXISTS (SELECT 1 FROM payment_attempts fa WHERE fa.session_id=ps.id AND fa.status='InBank')))
|
||||
AND ($6='' OR ps.status=$6) AND ($7::bigint IS NULL OR ps.amount_rial >= $7) AND ($8::bigint IS NULL OR ps.amount_rial <= $8)
|
||||
GROUP BY ps.id,t.source_key ORDER BY ps.created_at DESC,ps.id DESC LIMIT $9 OFFSET $10`
|
||||
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.TerminalID, filter.FailureStage, filter.Status, filter.MinAmountRial, filter.MaxAmountRial, filter.Limit+1, offset)
|
||||
count(pa.id)::int,coalesce(bool_or(pa.status='InBank'),false) FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
|
||||
WHERE ` + detailFilterPredicate + `
|
||||
AND ($10::timestamptz IS NULL OR ps.created_at < $10 OR (ps.created_at=$10 AND ps.id<$11))
|
||||
GROUP BY ps.id,t.source_key ORDER BY ps.created_at DESC,ps.id DESC LIMIT $12`
|
||||
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.View, filter.TerminalID, filter.FailureStage, filter.Status, filter.MinAmountRial, filter.MaxAmountRial, cursorCreatedAt, cursorSessionID, filter.Limit+1)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("querying session details: %w", err)
|
||||
}
|
||||
@@ -146,11 +152,53 @@ func (s *Service) Details(ctx context.Context, merchantID int64, period Period,
|
||||
}
|
||||
next := ""
|
||||
if hasMore {
|
||||
next = base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset + filter.Limit)))
|
||||
next, err = s.encodeDetailCursor(merchantID, period, filter, details[len(details)-1].CreatedAt, ids[len(ids)-1])
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("encoding detail cursor: %w", err)
|
||||
}
|
||||
}
|
||||
return details, next, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExportDetails(ctx context.Context, merchantID int64, period Period, filter DetailFilter) ([]ExportDetail, error) {
|
||||
filter.Cursor = ""
|
||||
filter.Limit = 100
|
||||
if err := filter.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := `SELECT ps.id,ps.source_key,t.source_key,ps.status,ps.amount_rial,ps.fee_amount_rial,ps.created_at,ps.expires_at,ps.verified_at,ps.settled_at,
|
||||
(SELECT count(*)::int FROM payment_attempts attempts WHERE attempts.session_id=ps.id),
|
||||
EXISTS(SELECT 1 FROM payment_attempts attempts WHERE attempts.session_id=ps.id AND attempts.status='InBank'),
|
||||
last_attempt.status,last_attempt.created_at
|
||||
FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id
|
||||
LEFT JOIN LATERAL (SELECT status,created_at FROM payment_attempts WHERE session_id=ps.id ORDER BY sequence DESC LIMIT 1) last_attempt ON true
|
||||
WHERE ` + detailFilterPredicate + ` ORDER BY ps.created_at DESC,ps.id DESC LIMIT $10`
|
||||
rows, err := s.pool.Query(ctx, query, merchantID, period.StartAt, period.EndAt, filter.View, filter.TerminalID, filter.FailureStage, filter.Status, filter.MinAmountRial, filter.MaxAmountRial, MaxExportRows+1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying session export: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]ExportDetail, 0, 1024)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var reachedBank bool
|
||||
var item ExportDetail
|
||||
if err := rows.Scan(&id, &item.SessionID, &item.TerminalID, &item.SessionStatus, &item.AmountRial, &item.FeeAmountRial, &item.CreatedAt, &item.ExpiresAt, &item.VerifiedAt, &item.SettledAt, &item.AttemptCount, &reachedBank, &item.LastAttemptStatus, &item.LastAttemptAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning session export: %w", err)
|
||||
}
|
||||
item.SessionID = "ses_" + item.SessionID
|
||||
setFailureStage(&item.Detail, reachedBank)
|
||||
items = append(items, item)
|
||||
if len(items) > MaxExportRows {
|
||||
return nil, ErrExportTooLarge
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating session export: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) Session(ctx context.Context, merchantID int64, sessionID string) (Detail, bool, error) {
|
||||
sourceKey, valid := sessionSourceKey(sessionID)
|
||||
if !valid {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package analytics
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDetailFilterValidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -59,3 +64,53 @@ func TestTerminalDisplayName(t *testing.T) {
|
||||
t.Fatalf("terminalDisplayName() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailCursorRejectsMerchantAndFilterReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := &Service{cursorSecret: []byte("test-cursor-secret-that-is-at-least-32-bytes")}
|
||||
period := Period{PeriodID: "jm-1405-03"}
|
||||
minimum := int64(1000)
|
||||
filter := DetailFilter{View: "ALL", TerminalID: "T318", Status: "Verified", MinAmountRial: &minimum, Limit: 25}
|
||||
createdAt := time.Date(2026, time.June, 20, 8, 30, 0, 0, time.UTC)
|
||||
|
||||
cursor, err := service.encodeDetailCursor(42, period, filter, createdAt, 99)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
position, err := service.decodeDetailCursor(42, period, filter, cursor)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeDetailCursor() error = %v", err)
|
||||
}
|
||||
if !position.CreatedAt.Equal(createdAt) || position.SessionID != 99 {
|
||||
t.Fatalf("decodeDetailCursor() = %+v", position)
|
||||
}
|
||||
|
||||
if _, err := service.decodeDetailCursor(43, period, filter, cursor); !errors.Is(err, ErrInvalidCursor) {
|
||||
t.Fatalf("merchant replay error = %v, want ErrInvalidCursor", err)
|
||||
}
|
||||
changed := filter
|
||||
changed.Status = "Paid"
|
||||
if _, err := service.decodeDetailCursor(42, period, changed, cursor); !errors.Is(err, ErrInvalidCursor) {
|
||||
t.Fatalf("filter replay error = %v, want ErrInvalidCursor", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailCursorRejectsTampering(t *testing.T) {
|
||||
t.Parallel()
|
||||
service := &Service{cursorSecret: []byte("test-cursor-secret-that-is-at-least-32-bytes")}
|
||||
period := Period{PeriodID: "jm-1405-03"}
|
||||
filter := DetailFilter{View: "ALL", Limit: 25}
|
||||
cursor, err := service.encodeDetailCursor(42, period, filter, time.Now().UTC(), 99)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed, err := base64.RawURLEncoding.DecodeString(cursor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sealed[len(sealed)-1] ^= 1
|
||||
tampered := base64.RawURLEncoding.EncodeToString(sealed)
|
||||
if _, err := service.decodeDetailCursor(42, period, filter, tampered); !errors.Is(err, ErrInvalidCursor) {
|
||||
t.Fatalf("tampered cursor error = %v, want ErrInvalidCursor", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,9 +49,10 @@ type PaymentHistoryPoint struct {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
mu sync.RWMutex
|
||||
runs map[string]storedRun
|
||||
pool *pgxpool.Pool
|
||||
cursorSecret []byte
|
||||
mu sync.RWMutex
|
||||
runs map[string]storedRun
|
||||
}
|
||||
|
||||
type storedRun struct {
|
||||
@@ -59,7 +60,9 @@ type storedRun struct {
|
||||
run Run
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Service { return &Service{pool: pool, runs: map[string]storedRun{}} }
|
||||
func New(pool *pgxpool.Pool, cursorSecret string) *Service {
|
||||
return &Service{pool: pool, cursorSecret: []byte(cursorSecret), runs: map[string]storedRun{}}
|
||||
}
|
||||
|
||||
func (s *Service) Periods(ctx context.Context) ([]Period, error) {
|
||||
var start, end time.Time
|
||||
@@ -105,21 +108,27 @@ func (s *Service) Start(ctx context.Context, merchantID int64, period Period, te
|
||||
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)}
|
||||
peerCount, peerSessions, err := s.peerEligibility(ctx, merchantID, period)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
if peerCount < 10 || peerSessions < 1000 {
|
||||
if !peerComparisonSupported(terminalID) {
|
||||
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "SUPPRESSED", "metadata": metadata,
|
||||
"reason": "INSUFFICIENT_COHORT", "eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions,
|
||||
"reason": "TERMINAL_SCOPE_UNSUPPORTED", "eligiblePeerCount": 0, "combinedPeerSessionCount": 0,
|
||||
"requiredPeerCount": 10, "requiredPeerSessionCount": 1000}
|
||||
} else {
|
||||
peerMetrics, err := s.peerMetrics(ctx, merchantID, period)
|
||||
peerCount, peerSessions, err := s.peerEligibility(ctx, merchantID, period)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "READY", "metadata": metadata,
|
||||
"data": map[string]any{"eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions, "metrics": peerMetrics}}
|
||||
if peerCount < 10 || peerSessions < 1000 {
|
||||
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "SUPPRESSED", "metadata": metadata,
|
||||
"reason": "INSUFFICIENT_COHORT", "eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions,
|
||||
"requiredPeerCount": 10, "requiredPeerSessionCount": 1000}
|
||||
} else {
|
||||
peerMetrics, err := s.peerMetrics(ctx, merchantID, period)
|
||||
if err != nil {
|
||||
return Run{}, err
|
||||
}
|
||||
run.Sections["peerComparison"] = map[string]any{"sectionId": "peer_comparison", "state": "READY", "metadata": metadata,
|
||||
"data": map[string]any{"eligiblePeerCount": peerCount, "combinedPeerSessionCount": peerSessions, "metrics": peerMetrics}}
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.runs[key] = storedRun{merchantID: merchantID, run: run}
|
||||
@@ -128,6 +137,8 @@ func (s *Service) Start(ctx context.Context, merchantID int64, period Period, te
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func peerComparisonSupported(terminalID string) bool { return terminalID == "" }
|
||||
|
||||
func (s *Service) Run(merchantID int64, runID string) (Run, bool) {
|
||||
s.mu.RLock()
|
||||
stored, ok := s.runs[runID]
|
||||
|
||||
13
internal/analytics/service_test.go
Normal file
13
internal/analytics/service_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package analytics
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPeerComparisonSupported(t *testing.T) {
|
||||
t.Parallel()
|
||||
if peerComparisonSupported("T318") {
|
||||
t.Fatal("peerComparisonSupported() = true for terminal scope")
|
||||
}
|
||||
if !peerComparisonSupported("") {
|
||||
t.Fatal("peerComparisonSupported() = false for merchant scope")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
@@ -8,16 +9,22 @@ import (
|
||||
"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
|
||||
@@ -150,6 +157,11 @@ func (s *Server) periods(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
@@ -257,23 +269,16 @@ func (s *Server) details(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
limit = parsed
|
||||
}
|
||||
minAmount, valid := optionalNonNegativeInt64(r.URL.Query().Get("minAmountRial"))
|
||||
if !valid {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid minimum amount")
|
||||
filter, err := detailFilterFromRequest(r, limit)
|
||||
if err != nil {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Invalid detail request")
|
||||
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 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")
|
||||
@@ -309,30 +314,99 @@ func (s *Server) exportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
s.problem(w, r, 422, "VALIDATION_FAILED", "Unknown reporting period")
|
||||
return
|
||||
}
|
||||
filter := analytics.DetailFilter{View: r.URL.Query().Get("view"), FailureStage: r.URL.Query().Get("failureStage"), TerminalID: r.URL.Query().Get("terminalId"), Limit: 100}
|
||||
items, _, err := s.analytics.Details(r.Context(), sessionFrom(r.Context()).MerchantID, period, filter)
|
||||
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")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", strings.ToLower(filter.View)+"-"+period.PeriodID+".csv"))
|
||||
_, _ = w.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
writer := csv.NewWriter(w)
|
||||
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
|
||||
_ = writer.Write([]string{"شناسه نشست پرداخت", "شناسه پایانه", "وضعیت پرداخت", "مبلغ ریال", "کارمزد ریال", "زمان ایجاد", "زمان انقضا", "تعداد تلاش", "وضعیت آخرین تلاش", "زمان آخرین تلاش", "زمان تأیید", "زمان تسویه", "مرحله مشاهده شده شکست"})
|
||||
if err := writer.Write([]string{"شناسه نشست پرداخت", "شناسه پایانه", "وضعیت پرداخت", "مبلغ ریال", "کارمزد ریال", "زمان ایجاد", "زمان انقضا", "تعداد تلاش", "وضعیت آخرین تلاش", "زمان آخرین تلاش", "زمان تأیید", "زمان تسویه", "مرحله مشاهده شده شکست"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
lastStatus, lastAt := "", ""
|
||||
if len(item.Attempts) > 0 {
|
||||
last := item.Attempts[len(item.Attempts)-1]
|
||||
lastStatus = persianStatus(last.Status)
|
||||
if last.CreatedAt != nil {
|
||||
lastAt = last.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
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.Write([]string{item.SessionID, item.TerminalID, 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), lastStatus, lastAt, formatTime(item.VerifiedAt), formatTime(item.SettledAt), stringValue(item.FailureStage)})
|
||||
}
|
||||
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) {
|
||||
@@ -364,6 +438,45 @@ func optionalNonNegativeInt64(value string) (*int64, bool) {
|
||||
}
|
||||
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 ""
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
|
||||
)
|
||||
|
||||
func TestServerCookieName(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -22,6 +30,25 @@ func TestServerCookieName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderExportCSVIsBoundedAndFormulaSafe(t *testing.T) {
|
||||
t.Parallel()
|
||||
item := analytics.ExportDetail{Detail: analytics.Detail{
|
||||
SessionID: "=session", TerminalID: "+terminal", SessionStatus: "Verified",
|
||||
AmountRial: 1000, FeeAmountRial: 10, CreatedAt: time.Date(2026, time.June, 20, 8, 0, 0, 0, time.UTC),
|
||||
}}
|
||||
encoded, err := renderExportCSV([]analytics.ExportDetail{item}, 4096)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(encoded)
|
||||
if !strings.Contains(text, "'=session") || !strings.Contains(text, "'+terminal") {
|
||||
t.Fatalf("renderExportCSV() did not neutralize formulas: %q", text)
|
||||
}
|
||||
if _, err := renderExportCSV([]analytics.ExportDetail{item}, 10); !errors.Is(err, errExportBytesExceeded) {
|
||||
t.Fatalf("renderExportCSV() error = %v, want errExportBytesExceeded", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersianStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := persianStatus("Verified"); got != "تأیید شده" {
|
||||
@@ -60,3 +87,39 @@ func equalInt64Pointers(left, right *int64) bool {
|
||||
}
|
||||
return *left == *right
|
||||
}
|
||||
|
||||
func TestDetailFilterFromRequestParsesCanonicalFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := httptest.NewRequest("GET", "/?view=ALL&failureStage=&terminalId=T318&status=Verified&minAmountRial=1000&maxAmountRial=9000&cursor=opaque", nil)
|
||||
|
||||
filter, err := detailFilterFromRequest(request, 25)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filter.View != "ALL" || filter.TerminalID != "T318" || filter.Status != "Verified" || filter.Cursor != "opaque" || filter.Limit != 25 {
|
||||
t.Fatalf("detailFilterFromRequest() = %+v", filter)
|
||||
}
|
||||
if filter.MinAmountRial == nil || *filter.MinAmountRial != 1000 || filter.MaxAmountRial == nil || *filter.MaxAmountRial != 9000 {
|
||||
t.Fatalf("detailFilterFromRequest() amount bounds = %v/%v", filter.MinAmountRial, filter.MaxAmountRial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailFilterFromRequestRejectsDescendingAmountRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
request := httptest.NewRequest("GET", "/?view=ALL&minAmountRial=9000&maxAmountRial=1000", nil)
|
||||
if _, err := detailFilterFromRequest(request, 25); err == nil {
|
||||
t.Fatal("detailFilterFromRequest() error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeriodsByType(t *testing.T) {
|
||||
t.Parallel()
|
||||
periods := []analytics.Period{{PeriodType: "DAY"}, {PeriodType: "WEEK"}}
|
||||
filtered, err := filterPeriodsByType(periods, "DAY")
|
||||
if err != nil || len(filtered) != 1 || filtered[0].PeriodType != "DAY" || !filtered[0].IsDefault {
|
||||
t.Fatalf("filterPeriodsByType() = %+v, %v", filtered, err)
|
||||
}
|
||||
if _, err := filterPeriodsByType(periods, "MONTH"); err == nil {
|
||||
t.Fatal("filterPeriodsByType() accepted invalid type")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Config struct {
|
||||
DemoUsername string
|
||||
DemoPassword string
|
||||
SessionSecret string
|
||||
CursorSecret string
|
||||
FrontendOrigin string
|
||||
CookieSecure bool
|
||||
}
|
||||
@@ -30,6 +31,7 @@ func Load() (Config, error) {
|
||||
DemoUsername: value("DEMO_USERNAME", "merchant-0001"),
|
||||
DemoPassword: os.Getenv("DEMO_PASSWORD"),
|
||||
SessionSecret: os.Getenv("SESSION_SECRET"),
|
||||
CursorSecret: os.Getenv("CURSOR_SECRET"),
|
||||
FrontendOrigin: value("FRONTEND_ORIGIN", "http://localhost:5173"),
|
||||
}
|
||||
|
||||
@@ -48,6 +50,12 @@ func (c Config) ValidateAPI() error {
|
||||
if len(c.SessionSecret) < 32 {
|
||||
return errors.New("config: SESSION_SECRET must contain at least 32 characters")
|
||||
}
|
||||
if len(c.CursorSecret) < 32 {
|
||||
return errors.New("config: CURSOR_SECRET must contain at least 32 characters")
|
||||
}
|
||||
if c.CursorSecret == c.SessionSecret {
|
||||
return errors.New("config: CURSOR_SECRET must differ from SESSION_SECRET")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,25 @@ func TestLoadRequiresDatabaseURL(t *testing.T) {
|
||||
|
||||
func TestValidateAPIRequiresLongSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{SessionSecret: "short"}
|
||||
cfg := Config{SessionSecret: "short", CursorSecret: "cursor-secret-that-is-at-least-32-bytes"}
|
||||
if err := cfg.ValidateAPI(); err == nil {
|
||||
t.Fatal("ValidateAPI() error = nil, want secret length error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAPIRequiresLongCursorSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := Config{SessionSecret: "session-secret-that-is-at-least-32-bytes", CursorSecret: "short"}
|
||||
if err := cfg.ValidateAPI(); err == nil {
|
||||
t.Fatal("ValidateAPI() error = nil, want cursor secret length error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAPIRequiresIndependentCursorSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
secret := "shared-secret-that-is-at-least-32-characters"
|
||||
cfg := Config{SessionSecret: secret, CursorSecret: secret}
|
||||
if err := cfg.ValidateAPI(); err == nil {
|
||||
t.Fatal("ValidateAPI() error = nil, want independent secrets error")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user