diff --git a/CONTEXT.md b/CONTEXT.md index f94802f..04761d0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -66,11 +66,11 @@ A deterministic numerical measure with a definition, source fields, filters, per ## Analytics Projection -A rebuildable, versioned analytics result derived from normalized PostgreSQL facts and cached in Redis for one merchant/cohort revision. It is not independent business truth. +A rebuildable, versioned analytics result derived from normalized PostgreSQL facts and cached in Redis for one immutable dataset snapshot. It is not independent business truth. -## Analytics Revision +## Dataset Snapshot -A monotonically increasing PostgreSQL merchant or cohort version changed when a related payment fact is inserted. Redis cache keys include the revisions they represent; a mismatch causes request-triggered section jobs to calculate new keys while older keys expire through TTL. +The one completed import run selected for the MVP. Its immutable import-run id, source checksum, and schema version identify the PostgreSQL facts represented by a Redis cache key. The MVP does not mutate payment facts or require cache-invalidation triggers. ## Analytics Section diff --git a/docs/ANALYTICS-SPEC.md b/docs/ANALYTICS-SPEC.md index ca7bb27..607284b 100644 --- a/docs/ANALYTICS-SPEC.md +++ b/docs/ANALYTICS-SPEC.md @@ -29,7 +29,7 @@ Turn normalized ZarinPal payment facts into trustworthy, merchant-scoped analyti - Current-period trends against an approved equivalent baseline - Same-category merchant-weighted peer median and performance percentile - Lazy, bounded parallel calculation with partial SSE delivery -- Versioned 30-day Redis cache and PostgreSQL revision invalidation +- Versioned 30-day Redis cache keyed by the immutable MVP dataset snapshot - Traceability from aggregate metrics to permitted merchant-owned evidence ## Out of scope for MVP @@ -61,7 +61,7 @@ Every section uses a versioned typed contract and has independent `queued`, `cal - Rates are calculated from summed numerator and denominator components for the selected period, never averaged from daily percentages. - Timing percentiles exclude null values and expose sample size and non-null coverage; missing time never becomes zero. - `NoAttempt` markers create zero-attempt sessions and never count as attempts or retries. `InBank` is an observed attempt stage, not proof of rejection. -- Every result states the exact Tehran-local half-open period, Jalali label, unit, sample/denominator, source revision, metric-definition version, and calculation timestamp. +- Every result states the exact Tehran-local half-open period, Jalali label, unit, sample/denominator, dataset snapshot, metric-definition version, and calculation timestamp. - Approved period types are one Tehran-local day, one Saturday–Friday week, one Jalali month, or one Persian season. Holiday/event dimensions do not exist in the MVP contract. ## Peer comparison contract @@ -75,13 +75,13 @@ Every section uses a versioned typed contract and has independent `queued`, `cal ## Calculation and cache lifecycle -1. An idempotent authenticated start request resolves merchant, selected period, merchant revision, cohort revision, and metric-definition version. +1. An idempotent authenticated start request resolves merchant, selected period, immutable dataset snapshot, and metric-definition version. 2. Equivalent requests map to one current analytics run and do not enqueue duplicate work. 3. Missing section results are enqueued together and calculated independently by the Go worker through Asynq `v0.26.0`, explicitly a pre-1.0 dependency pinned for compatibility. 4. Worker limits default to 16 jobs globally and four per merchant. All four core sections begin in bounded parallel and become visible independently. 5. Each attempt times out after 60 seconds. Transient failures receive at most two retries using exponential backoff of approximately one then two seconds plus jitter; deterministic failures are not retried. -6. Successful section results are published atomically to Redis under revisioned/versioned keys with a 30-day TTL. PostgreSQL facts and revision rows remain authoritative. -7. Relevant fact inserts advance the affected merchant and category/cohort revisions in PostgreSQL. New requests use new keys; old keys expire without scans. +6. Successful section results are published atomically to Redis under keys containing the immutable dataset snapshot, merchant, period, section, and metric-definition version, with a 30-day TTL. PostgreSQL normalized facts remain authoritative. +7. Payment facts do not change during the hackathon MVP, so no insert/update/delete invalidation trigger exists. Any future dataset replacement requires a separately approved recovery and cache-namespace-selection contract. 8. Redis loss causes safe lazy recomputation. It never causes loss of payment facts or metric definitions. ## Partial delivery contract @@ -131,7 +131,7 @@ Every section uses a versioned typed contract and has independent `queued`, `cal - `Paid` plus `Verified` success, verification completion, retry recovery, and timing coverage match catalogue definitions. - Saturday–Friday, Jalali-month, and Persian-season boundaries match `calendar_dates` and API half-open ranges. - First access starts all missing sections once; ready sections stream independently and equivalent concurrent starts deduplicate. -- Cache hits avoid repeating source aggregation; merchant/cohort revision changes select fresh keys and recompute only missing sections. +- Cache hits avoid repeating source aggregation; the immutable completed-import snapshot keeps equivalent requests in one stable namespace. - 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. diff --git a/docs/API-SPEC.md b/docs/API-SPEC.md index 2d4195d..8fba08f 100644 --- a/docs/API-SPEC.md +++ b/docs/API-SPEC.md @@ -81,15 +81,15 @@ The default is the latest complete Jalali month containing source data. Weekly p ## 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, merchant/cohort revisions, and metric-definition version. +`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. - 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 revision resolve to the same run even when their valid intent keys differ. +- 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 `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, revisions, 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, 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. ## Metric response rules diff --git a/docs/DATA-FOUNDATION-SPEC.md b/docs/DATA-FOUNDATION-SPEC.md index 6722996..ab513ae 100644 --- a/docs/DATA-FOUNDATION-SPEC.md +++ b/docs/DATA-FOUNDATION-SPEC.md @@ -16,7 +16,7 @@ Perform a one-time, repeatable bootstrap of the supplied payment-attempt archive - Import runs, checksums, schema version, row counts, rejection/error reporting - Normalized merchant, category, terminal, payment-session, payment-attempt, and account/profile concepts - Gregorian/Jalali reporting dates with day, week, month, and Persian-season attributes -- PostgreSQL merchant/cohort invalidation revisions for versioned Redis analytics cache keys +- Immutable completed-import snapshot identity for versioned Redis analytics cache keys - Synthetic merchant display profiles and one demo account per merchant - Idempotent re-runs and explicit import lifecycle - Database constraints/indexes driven by documented access paths @@ -42,7 +42,6 @@ erDiagram TERMINAL ||--o{ PAYMENT_SESSION : handles PAYMENT_SESSION ||--o{ PAYMENT_ATTEMPT : contains IMPORT_RUN ||--o{ PAYMENT_SESSION : introduced_by - IMPORT_RUN ||--o{ PAYMENT_ATTEMPT : introduced_by IMPORT_RUN ||--o{ IMPORT_ISSUE : reports MERCHANT_CATEGORY { @@ -61,6 +60,7 @@ erDiagram bigint id PK text source_terminal_key UK bigint merchant_id FK + text verification_type } MERCHANT_USER { bigint id PK @@ -90,13 +90,11 @@ erDiagram text psp_code text issuer_bank_code text payer_card_key - text verify_type int init_time_ms int verify_time_ms timestamptz try_created_at timestamptz verified_at timestamptz settled_at - timestamptz expires_at } IMPORT_RUN { bigint id PK @@ -142,27 +140,25 @@ This contract applies only to the one-time challenge-data bootstrap and explicit A source `(try_seq = 0, try_status = 'NoAttempt')` record creates the session but no `payment_attempt` row; all normalized attempts require `try_seq >= 1`. 6. Promote accepted data transactionally or through a resumable, explicitly versioned workflow; a failed run cannot appear complete. 7. Emit a machine-readable and human-readable import report. Do not log raw card keys or credentials. -8. Populate and validate the approved reporting-date range, then initialize merchant/cohort analytics revisions. Do not eagerly calculate dashboard results; first relevant access starts lazy section jobs. -9. Never schedule, watch, or poll for another CSV. After bootstrap, production source-row creation is an external ZarinPal responsibility. Insert-triggered revisions make prior Redis cache keys obsolete. +8. Populate and validate the approved reporting-date range. The sole completed import run identifies the immutable MVP dataset snapshot. Do not eagerly calculate dashboard results; first relevant access starts lazy section jobs. +9. Never schedule, watch, poll for, or automatically re-import another CSV. Replacing the dataset is an explicit recovery operation outside ordinary service startup. -## Production write and lazy analytics-cache contract +## Static MVP data and lazy analytics-cache contract -- ZarinPal is the authoritative writer of future production payment sessions/attempts in the normalized PostgreSQL schema. -- This project owns the normalized schema contract, migrations, read models, and analytics; it does not recreate ZarinPal's payment-write workflow. -- Payment facts are append-only in the MVP. Existing session/attempt updates and hard deletion are out of scope; a future mutation requirement needs a new invalidation contract. -- An idempotent authenticated `POST` starts or returns the current analytics run for the merchant/revision. It enqueues the four MVP sections—`overview`, `payment_performance`, `fees`, and `peer_comparison`—for bounded parallel calculation by a separate Go worker service. +- The hackathon MVP reads only the immutable challenge dataset after its one-time PostgreSQL bootstrap. +- No service inserts, updates, or deletes payment facts during normal MVP operation. Future ZarinPal production writes require a separate integration and cache-invalidation specification after acceptance. +- An idempotent authenticated `POST` starts or returns the current analytics run for the merchant/dataset snapshot. It enqueues the four MVP sections—`overview`, `payment_performance`, `fees`, and `peer_comparison`—for bounded parallel calculation by a separate Go worker service. - Use Asynq pinned to `v0.26.0` as the Redis-backed task queue. Asynq is pre-`v1.0.0`; never use a floating version, and review API/behavior compatibility before upgrading the pin. -- Each completed section is serialized through its approved typed contract and cached in Redis under a key containing merchant id, merchant source revision, relevant cohort revision, metric-definition version, and section id. Cache entries have a 30-day TTL. +- Each completed section is serialized through its approved typed contract and cached in Redis under a key containing the sole completed import-run snapshot, merchant id, selected period, metric-definition version, and section id. Cache entries have a 30-day TTL. - A separate authenticated `GET` SSE stream for that run emits section progress and makes each successful section available immediately; the dashboard does not wait for every job to finish. - Redis-backed idempotency/single-flight coordination prevents equivalent concurrent requests from duplicating the same section calculation. Parallelism is bounded globally and per merchant. - Worker concurrency defaults to 16 jobs globally and 4 jobs per merchant, configurable downward/upward through validated environment settings. -- Every inserted related payment fact atomically advances the affected PostgreSQL merchant analytics revision. It also advances the relevant cohort/category revision when peer metrics can change. Database-managed revision changes cover direct ZarinPal inserts that bypass Go request handlers. -- Revision changes invalidate logically by producing new cache keys; old values are not trusted and expire through TTL without requiring a Redis key scan. +- No database invalidation trigger or revision table exists for the static MVP. Any future dataset replacement requires a separately approved recovery and cache-namespace-selection contract. - Each section attempt has a 60-second timeout. Transient failures receive at most two automatic retries (three total attempts) with exponential backoff and jitter; deterministic validation/authorization failures are not retried. Exhausted sections emit a typed failure event without marking the section ready. Other successful sections remain available. - Rate limits use independent authenticated-user and merchant buckets: analytics starts allow 10 requests/minute with burst 3; manual failed-section retries allow 3 requests/10 minutes with burst 1; SSE allows 2 concurrent connections/user and 4/merchant. Rejections return `429` with `Retry-After` and do not enqueue work. - SSE sends a heartbeat every 15 seconds and retains replayable run events for one hour. Reconnects resume from the last authorized event id where available; after retention, the client fetches the current run snapshot and reopens the stream. -- Manual retry is available only for an exhausted failed section, is idempotent for the current revisions, and never clears or restarts successful sibling sections. -- Redis is cache/coordination only. PostgreSQL normalized facts and revision rows remain authoritative; loss or flush of Redis causes safe lazy recomputation. +- Manual retry is available only for an exhausted failed section, is idempotent for the current dataset snapshot, and never clears or restarts successful sibling sections. +- Redis is cache/coordination only. PostgreSQL normalized facts and the sole completed import run remain authoritative; loss or flush of Redis causes safe lazy recomputation. - Redis and the on-demand job worker are private services, never exposed through Nginx. Cache values exclude payer-card keys, credentials, raw payment rows, and cross-tenant details. ## Synthetic merchant/account rules @@ -189,7 +185,7 @@ This contract applies only to the one-time challenge-data bootstrap and explicit - Source `adjusted_fee` is normalized once per session as `fee_amount_rial`; retry attempts never multiply fee totals. - First access idempotently creates/returns one run, starts all four core analytics sections in bounded parallel, and delivers each through authenticated SSE as soon as it is ready. - A second equivalent request reads current 30-day Redis entries without repeating source-fact aggregation. -- Inserting a related payment fact advances only the affected merchant revision and any affected cohort revision; the next relevant request recomputes once and publishes atomically. +- Equivalent requests for the immutable completed import snapshot reuse or rebuild the same versioned cache namespace without database triggers. - Concurrent equivalent requests do not duplicate section jobs; configured global/per-merchant limits prevent unbounded query fan-out. - One failed section does not hide ready sections and does not create a ready cache entry. - A transient section failure performs no more than two retries with exponential backoff/jitter; each attempt ends within 60 seconds, while non-retryable failures fail immediately. @@ -218,7 +214,7 @@ Resolved: peer benchmarks use other merchants in the active merchant's category Resolved: use normalized PostgreSQL source-of-truth entities plus a reporting-date dimension, then compute versioned analytics sections lazily into Redis. Daily, weekly, Jalali-month, and Persian-season results use explicit date boundaries. Keep fact tables unpartitioned initially and introduce partitioning only from measured need. See ADR 0007. -Resolved: ZarinPal writes future append-only production rows directly to normalized tables. An idempotent `POST` starts four sections through Asynq `v0.26.0` (explicitly pre-1.0) in a separate Go worker, limited to 4 concurrent jobs/merchant and 16 globally. Redis caches results for 30 days; authenticated SSE uses 15-second heartbeats and one-hour replay. Sections have a 60-second attempt timeout and two transient exponential-backoff retries. Start, SSE, and manual-retry operations are rate-limited. PostgreSQL revisions provide invalidation. See ADR 0011. +Resolved: the MVP reads one immutable completed-import snapshot and performs no payment-fact mutations. An idempotent `POST` starts four sections through Asynq `v0.26.0` (explicitly pre-1.0) in a separate Go worker, limited to 4 concurrent jobs/merchant and 16 globally. Redis caches results for 30 days; authenticated SSE uses 15-second heartbeats and one-hour replay. Sections have a 60-second attempt timeout and two transient exponential-backoff retries. Start, SSE, and manual-retry operations are rate-limited. No PostgreSQL cache-invalidation trigger exists in the MVP. See ADR 0011. Resolved: holiday and named-event reporting are removed from scope. MVP reporting periods are limited to daily, weekly, Jalali-month, and Persian-season views. See ADR 0010. diff --git a/docs/PHYSICAL-SCHEMA-ERD.html b/docs/PHYSICAL-SCHEMA-ERD.html index 1526584..233f380 100644 --- a/docs/PHYSICAL-SCHEMA-ERD.html +++ b/docs/PHYSICAL-SCHEMA-ERD.html @@ -103,7 +103,7 @@ ROOT payment_sessions - # id bigint→ merchant_id→ terminal_idsource_session_key UKamount_rialfee_amount_rialsession_statuscreated_at / expires_at→ import_run_id nullable + # id bigint→ merchant_id→ terminal_idsource_session_key UKamount_rialfee_amount_rialsession_statuscreated_at / expires_at→ import_run_id @@ -134,17 +134,15 @@
-
Identity, audit, reporting calendar, and cache revisions
+
Identity, audit, and reporting calendar
Operational physical entity relationships - Merchants own demo users and merchant analytics revisions; users own server-side sessions, 30-day authentication audit events, and 90-day analytics access audit events; categories own cohort revisions; the reporting calendar remains an independent date dimension. + Merchants own demo users; users own server-side sessions, 30-day authentication audit events, and 90-day analytics access audit events; the reporting calendar remains an independent date dimension. The immutable MVP needs no cache revision entities. 1N 1N 1N 1N - 11 - 1N @@ -176,31 +174,19 @@ analytics_access_audit_events # id bigint→ merchant_user_id + merchant_idevent_type / resultfilter_fingerprint?row_count? / byte_count?expires_at (90 days) - - - STATE - merchant_analytics_revisions - # → merchant_idsource_revisionupdated_at - ROOT merchant_categories # id bigintsource_category_key UKtitle_fa - - - STATE - cohort_analytics_revisions - # → category_idsource_revisionupdated_at - DIM calendar_dates# gregorian_date + Jalali periods -
Identity is demo-sized and replaceable by SSORevision rows select cache namespacesCalendar contains no holiday dimension
+
Identity is demo-sized and replaceable by SSODataset snapshot comes from the completed import runCalendar contains no holiday dimension

Typography note: this standalone backend document uses a system fallback to remain self-contained. The product UI uses the licensed Git-tracked Iran Yekan X FaNum variable webfont. Diagram palette follows the approved ZarinPal-inspired light/dark semantic tokens.

diff --git a/docs/PHYSICAL-SCHEMA-PROPOSAL.md b/docs/PHYSICAL-SCHEMA-PROPOSAL.md index 481dbdb..599a5c3 100644 --- a/docs/PHYSICAL-SCHEMA-PROPOSAL.md +++ b/docs/PHYSICAL-SCHEMA-PROPOSAL.md @@ -1,7 +1,7 @@ # PostgreSQL Physical Schema Specification -- Status: Decision-ready draft — stakeholder approval required -- Scope: Data foundation, demo identity persistence, and analytics invalidation state +- Status: Approved specification — implementation not yet authorized +- Scope: Data foundation, demo identity persistence, and immutable dataset cache identity - Implementation authorization: Not granted - Last updated: 2026-08-20 - Visual companion: [`PHYSICAL-SCHEMA-ERD.html`](./PHYSICAL-SCHEMA-ERD.html) @@ -26,7 +26,7 @@ Define the implementation-ready PostgreSQL shape for the normalized ZarinPal arc 2. A terminal belongs to exactly one merchant. 3. A payment session belongs to exactly one merchant and one terminal; a composite foreign key proves that the terminal belongs to that merchant. 4. A payment attempt belongs to exactly one session and has a positive sequence unique within that session. -5. A bootstrap session belongs to exactly one completed import lineage; a live session has no synthetic import run. +5. Every MVP payment session belongs to exactly one completed import lineage. 6. Attempt provenance is inherited through its session rather than duplicated on every attempt. 7. PSP and issuer-bank codes are reference entities. Switch response codes remain nullable PSP-scoped text because no authoritative meaning catalogue was supplied. 8. Payer-card tokens are nullable, merchant-scoped through the session relationship, and never globally unique. @@ -56,7 +56,7 @@ Define the implementation-ready PostgreSQL shape for the normalized ZarinPal arc | `started_at` | `timestamptz` required | run start | | `completed_at` | `timestamptz` nullable | terminal completion/failure time | -A partial unique index on `(source_sha256, source_schema_version, importer_revision)` where `status = 'completed'` makes a completed import idempotent while permitting explicit retry after a failed run. Terminal state requires `completed_at`; non-terminal state forbids it. +A partial unique index on `(source_sha256, source_schema_version)` where `status = 'completed'` makes a completed source snapshot idempotent across importer rebuilds while permitting explicit retry after a failed run. `importer_revision` remains provenance, not business identity. Terminal state requires `completed_at`; non-terminal state forbids it. #### `import_issues` @@ -206,10 +206,9 @@ The ownership FK guarantees that the user belongs to the recorded merchant. Chec | `session_status` | `text` required | non-empty source lifecycle code | | `created_at` | `timestamptz` required | source Tehran-local value normalized to UTC | | `expires_at` | `timestamptz` required | source `expire_in` normalized to UTC | -| `source_origin` | `text` required | `bootstrap` or `zarinpal_live` | -| `import_run_id` | `bigint` nullable | restrictive FK to `import_runs` | +| `import_run_id` | `bigint` required | restrictive FK to the completed `import_runs` lineage | -The origin check requires an import run for `bootstrap` and forbids one for `zarinpal_live`. Timestamp ordering is not enforced because source ordering anomalies are preserved as evidence rather than rewritten; expiry-before-creation, if found during exact validation, is reported according to the import specification. +Timestamp ordering is not enforced because source ordering anomalies are preserved as evidence rather than rewritten; expiry-before-creation, if found during exact validation, is reported according to the import specification. Approved indexes: @@ -238,27 +237,11 @@ Approved indexes: Unique `(payment_session_id, try_seq)` is the attempt identity and lookup index. A check requires `issuer_bank_id` and `payer_card_key` to be either both null or both populated, matching the authoritative data guide and observed missingness. No timestamp-order check is used because documented anomalies are retained. No standalone payer-token, bank, PSP, or status index is added until an approved measured query justifies it. -### Analytics invalidation +### Static dataset cache namespace -#### `merchant_analytics_revisions` +The MVP performs no normal payment-fact writes after the one-time bootstrap, so the physical schema contains no analytics-revision tables and no insert/update/delete invalidation triggers. The importer permits one completed dataset in the MVP; Redis keys include that completed `import_runs.id` plus its immutable source checksum/schema version, merchant, period, section, and metric-definition version. -`merchant_id bigint` PK/FK to `merchants`; `source_revision bigint` required default `0` and non-negative; `updated_at timestamptz` required. - -#### `cohort_analytics_revisions` - -`category_id bigint` PK/FK to `merchant_categories`; `source_revision bigint` required default `0` and non-negative; `updated_at timestamptz` required. - -Revision values are cache namespace markers, not transaction counts. They may advance more than once in one business operation without changing correctness. - -## Database-managed revision contract - -Direct ZarinPal inserts may bypass Go, so invalidation cannot rely only on application callbacks. Versioned PostgreSQL statement-level `AFTER INSERT` triggers with transition tables update each affected merchant and category once per insert statement: - -- session insert: use merchant/category relationships from the inserted sessions; -- attempt insert: join inserted attempts to their sessions, merchants, and categories; -- update/delete triggers do not exist in MVP because payment facts are append-only. - -This is deliberate schema-owned logic, named in migrations and covered by integration tests. Bulk promotion remains efficient because a multi-row `INSERT ... SELECT` advances each affected revision once, not once per imported row. Trigger failure aborts the fact insert; analytics can never observe a new fact under an old revision. +Dataset replacement and future ZarinPal live writes are outside the MVP. Either change requires a separately approved recovery, namespace-selection, and invalidation design before payment-fact mutation is allowed. ## Staging boundary @@ -266,7 +249,7 @@ Importer staging tables are run-scoped, migration-owned technical structures rat ## Deletion, mutation, and retention -- Imported and live payment facts are append-only in MVP. Corrections require a new specification/ADR and matching invalidation behavior. +- Imported payment facts are immutable in MVP. Corrections or live writes require a new specification/ADR and matching invalidation behavior. - Merchant/category/terminal/import provenance referenced by facts cannot be deleted. Synthetic display names and password hashes may be updated through approved identity workflows. - Session rows are revoked/expired, not hard-deleted during their security lifetime. Authentication audit events expire after 30 days; analytics-access and CSV-export audit events expire after 90 days. Cleanup is allowed only after `expires_at` and must not remove payment/import facts. - Redis analytics entries are disposable and expire after 30 days; no calculated analytics table exists in PostgreSQL. @@ -275,7 +258,7 @@ Importer staging tables are run-scoped, migration-owned technical structures rat - Atlas-generated versioned SQL is reviewed and committed; production startup never auto-migrates. - Migration tests create the schema from zero and upgrade from every supported release state. -- Constraint tests cover terminal ownership, user/audit tenant ownership, no-attempt exclusion, attempt uniqueness, bootstrap/live provenance, paired card/bank nullability, session token digest length, audit fingerprint length, and revision-trigger atomicity. +- Constraint tests cover terminal ownership, user/audit tenant ownership, no-attempt exclusion, attempt uniqueness, bootstrap provenance, paired card/bank nullability, session token digest length, and audit fingerprint length. - Query-plan verification uses production-shaped volumes and `EXPLAIN (ANALYZE, BUFFERS)` for approved analytics, detail pagination, CSV export, and session-authentication paths before adding further indexes. - Initial fact tables are not partitioned. Partitioning requires measured operational need and a separate ADR. @@ -287,20 +270,20 @@ Importer staging tables are run-scoped, migration-owned technical structures rat - Session-level amount, fee, merchant, terminal, status, creation, expiry, origin, and provenance are stored once. - `verify_type` is stored once per terminal and reconciles to every source record for that terminal. - Payer tokens remain unchanged and restricted; they are absent from uniqueness constraints, logs, exports, peer results, and AI inputs. -- Direct session/attempt inserts atomically select new merchant/cohort revision namespaces. +- The sole completed import run provides an immutable dataset cache namespace without database triggers. - Approved period, authentication, drill-down, and export access paths have an explicit initial index or a documented measure-first rule. -- Schema creation, FK/check constraints, trigger behavior, 30/90-day retention cleanup, and query plans are integration-tested before implementation approval can be considered complete. +- Schema creation, FK/check constraints, snapshot selection, 30/90-day retention cleanup, and query plans are integration-tested before implementation approval can be considered complete. ## Boundaries -- Always: preserve source keys and nulls, enforce tenant ownership with FKs, store money at session grain, use UTC instants, version migrations, and test revision changes atomically. +- Always: preserve source keys and nulls, enforce tenant ownership with FKs, store money at session grain, use UTC instants, version migrations, and treat imported payment facts as immutable. - Ask first: add a table/column/index, denormalize tenant/category data, introduce partitioning, classify a new lifecycle status, or permit fact mutation/deletion. - Never: create one wide CSV table, create a fake no-attempt row, duplicate session money on attempts, trust file order, store raw session tokens, or hide production migrations in startup. -## Decision batch for approval +## Resolved decisions 1. Store terminal-invariant `verify_type` on `terminals`, not every payment attempt. 2. Preserve future non-empty lifecycle status values as text and classify them through the metric catalogue instead of using rigid PostgreSQL enums. -3. Use explicit statement-level insert triggers for merchant/cohort analytics revisions because direct ZarinPal writes can bypass Go. +3. Omit cache-revision tables and database triggers because the MVP dataset is immutable; use the sole completed import run as the Redis cache namespace. 4. Persist minimal 30-day authentication audit events and 90-day analytics/export audit events because the approved Identity and API specs require queryable operator evidence without raw payloads, cursors, credentials, or sensitive data. 5. Approve the table, constraint, index, and restrictive-deletion contract above as the migration target. diff --git a/docs/decisions/0007-calendar-aware-analytics-projections.md b/docs/decisions/0007-calendar-aware-analytics-projections.md index 39a60bd..b1bc4a2 100644 --- a/docs/decisions/0007-calendar-aware-analytics-projections.md +++ b/docs/decisions/0007-calendar-aware-analytics-projections.md @@ -17,7 +17,7 @@ Add a reporting-date model containing Gregorian and Jalali date attributes, expl Build idempotent, rebuildable analytics-section queries containing additive components such as session count, attempt count, successful count, amount sum, and fee sum as approved by the metric catalogue. Derive week/month/season totals through the reporting-date model and recompute ratios from their numerators and denominators. -Calculate non-additive metrics such as exact unique payer-token counts directly at their requested grain; never obtain them by summing daily distinct counts. Cache each typed section in Redis under source/cohort and metric-definition revisions. +Calculate non-additive metrics such as exact unique payer-token counts directly at their requested grain; never obtain them by summing daily distinct counts. Cache each typed section in Redis under the immutable completed-import snapshot and metric-definition version. Keep normalized fact tables unpartitioned initially. Add targeted indexes from approved API access paths and verified query plans. Introduce PostgreSQL partitioning only through a future ADR supported by measured table growth, query latency, vacuum/maintenance behavior, or retention operations. @@ -26,5 +26,5 @@ Keep normalized fact tables unpartitioned initially. Add targeted indexes from a - The normalized ERD remains semantically correct while dashboards reuse disposable Redis results. - Section calculations support approved period groupings without storing calculated facts as PostgreSQL business truth. - Explicit reporting-period definitions and metric provenance make future AI observations traceable. -- Lazy parallel calculation, Redis revision keys, job deduplication, partial failure, and SSE visibility tests become required behavior. +- Lazy parallel calculation, dataset-versioned Redis keys, job deduplication, partial failure, and SSE visibility tests become required behavior. - Schema/index/cache details remain subject to data profiling and approved metric/API requirements. diff --git a/docs/decisions/0011-lazy-redis-analytics-cache.md b/docs/decisions/0011-lazy-redis-analytics-cache.md index 653ad9a..8ef3ac0 100644 --- a/docs/decisions/0011-lazy-redis-analytics-cache.md +++ b/docs/decisions/0011-lazy-redis-analytics-cache.md @@ -5,15 +5,15 @@ ## Context -The hackathon dataset is static after its one-time bootstrap. Continuously refreshing analytics would spend resources before a merchant requests them. A first calculation may contain several independent dashboard sections, and the user should see completed sections without waiting for the slowest query. Future append-only rows may be written directly by ZarinPal, bypassing Go request handlers. +The hackathon dataset is static after its one-time bootstrap. Continuously refreshing analytics would spend resources before a merchant requests them. A first calculation may contain several independent dashboard sections, and the user should see completed sections without waiting for the slowest query. Production ZarinPal write integration is outside the MVP. ## Decision -On first dashboard access, an authenticated idempotent `POST` creates or returns the current analytics run for that merchant/source/cohort/metric-definition revision. It enqueues four section ids—`overview`, `payment_performance`, `fees`, and `peer_comparison`—for bounded parallel calculation by a separate Go worker service. This worker is event-driven, not scheduled. +On first dashboard access, an authenticated idempotent `POST` creates or returns the current analytics run for that merchant/dataset-snapshot/metric-definition version. It enqueues four section ids—`overview`, `payment_performance`, `fees`, and `peer_comparison`—for bounded parallel calculation by a separate Go worker service. This worker is event-driven, not scheduled. Use `github.com/hibiken/asynq` pinned to `v0.26.0` as the Redis-backed queue/worker library. Asynq is explicitly pre-`v1.0.0`, and its public API may change without a major-version increment. Do not float the dependency or upgrade it without reviewing release notes, API compatibility, Redis compatibility, retry/deduplication behavior, and integration tests. -Cache each completed typed section in Redis using a key derived from the merchant id, merchant source revision, relevant cohort revision, metric-definition version, and section id. Use a 30-day TTL. Redis-backed idempotency/single-flight coordination prevents duplicate equivalent jobs. Old-revision keys expire naturally and are never treated as current. +Cache each completed typed section in Redis using a key derived from the sole completed import-run snapshot, merchant id, period, metric-definition version, and section id. Use a 30-day TTL. Redis-backed idempotency/single-flight coordination prevents duplicate equivalent jobs. A future explicitly replaced dataset would require a newly specified namespace-selection rule. Expose a separate authenticated `GET` SSE endpoint for the run. Stream stable event ids and typed events for section started, section ready, section failed, overall complete, and heartbeat. Support safe reconnect without exposing another merchant’s jobs or values. One section failure does not suppress sections that succeeded. @@ -27,11 +27,11 @@ Apply authenticated rate limits independently from job deduplication: - manual retry: 3 requests/10 minutes in independent per-user and per-merchant buckets, burst 1; - SSE: 2 concurrent connections per user and 4 per merchant. -Return `429 Too Many Requests` with `Retry-After` before enqueuing work. A manual retry targets only one exhausted failed section for the current revisions and is idempotent; it cannot restart ready siblings. +Return `429 Too Many Requests` with `Retry-After` before enqueuing work. A manual retry targets only one exhausted failed section for the current dataset snapshot and is idempotent; it cannot restart ready siblings. Send SSE heartbeat events every 15 seconds. Retain run events for one hour for authorized `Last-Event-ID` replay. After replay retention expires, return/provide the current run snapshot and continue from new events rather than replaying another merchant’s or an unbounded event history. -Schema-owned PostgreSQL logic atomically increments the affected merchant revision whenever a related payment fact is inserted. Inserts affecting peer benchmarks also increment the corresponding category/cohort revision. This covers direct ZarinPal writes. Payment facts are append-only for the MVP; invalidation for update/delete remains out of scope. +Payment facts are immutable during the MVP, so PostgreSQL contains no cache-revision tables or invalidation triggers. Explicit dataset replacement is an administrative recovery workflow: it selects a different completed import run as the active snapshot and therefore creates a different Redis namespace. Any future live-write integration must define insert/update/delete invalidation in a separately approved specification. Redis is a private disposable cache and coordination system, not a source of truth. PostgreSQL remains authoritative. Cached values contain approved aggregates only and exclude raw rows, payer-card keys, credentials, session tokens, and cross-tenant details. @@ -43,7 +43,7 @@ Redis is a private disposable cache and coordination system, not a source of tru - The deployment includes private Redis and on-demand analytics-worker services. - Rate limits and concurrency values remain environment-configurable but changes require security/load evidence and a reviewed spec/config change. - The pre-1.0 Asynq pin creates upgrade risk; CI uses the committed Go module/checksum files and upgrades are deliberate. -- Trigger/revision behavior, tenant isolation, job deduplication, partial failures, SSE reconnect, and cache data minimization require integration/security tests. +- Snapshot selection, tenant isolation, job deduplication, partial failures, SSE reconnect, and cache data minimization require integration/security tests. ## References diff --git a/docs/openapi.yaml b/docs/openapi.yaml index cbd7771..31fa096 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -178,7 +178,7 @@ paths: operationId: createAnalyticsRun summary: Create or return the current analytics run description: | - Idempotently resolves the current merchant/source/cohort/metric revision + Idempotently resolves the current merchant/dataset/metric version and starts only missing sections. Equivalent concurrent starts deduplicate. parameters: - $ref: '#/components/parameters/IdempotencyKeyHeader' @@ -1052,8 +1052,7 @@ components: - runId - state - period - - merchantSourceRevision - - cohortRevision + - datasetSnapshotId - metricDefinitionVersion - createdAt - updatedAt @@ -1066,14 +1065,10 @@ components: $ref: '#/components/schemas/AnalyticsRunState' period: $ref: '#/components/schemas/ReportingPeriod' - merchantSourceRevision: + datasetSnapshotId: type: string - pattern: '^[0-9]+$' - description: Decimal string to avoid client integer-width loss. - cohortRevision: - type: string - pattern: '^[0-9]+$' - description: Decimal string to avoid client integer-width loss. + pattern: '^ds_[A-Za-z0-9_-]{8,60}$' + description: Opaque identifier for the immutable completed-import snapshot. metricDefinitionVersion: type: string const: 0.4.0 @@ -1147,14 +1142,14 @@ components: ReadySectionMetadata: type: object additionalProperties: false - required: [calculatedAt, sourceRevision, metricDefinitionVersion] + required: [calculatedAt, datasetSnapshotId, metricDefinitionVersion] properties: calculatedAt: type: string format: date-time - sourceRevision: + datasetSnapshotId: type: string - pattern: '^[0-9]+$' + pattern: '^ds_[A-Za-z0-9_-]{8,60}$' metricDefinitionVersion: type: string const: 0.4.0