20 KiB
PostgreSQL Physical Schema Specification
- 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
Objective
Define the implementation-ready PostgreSQL shape for the normalized ZarinPal archive without writing migrations. The schema preserves source identity and import provenance, separates the 2,062,839 payment sessions from 1,949,353 real attempts, enforces tenant relationships with foreign keys, supports approved analytics and CSV-detail access paths, and leaves Redis as a disposable result cache.
Evidence and conventions
- The archive contains 2,213,289 source records, including 263,936
NoAttemptmarkers. A marker creates a session and no attempt row. - Internal relational keys are
bigint GENERATED ALWAYS AS IDENTITY. Source identifiers remain non-emptytextwith unique constraints because they are opaque even when they look numeric. - Canonical instants are
timestamptzin UTC. Acalendar_datesdimension provides Tehran-local Gregorian/Jalali reporting attributes. - Money is
bigintRial.amount_rialmust be positive;fee_amount_rialmay be zero in production and must not be multiplied by attempt count. - Flexible upstream lifecycle/status codes remain non-empty
text, not PostgreSQL enums. Analytics uses a versioned allowlist so a future well-formed status can be preserved and reported as unclassified instead of making direct upstream writes fail. - All foreign keys are restrictive unless a table definition explicitly says otherwise. Payment and import facts are not cascade-deleted.
- Ent owns application entities and relationships; Atlas produces reviewed versioned SQL; pgx
COPYis restricted to staging/import paths. This document defines the target contract, not migration SQL.
Relationship invariants
- A merchant belongs to exactly one category.
- A terminal belongs to exactly one merchant.
- A payment session belongs to exactly one merchant and one terminal; a composite foreign key proves that the terminal belongs to that merchant.
- A payment attempt belongs to exactly one session and has a positive sequence unique within that session.
- Every MVP payment session belongs to exactly one completed import lineage.
- Attempt provenance is inherited through its session rather than duplicated on every attempt.
- PSP and issuer-bank codes are reference entities. Switch response codes remain nullable PSP-scoped text because no authoritative meaning catalogue was supplied.
- Payer-card tokens are nullable, merchant-scoped through the session relationship, and never globally unique.
- Verification method is terminal-level. A full archive scan found zero terminals with more than one
verify_type, while one merchant used different methods across its terminals.
Physical tables
Import provenance
import_runs
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
id |
bigint required |
identity primary key |
source_sha256 |
text required |
lowercase 64-character SHA-256 hex |
source_file_name |
text required |
basename only; never an operator filesystem path |
source_schema_version |
text required |
explicit supported input contract |
importer_revision |
text required |
immutable code/build revision |
status |
text required |
created, staging, validating, promoting, completed, or failed |
observed_rows |
bigint required default 0 |
source records read, non-negative |
accepted_source_rows |
bigint required default 0 |
accepted source records, non-negative |
rejected_source_rows |
bigint required default 0 |
rejected source records, non-negative |
created_sessions |
bigint required default 0 |
promoted session rows, non-negative |
created_attempts |
bigint required default 0 |
promoted real-attempt rows, non-negative |
failure_code |
text nullable |
stable safe failure category |
failure_summary |
text nullable |
bounded operator-safe summary without raw row data |
started_at |
timestamptz required |
run start |
completed_at |
timestamptz nullable |
terminal completion/failure time |
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
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
id |
bigint required |
identity primary key |
import_run_id |
bigint required |
restrictive FK to import_runs |
severity |
text required |
warning or fatal |
rule_code |
text required |
stable validation rule identifier |
affected_rows |
bigint required |
positive aggregate count |
evidence |
jsonb required default {} |
bounded aggregate examples; never payer tokens or credentials |
created_at |
timestamptz required |
evidence time |
Unique (import_run_id, severity, rule_code) keeps one aggregate record per rule. Index (import_run_id, severity) supports reports.
Reference data
merchant_categories
id bigint identity PK; source_category_key text unique/non-empty; title_fa text non-empty; created_at timestamptz required.
psps
id bigint identity PK; source_psp_code text unique/non-empty; created_at timestamptz required. Codes are opaque and do not imply a public PSP identity.
issuer_banks
id bigint identity PK; source_bank_code text unique/non-empty; created_at timestamptz required. Codes are opaque and do not imply a public bank identity.
calendar_dates
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
gregorian_date |
date required |
primary key |
gregorian_year/month/day |
smallint required |
validated Gregorian components |
jalali_year/month/day |
smallint required |
month 1..12, day valid for generated date |
iran_weekday |
smallint required |
0..6, Saturday through Friday |
reporting_week_start |
date required |
Saturday containing this date |
reporting_week_end_exclusive |
date required |
following Saturday; equals start + 7 days |
persian_season |
smallint required |
1..4 |
Unique (jalali_year, jalali_month, jalali_day) prevents duplicate calendar mappings. Indexes on (jalali_year, jalali_month, gregorian_date), (jalali_year, persian_season, gregorian_date), and (reporting_week_start, gregorian_date) support approved periods. Holiday/named-event columns do not exist.
Merchant and demo identity
merchants
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
id |
bigint required |
identity primary key |
source_merchant_key |
text required |
unique/non-empty opaque source key |
category_id |
bigint required |
restrictive FK to merchant_categories |
display_name |
text required |
generated demo-facing name |
profile_is_synthetic |
boolean required |
true for generated profile data |
created_at |
timestamptz required |
creation time |
Index category_id supports peer cohort membership.
terminals
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
id |
bigint required |
identity primary key |
source_terminal_key |
text required |
unique/non-empty opaque source key |
merchant_id |
bigint required |
restrictive FK to merchants |
verification_type |
text required |
Automated or Manual for the approved source contract |
created_at |
timestamptz required |
creation time |
Unique (id, merchant_id) is the target of the session ownership FK. Index merchant_id supports merchant terminal lookup.
merchant_users
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
id |
bigint required |
identity primary key |
merchant_id |
bigint required |
restrictive FK to merchants |
username_normalized |
text required |
lowercase ASCII, globally unique |
password_hash |
text required |
complete Argon2id encoded hash only |
is_demo |
boolean required default true |
demo adapter marker |
disabled_at |
timestamptz nullable |
disabled accounts cannot authenticate |
created_at/updated_at |
timestamptz required |
lifecycle timestamps |
Unique (id, merchant_id) is the target of tenant-ownership audit FKs. A partial unique index on merchant_id WHERE is_demo enforces at most one seeded demo user per merchant without preventing a future non-demo identity migration.
auth_sessions
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
id |
bigint required |
identity primary key |
merchant_user_id |
bigint required |
restrictive FK to merchant_users |
token_digest |
bytea required |
unique 32-byte SHA-256 digest; never raw token |
created_at |
timestamptz required |
creation time |
last_activity_at |
timestamptz required |
coalesced activity time |
idle_expires_at |
timestamptz required |
no more than 12 hours after approved activity |
absolute_expires_at |
timestamptz required |
no more than 7 days after creation |
revoked_at |
timestamptz nullable |
immediate invalidation time |
revocation_reason |
text nullable |
safe bounded reason code |
Checks enforce last_activity_at >= created_at, idle_expires_at <= absolute_expires_at, and paired revocation fields. Unique token_digest is the lookup path. Index (merchant_user_id, revoked_at, absolute_expires_at, created_at) supports the two-active-session rule and revocation.
auth_audit_events
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
id |
bigint required |
identity primary key |
merchant_user_id |
bigint nullable |
FK to merchant_users; null for an unresolved login subject |
event_type |
text required |
stable approved security-event code |
outcome |
text required |
safe outcome category |
correlation_id |
text nullable |
request correlation only, never token/credential material |
occurred_at |
timestamptz required |
event time |
expires_at |
timestamptz required |
30-day retention deadline |
Index expires_at supports bounded cleanup; (merchant_user_id, occurred_at DESC) supports tenant-safe investigation. Raw IP addresses, user agents, usernames attempted, passwords, hashes, and token values are prohibited.
analytics_access_audit_events
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
id |
bigint required |
identity primary key |
merchant_user_id |
bigint required |
restrictive FK to merchant_users |
merchant_id |
bigint required |
part of composite ownership FK with merchant_user_id |
event_type |
text required |
approved analytics/detail/export action code |
result |
text required |
stable completed/rejected/aborted outcome code |
period_id |
text nullable |
approved canonical period identifier |
view_type |
text nullable |
approved detail/export view code |
filter_fingerprint |
bytea nullable |
32-byte digest of canonical filters; never raw filter/cursor data |
row_count |
bigint nullable |
non-negative completed export count only |
byte_count |
bigint nullable |
non-negative completed export byte count only |
request_id |
text nullable |
bounded safe correlation identifier |
occurred_at |
timestamptz required |
action time |
expires_at |
timestamptz required |
90-day retention deadline |
The ownership FK guarantees that the user belongs to the recorded merchant. Checks enforce a 32-byte fingerprint when present and non-negative row/byte counts. Index expires_at supports automatic retention cleanup; (merchant_id, occurred_at DESC) and (merchant_user_id, occurred_at DESC) support operator-only investigation. Exported values, payer tokens, raw cursors/filters, credentials, user-controlled filenames, and peer data are prohibited. This table has no merchant-facing API or UI.
Payment facts
payment_sessions
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
id |
bigint required |
identity primary key |
source_session_key |
text required |
globally unique/non-empty opaque source key |
merchant_id |
bigint required |
restrictive FK to merchants |
terminal_id |
bigint required |
part of composite FK (terminal_id, merchant_id) to terminals |
amount_rial |
bigint required |
positive session amount |
fee_amount_rial |
bigint required |
non-negative real merchant fee, stored once |
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 |
import_run_id |
bigint required |
restrictive FK to the completed import_runs lineage |
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:
- unique
source_session_key (merchant_id, created_at DESC, id DESC)for period lists and keyset export(terminal_id, created_at DESC, id DESC)for terminal drill-down(merchant_id, session_status, created_at DESC, id DESC)for paid/unverified/failed detail views
payment_attempts
| Column | Type/nullability | Constraint or meaning |
|---|---|---|
id |
bigint required |
identity primary key |
payment_session_id |
bigint required |
restrictive FK to payment_sessions |
try_seq |
smallint required |
positive source sequence |
try_status |
text required |
non-empty source lifecycle code; never NoAttempt |
psp_id |
bigint required |
restrictive FK to psps |
switch_response_code |
text nullable |
PSP-scoped raw code |
issuer_bank_id |
bigint nullable |
restrictive FK to issuer_banks |
payer_card_key |
text nullable |
unchanged restricted opaque token |
init_time_ms |
integer nullable |
non-negative API duration |
verify_time_ms |
integer nullable |
non-negative API duration |
try_created_at |
timestamptz required |
source attempt creation normalized to UTC |
verified_at |
timestamptz nullable |
preserved source instant |
settled_at |
timestamptz nullable |
preserved source instant |
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.
Static dataset cache namespace
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.
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
Importer staging tables are run-scoped, migration-owned technical structures rather than durable domain entities. They mirror raw columns plus import_run_id, source_row_number, and parse/validation metadata. They have no application Ent entity, no long-term API, and are truncated/dropped only by the explicit recovery lifecycle after the durable import report is complete. Promotion targets only the tables above.
Deletion, mutation, and retention
- 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_atand must not remove payment/import facts. - Redis analytics entries are disposable and expire after 30 days; no calculated analytics table exists in PostgreSQL.
Migration and verification requirements
- 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 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.
Acceptance criteria
- The physical schema represents all 2,062,839 sessions and exactly 1,949,353 real attempts without a fake
NoAttemptattempt. - Every session's terminal is provably owned by the same merchant through a database foreign key.
- Every source session key and
(session, try_seq)pair is unique after promotion. - Session-level amount, fee, merchant, terminal, status, creation, expiry, origin, and provenance are stored once.
verify_typeis 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.
- 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, 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 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.
Resolved decisions
- Store terminal-invariant
verify_typeonterminals, not every payment attempt. - Preserve future non-empty lifecycle status values as text and classify them through the metric catalogue instead of using rigid PostgreSQL enums.
- Omit cache-revision tables and database triggers because the MVP dataset is immutable; use the sole completed import run as the Redis cache namespace.
- 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.
- Approve the table, constraint, index, and restrictive-deletion contract above as the migration target.