3. Data Model¶
The entities Link Access owns, all keyed to Identity's shared user.
3.1 What Access owns¶
Identity is the system of record for the person — the user (user_id, tenant_id, mobile, palm_enrolled) and the tenant. Access stores none of that. It owns its business data — memberships, zones, grants, balances, attendance — and keys every row to Identity's user_id and tenant_id. There is no user table in Access, and palm templates never leave the Verification Server.
USER + TENANT is Identity's — shown dashed, with no arrows. It has none because it would connect to every box; instead, every Access row simply carries user_id + tenant_id as a reference. Access copies none of the person's data.
flowchart LR
subgraph IDP["Identity Platform (external)"]
USER["USER + TENANT<br/>Access references these by id"]
end
subgraph People
MEMBERSHIP
RELATIONSHIP
end
subgraph Where
ZONE
ZONEDEVICE
end
subgraph Entitlement
PRODUCT
GRANT
end
subgraph Money
TOPUP
TRANSACTION
POSTING
ACCOUNT
end
subgraph Records
ATTENDANCE
end
ZONE -->|nests| ZONE
ZONE --> ZONEDEVICE
PRODUCT -->|issues| GRANT
GRANT -->|scopes| ZONE
ATTENDANCE -->|at| ZONE
TOPUP -->|creates on paid| TRANSACTION
TRANSACTION -->|has 2+| POSTING
POSTING -->|hits| ACCOUNT
classDef ext fill:#ede9fe,stroke:#7c3aed,stroke-dasharray: 5 5
class USER ext
How to read this map. Identity owns the person; Access owns everything else, each row tagged with that person's id.
- People — who you are to a venue: your role (
Membership) and links to others (Relationship— e.g. parent↔child). - Where — the places: a
Zoneis a named spot (a gate, a classroom);ZoneDevicemaps each physical scanner to its zone. - Entitlement — your right to enter: buying a
Product(a sellable pass) gives you aGrant, which says whichZoneyou can go into, and when. - Money — your balance: you load funds with a card (a
TopUp) and spend them by palm. Each time money moves — card → your balance, or your balance → the venue — the system writes down both ends, so the totals always add up and nothing can go missing. The tables that do this (Transaction,Posting,Account) are below. - Records — what happened: an
Attendancerow logs a scan at a zone.
Two chains carry the weight: Product → Grant → Zone (what lets you in) and TopUp → Transaction → Posting → Account (how money moves). Everything else hangs off your Identity user_id.
For the Parks MVP, RELATIONSHIP and ATTENDANCE are unused (see tags) — the rest is the first build. One-to-many detail lives in the field tables, via the foreign keys.
3.2 People¶
Access assigns roles and links members; the person still lives in Identity.
Membership — a user's role within a tenant.
| Field | Type | Notes |
|---|---|---|
membership_id |
id | |
tenant_id |
id | Identity tenant |
user_id |
id | Identity user |
role |
enum | admin, operator, cashier, attendant, member, student, parent, employee, visitor |
status |
enum | active, suspended |
Relationship — links two users. [POST-MVP for Parks]
| Field | Type | Notes |
|---|---|---|
relationship_id |
id | |
tenant_id |
id | |
from_user_id |
id | e.g. the guardian |
to_user_id |
id | e.g. the child |
type |
enum | guardian, group |
3.3 Where¶
Zone — a named policy target; devices are assigned to it, grants point at it.
| Field | Type | Notes |
|---|---|---|
zone_id |
id | |
tenant_id |
id | |
name |
string | "Park Entry", "Class 5A" |
parent_zone_id |
id? | nesting; null = top level |
effects |
array | what allow does: open, attendance, notify |
schedule |
object? | when active (hours / term); null = always |
status |
enum | active, inactive |
Pay is zone-agnostic — a till (bound_action = pay) goes straight to the ledger, so effects only cover access and attendance.
ZoneDevice — Access's map from an Identity device to a zone.
| Field | Type | Notes |
|---|---|---|
tenant_id |
id | |
device_id |
id | the Identity device (device_id from the authorize call) |
zone_id |
id |
3.4 Entitlement¶
Grant — the right to enter a zone for a window. A pass is a purchased grant.
| Field | Type | Notes |
|---|---|---|
grant_id |
id | |
tenant_id |
id | |
user_id |
id | |
zone_id |
id | covers this zone and, if nested, its children |
valid_from / valid_until |
timestamp | day vs season pass = different windows |
source |
enum | purchase, sis, hr, admin |
product_id |
id? | set when source = purchase |
status |
enum | active, revoked, expired |
Product — a sellable pass type (the store's SKU).
| Field | Type | Notes |
|---|---|---|
product_id |
id | |
tenant_id |
id | |
name |
string | "Day Pass", "Season Pass" |
price / currency |
money | |
grants_zone_id |
id | zone the pass grants |
grants_validity |
duration | e.g. 1 day, 90 days |
Buying a product issues a Grant.
3.5 Money (double-entry)¶
The ledger is double-entry and append-only: every movement is a Transaction made of Posting lines that sum to zero. A balance is the cached sum of an account's postings.
Account — a bucket money sits in.
| Field | Type | Notes |
|---|---|---|
account_id |
id | |
tenant_id |
id | |
type |
enum | user_balance, gateway_clearing, revenue |
user_id |
id? | set for user_balance; null for system accounts |
currency |
string | SAR |
cached_balance |
money | derived (sum of postings), cached for reads |
status |
enum | active, frozen |
Transaction — a business event.
| Field | Type | Notes |
|---|---|---|
transaction_id |
id | |
tenant_id |
id | |
type |
enum | topup, spend, refund |
amount / currency |
money | |
status |
enum | pending, completed, failed, reversed |
idempotency_key |
string | dedupes device retries + webhook replays |
external_ref |
string? | Moyasar payment id, or device transaction id |
Posting — one debit/credit line of a transaction.
| Field | Type | Notes |
|---|---|---|
posting_id |
id | |
transaction_id |
id | |
account_id |
id | |
direction |
enum | debit, credit |
amount |
money |
Value flows — each is one transaction, two postings:
| Event | Value moves |
|---|---|
| Top-up | gateway_clearing → user_balance |
| Spend | user_balance → revenue |
| Refund | user_balance → gateway_clearing |
TopUp — the gateway payment behind a top-up.
| Field | Type | Notes |
|---|---|---|
topup_id |
id | |
tenant_id / user_id |
id | |
amount / currency |
money | |
gateway |
enum | moyasar (later stripe) |
gateway_payment_id |
string | |
status |
enum | pending, paid, failed |
transaction_id |
id? | the ledger transaction, posted on paid |
A top-up starts pending; only Moyasar's confirmed webhook posts the ledger credit, idempotent on gateway_payment_id.
3.6 Records¶
Attendance — a scan recorded at an attendance zone. [POST-MVP for Parks]
| Field | Type | Notes |
|---|---|---|
attendance_id |
id | |
tenant_id / user_id |
id | |
zone_id |
id | |
at |
timestamp | |
kind |
enum | check_in, check_out |