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.
25 KiB
Merchant Analytics API Capability Spec
- Status: Approved specification — implementation authorized 2026-08-20
- Capability: Merchant API
- Depends on: Approved Identity, Data Foundation, and Analytics Engine specifications
- Interface source of truth:
docs/openapi.yaml - Last updated: 2026-08-20
Objective
Expose the approved merchant identity and analytics capabilities as a tenant-safe REST JSON API under /api/v1. The API starts and observes analytics runs, streams partial results, lists merchant-owned evidence, and exports the same authorized detail view as CSV. OpenAPI is the normative interface contract and is intended to generate frontend types after approval.
Actors and trust boundaries
- Unauthenticated browser: may call login and explicitly public health/static resources only.
- Authenticated merchant user: may access one merchant derived from the current server-side session.
- Merchant dashboard: same-origin browser client using the opaque session cookie and CSRF protection for mutations.
- Analytics worker and Redis: private implementation boundaries; neither is exposed through this API.
- Cross-merchant peer data: server-only calculation input. The API returns only approved anonymized aggregates or a suppressed state.
Every request, query parameter, cursor, run id, event id, and export request is untrusted. Possession of a resource id never grants access. The backend resolves the merchant from __Host-zpa_session for every protected request and checks resource ownership inside that boundary.
Scope
In scope for MVP
- Login, current-session inspection, and logout
- Approved reporting-period discovery with exact ISO boundaries and Persian/Jalali labels
- Idempotent analytics-run start, snapshot retrieval, section retry, and authenticated SSE
- Typed results for
overview,payment_performance,fees, andpeer_comparison - Cursor-paginated, read-only merchant-owned session details for unverified-paid, failed, and retried views
- Synchronous CSV export of the currently authorized detail view and filters
- RFC 9457 Problem Details, authorization, throttling, response-size limits, audit, and observability contracts
Out of scope for MVP
- Merchant or user administration, registration, password recovery, and browser operator access
- Arbitrary formulas, date ranges, filters, sorting, cross-merchant drill-down, and peer-row export
- Payer-token, credential, session-token, raw cache/job, or AI interfaces
- Asynchronous export jobs, saved reports, scheduled delivery, and export history
- Production payment writes, CSV ingestion, migrations, deployment endpoints, and AvalAI calls
Protocol conventions
- HTTPS is required outside explicit local development. Successful structured responses use
application/json; errors useapplication/problem+json. - JSON field and query names use
camelCase; enum values useUPPER_SNAKE_CASE; canonical metric and section ids retain their approvedsnake_casevalues. - Canonical timestamps are ISO 8601/RFC 3339 instants in UTC with
Z. Period objects also return their Tehran-local half-open boundaries with explicit+03:30offsets and a ready-to-display Persian/Jalali label. - Money is an integer number of Rial. Rates and percentiles are fixed-precision decimal JSON strings, never binary floating-point values.
- Unknown JSON fields and unknown query parameters are rejected with
VALIDATION_ERRORso misspelled filters cannot silently broaden a result. - Protected responses use
Cache-Control: private, no-store. Responses includeX-Request-ID; a safe client-supplied request id may be accepted but never trusted as unique. - State-changing browser requests require both the approved CSRF token header and strict same-origin evidence. Login is unauthenticated but still enforces origin checks and its approved throttles.
Resource map
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/auth/sessions |
Authenticate and create a server-side session |
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.
Reporting-period contract
Clients select one allowlisted periodId returned by GET /analytics/periods; they do not submit arbitrary start/end timestamps. Each period contains:
- stable
periodIdandperiodType(DAY,WEEK,JALALI_MONTH, orPERSIAN_SEASON); - exact half-open UTC query boundaries
startAtandendAt; - exact Tehran-local boundaries
localStartAtandlocalEndAt; timezone: Asia/TehranandjalaliLabelFa, ready for display;isCompleteandisDefaultflags.
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, 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. - Equivalent concurrent requests for the current dataset snapshot resolve to the same run even when their valid intent keys differ.
- An in-flight duplicate returns the current
202run 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, 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
- Every metric value identifies its canonical metric id, unit, definition version, sample/denominator or coverage where applicable, and calculation time through its containing section.
- Count and money values are JSON integers. Percentage, ratio, and percentile values are decimal strings or
nullwhen mathematically undefined. - Zero, undefined denominator, unavailable comparison, suppressed peer result, not-yet-ready, and calculation failure are distinct states.
PaidandVerifiedare bank-paid; onlyVerifiedis finalized. Session money and fees are counted once regardless of attempt count.- Timing values include p50, p95, contributing sample size, and coverage. Missing timings never become zero.
- Peer outputs include only the approved merchant-weighted cohort median and direction-adjusted merchant percentile after both suppression thresholds pass.
Detail views and cursor pagination
GET /analytics/session-details requires an approved view:
ALL: all merchant-owned sessions in the selected scope;UNVERIFIED_PAID: sessions currently inPaid;FAILED: sessions currently inFailed, optionally filtered byfailureStage=NO_ATTEMPT|REACHED_BANK|OTHER_OBSERVED;RETRIED: sessions with more than one normalized attempt.
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 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.
limitdefaults to 25 and is limited to 1–100.- Responses contain
items,pageInfo.limit,pageInfo.hasNextPage, and nullablepageInfo.nextCursor. - Total counts are omitted from page responses to avoid an additional unbounded count query. Aggregate cards remain the reconciliation source.
- Reusing a cursor with changed filters, another merchant, or after expiry returns
400 INVALID_CURSORwithout disclosing which binding failed.
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.
Format and delivery
Content-Type: text/csv; charset=utf-8- UTF-8 with one leading BOM for common Persian spreadsheet compatibility
- RFC 4180-style comma separation, header row, CRLF records, and doubled quotes
- Stable Persian column names separated by normal spaces, with Persian status labels
- UTC ISO timestamps with
Z; Rial integer values; no locale-formatted numbers Content-Disposition: attachmentwith an ASCIIfilenamefallback followed by an RFC 6266filename*=UTF-8''...valueCache-Control: private, no-storeandX-Content-Type-Options: nosniff
The filename contains the view, canonical period id, and generation timestamp. It never contains a username, merchant name/key, raw filter text, or other user-controlled value.
Safeguards
- Export columns are an allowlisted subset of the equivalent authorized JSON detail view. A CSV never adds payer tokens, credentials, peer-level data, internal ids, or fields unavailable in that view.
- Text cells that begin with
=,+,-,@, tab, CR, or LF after leading whitespace are prefixed with a single apostrophe before CSV quoting to prevent spreadsheet formula execution. Numeric money/count columns are emitted only after strict numeric typing. - Embedded CR/LF and quotes are encoded per CSV rules; control characters not required by approved fields are rejected or replaced with a documented safe marker.
- The export fails before sending CSV bytes if its preflight result exceeds either 100,000 rows or 50 MiB estimated uncompressed output. It returns
413 EXPORT_TOO_LARGEand instructs the merchant to narrow approved filters. - Export generation has a 60-second server deadline. A timeout returns a Problem Details response if headers are unsent; a connection terminated after streaming begins is incomplete and must never be presented as a successful audited export.
- Export requests default to 3 per 10 minutes with burst 1 in independent authenticated-user and merchant buckets. A rejection returns
429withRetry-Afterbefore query/stream work begins.
The row/byte/deadline/export-throttle values are approved MVP policy.
SSE events and recovery
The run stream uses text/event-stream; charset=utf-8, disables intermediary buffering/compression, sends Cache-Control: no-cache, no-transform, and reauthorizes the session for connection and replay. Event ids are opaque, stable within the retained run history, and scoped to the run and merchant.
| Event | Payload purpose |
|---|---|
run.snapshot |
Full current snapshot on initial connection or replay reset |
section.started |
Section entered CALCULATING |
section.ready |
Typed ready section payload |
section.failed |
Safe failure code and manual-retry eligibility |
section.suppressed |
Peer section suppressed with threshold evidence only |
run.completed |
Terminal run snapshot after every section is terminal |
heartbeat |
Server time and run id; no analytics payload |
Each non-comment event contains id, event, and one-line JSON data. Heartbeats are named events every 15 seconds so clients can observe liveness. The server may send SSE comment lines additionally, but clients must not depend on them.
On reconnect, the browser sends Last-Event-ID. If the id remains within the one-hour authorized replay history, the server replays only later events in original order and then continues live. If it is absent, invalid, belongs elsewhere, or has expired, the stream emits a fresh run.snapshot with recovery: RESET_TO_SNAPSHOT and continues from new events; it never reveals whether an id belongs to another merchant. Session expiry/revocation terminates recovery access. A completed run emits its current snapshot and terminal event, then may close.
Error contract
Every non-SSE error response uses RFC 9457 application/problem+json with:
{
"type": "https://zarinpal.amiirkhl.ir/docs/errors/validation-error",
"title": "Request validation failed",
"status": 422,
"detail": "One or more request values are invalid.",
"instance": "/api/v1/analytics/runs",
"code": "VALIDATION_ERROR",
"requestId": "01J...",
"errors": [{ "field": "periodId", "reason": "UNKNOWN_PERIOD" }]
}
code is the stable application discriminator; clients must not parse title or detail. requestId is safe correlation metadata. Validation errors use allowlisted field names/reasons and never echo credentials, cookies, cursor contents, SQL, stack traces, peer identities, or sensitive request values.
| Status | Stable codes (non-exhaustive) |
|---|---|
400 |
MALFORMED_REQUEST, INVALID_CURSOR |
401 |
UNAUTHENTICATED |
403 |
FORBIDDEN, CSRF_FAILED |
404 |
RESOURCE_NOT_FOUND |
409 |
SECTION_NOT_RETRYABLE, RUN_REVISION_STALE |
413 |
REQUEST_TOO_LARGE, EXPORT_TOO_LARGE |
415 |
UNSUPPORTED_MEDIA_TYPE |
422 |
VALIDATION_ERROR, IDEMPOTENCY_KEY_REUSED, UNKNOWN_PERIOD |
429 |
RATE_LIMITED, CONNECTION_LIMIT_REACHED |
500 |
INTERNAL_ERROR |
503 |
DEPENDENCY_UNAVAILABLE, ANALYTICS_UNAVAILABLE |
504 |
ANALYTICS_TIMEOUT, EXPORT_TIMEOUT |
For tenant-owned ids, an absent resource and a resource outside the authenticated merchant boundary both return the same 404 RESOURCE_NOT_FOUND. 429 and retryable 503 responses include Retry-After when a meaningful retry time is known.
Authorization and privacy matrix
| Surface | Authentication | Authorization/privacy rule |
|---|---|---|
| Login | None; origin checked | Generic credential outcome and approved username/IP throttles |
| Current session/logout | Valid current session | Current user/session only; logout requires CSRF and origin evidence |
| Periods | Valid current session | Approved global period definitions; no merchant ids accepted |
| Run/snapshot/SSE/retry | Valid current session | Run merchant must equal session merchant; ids never authorize |
| Detail JSON/CSV | Valid current session | Query is built inside session merchant scope; export is an allowlisted projection |
| Peer result | Valid current session | Aggregate or suppression only; never peer rows/ids/exports |
The API never serializes payer_card_key, password hashes, raw session/CSRF tokens, cookie values, credential material, source merchant keys, individual peer values/ranks, Redis keys, job payloads, or AI secrets.
Rate and size limits
Approved existing limits remain unchanged:
- login: five failures/normalized username and 30 failures/source IP per five minutes;
- analytics start: 10/minute, burst 3, independently per user and merchant;
- failed-section retry: 3/10 minutes, burst 1, independently per user and merchant;
- SSE: two concurrent streams/user and four/merchant;
- section attempt: 60 seconds and at most two transient retries.
API boundary limits:
- Approved: JSON request bodies are limited to 16 KiB; requests with no defined body reject non-empty bodies.
- Approved: the request target/query string is limited to 8 KiB at the public edge.
- Approved: detail pages default to 25 rows and cannot exceed 100.
- Approved: synchronous CSV export is limited to 100,000 rows, 50 MiB estimated uncompressed output, and 60 seconds, with 3 requests/10 minutes and burst 1 per user and merchant.
Limits are enforced before enqueuing or expensive work where possible. User and merchant buckets are independent; exhausting one never consumes another tenant's allowance.
Audit and observability
Audit events
Record login success/failure category, logout, analytics start/deduplication, manual retry, SSE authorization/replay reset, detail access, and CSV export requested/completed/rejected/aborted. Export audit records include user id, merchant id, canonical view/filter fingerprint, period id, row count, byte count, result, request id, and timestamps—not exported row values, payer tokens, credentials, raw cursors, filenames from users, or peer data.
Minimal demo authentication audit retention remains 30 days. Analytics-access and CSV-export audit records are retained for 90 days, are accessible only to explicitly authorized backend operators, and are automatically deleted after the retention window. The MVP has no merchant-facing audit endpoint or audit-history UI.
Metrics and traces
Measure request duration/status/code/response size; rate-limit rejections by operation/bucket type; run deduplication; section queue/calculation/cache/retry behavior; SSE open duration, heartbeat, replay/reset, disconnect; detail query duration/page size; and export preflight/query/stream duration, rows, bytes, rejection/abort reason.
Correlate requestId, authenticated user/merchant internal ids, run id, section id, job id, and event id in structured telemetry. Do not place usernames, source ids, session ids from payment data, raw filter values, cookies, CSRF values, payer tokens, or merchant row payloads in metric labels or traces.
Acceptance criteria
- OpenAPI validates as 3.1 and defines every operation, parameter, body, response, security requirement, SSE event, CSV header, and RFC 9457 problem schema named by this spec.
- Generated frontend types can consume JSON schemas without handwritten duplicate DTOs; all
operationIdvalues and schema names are stable and unique. - Every protected endpoint resolves the active merchant from the server-side session and produces the same
404for absent and cross-tenant resource ids. - Login/logout/session behavior, cookie flags, CSRF/origin checks, expiry, revocation, and throttling match the approved Identity spec.
- Period discovery returns exact UTC and Tehran-local half-open boundaries plus a ready-to-display Persian/Jalali label; arbitrary ranges are rejected.
- Equivalent analytics starts deduplicate, idempotency-key payload conflicts fail, and all four section states/results match the Analytics spec and metric catalogue.
- SSE delivers independently completed sections, 15-second heartbeats, ordered one-hour replay, snapshot reset after replay loss, sibling failure isolation, and reauthorization after session changes.
- Detail pagination is deterministic, defaults to 25, caps at 100, and rejects a cursor reused across filters or tenant boundaries.
- Each detail view reconciles with its approved aggregate and exposes no payer token, credential, peer row, undocumented cause, or field unavailable to that view.
- CSV applies the same tenant/filter predicate and allowlisted projection as JSON detail, uses the documented UTF-8/CSV/content-disposition contract, neutralizes spreadsheet formulas, and emits no partial success audit on interruption.
- Request/rate/export limits reject before expensive work and return Problem Details plus
Retry-Afterwhere applicable. - Problem responses keep HTTP status and
statusaligned, expose stablecode, and leak no stack, SQL, secret, cursor content, peer identity, or cross-tenant existence. - Contract/security tests cover schema examples, unknown fields/params, cross-tenant ids and cursors, stale event ids, idempotency races, export filter parity, formula injection, row/byte/deadline caps, and sensitive-field absence.
- Analytics-access and CSV-export audit records are visible only to authorized backend operators and are deleted after 90 days.
Boundaries
- Always: treat OpenAPI as source of truth; derive tenant scope from the session; validate inputs at the HTTP boundary; preserve approved metric/period semantics; use RFC 9457; paginate detail lists; reauthorize streams/exports; audit sensitive actions without payloads.
- Ask first: change an endpoint/schema/error code, expose a new field/filter/detail/export, change a rate/size/retention limit, add a role, change cookie/CORS/CSRF behavior, or add a cross-service/public interface.
- Never: accept merchant id as authority; expose payer tokens, credentials, peer rows, internal errors, or raw infrastructure state; silently broaden invalid filters; return partial CSV as success; let OpenAPI drift behind implementation.
Resolved API decisions
- Merchant-facing detail CSV export is synchronous in the MVP and honors the current authorized tenant scope and approved filters.
- CSV export is capped at 100,000 rows, 50 MiB estimated uncompressed output, and a 60-second deadline.
- CSV export is limited to 3 requests per 10 minutes with burst 1 in independent authenticated-user and merchant buckets.
- CSV uses UTF-8 with a BOM, Persian column names separated by normal spaces, and Persian status labels. Numeric cells remain plain calculation-safe numbers and timestamps remain UTC ISO values.
- Analytics-access and CSV-export audit records are retained for 90 days and are accessible only to explicitly authorized backend operators; the MVP exposes no merchant audit-history surface.
- Detail responses and CSV exports show the existing opaque source terminal reference as
شناسه پایانه; it is tenant-scoped trace data and never an authorization input. - RFC 9457 problem
typeURIs usehttps://zarinpal.amiirkhl.ir/docs/errors/{error-slug}and resolve to human-readable error documentation. - Public-edge requests are limited to 16 KiB for JSON bodies and 8 KiB for the request target/query string.
Remaining product questions
None.