MahalliMahalli Handbook

Domain Model

The tenant-scoped entity model — core, catalog and customers, money, presence and content, ads, sites, platform — and the fixed rules every table obeys.

03 — Domain model

Every tenant-scoped entity carries the tenant id. Identifiers are ULIDs. Timestamps are stored in UTC and displayed in Asia/Riyadh.

0. Implementation status on supastarter

  • Tenant = the Better Auth Organization. The Prisma model Organization is extended with crNumber (unique), vatNumber, sector, phone, city, and country (default SA). The conceptual tenant_id below is organizationId in code.
  • Location exists now in packages/database/prisma/schema.prisma (id, organizationId, name, addressLine, city, lat, lng, phone, whatsapp, hours, isPrimary, createdAt, updatedAt) and is mirrored in the Drizzle Postgres schema.
  • User and Membership are the Better Auth User (with phoneNumber) and Member models. Roles are Better Auth organization roles evaluated through Permix.
  • Subscription / plans come from packages/payments and are attached to the organization.
  • Everything else in this document is the target model; tables are added to packages/database as the modules that need them land. Prisma models use camelCase; the snake_case names below are the conceptual names.

1. Core

Tenant ──< Location            (business → branches)
Tenant ──< Membership >── User (roles: owner / manager / staff / accountant)
Tenant ──1 BrandKit
Tenant ──1 Subscription ──< Entitlement
Tenant ──< Installation >── AddOn   (add-ons installed from the Marketplace)
Tenant ──1 SectorProfile           (sector_id + overrides)
EntityKey fields
tenants (Organization)id, legal_name, trade_name, cr_number, vat_number, sector_id, status, wathq_snapshot (jsonb), created_at
locationsid, tenant_id, name, address (jsonb), lat, lng, phone, whatsapp, hours (jsonb), gbp_location_id?
usersid, phone, name, locale, last_login_at
membershipstenant_id, user_id, role, location_ids[]
brand_kitstenant_id, logo_asset_id, palette (jsonb, OKLCH), fonts (jsonb), tone, tagline
subscriptionstenant_id, plan_id, status, period_start, period_end, psp_customer_ref
entitlementstenant_id, key, value, source (plan / addon / trial / manual)

2. Catalog and customers

Tenant ──< Category ──< Item ──< Variant
Item ──< ItemAvailability >── Location
Tenant ──< Customer ──< Consent
Customer ──< Interaction (visit, payment, review, message)
EntityFields
itemsid, tenant_id, category_id, kind (product / service), name_ar, name_en, description, price_incl_vat, vat_rate, duration_min?, images[], attributes (jsonb, by sector)
customersid, tenant_id, phone_hash, phone_enc, name, tags[], first_seen_at, ltv
consentscustomer_id, channel (sms / whatsapp / email / ads_audience), granted, source, at

3. Money

Tenant ──< Order ──< OrderLine
Order ──< Payment (method: mada / card / applepay / stcpay / tabby / tamara)
Payment ──< Refund
Order ──1 Invoice (ZATCA simplified, QR TLV, xml_hash, status)
Tenant ──< PaymentLink (amount or open, expires, qr_asset)
Tenant ──1 PspAccount (sub-merchant refs, enabled methods, bnpl_status per provider)
  • payments.psp_ref is unique; webhooks are idempotent on psp_event_id.
  • There is no platform "balance" table. Settlement happens at the PSP in the merchant's name.

4. Presence and content

Location ──1 Listing (gbp) ──< Review ──1? ReviewReply
Tenant ──< SocialAccount (ig / x / snap / tiktok / gbp) ──< Post ──< PostPublication
Tenant ──< Asset ; Tenant ──< Template (design)

5. Ads

Tenant ──< AdAccount (google / snap / tiktok / meta, oauth_token_enc)
AdAccount ──< Campaign (template_id, objective, budget, status) ──< Creative
Campaign ──< SpendSnapshot (daily)
Tenant ──< Audience (hashed customers with consent)

6. Sites

Tenant ──1..n Site (theme_id, sector_template, settings) ──< Page (blocks jsonb)
Site ──< Domain (hostname, kind: subdomain / custom, cf_hostname_id, status)

7. Platform

Plan ──< PlanPrice ; AddOn ──< AddOnPrice
Outbox (event_id, type, payload, tenant_id, published_at)
AuditLog (actor, action, target, tenant_id, at, ip)
AiCall (tenant_id, tool, tokens_in, tokens_out, cost_sar, at)
Job (queue, key, status, attempts)

8. Fixed rules

  1. No hard deletes for money or audit entities; deleted_at only.
  2. Phone numbers are stored encrypted plus a hash for lookup; the full number is shown only to authorised roles.
  3. Every price is stored VAT-inclusive in halalas (integer) together with vat_rate.
  4. Any entity a module creates must be defined in packages/database, never inside the module (prevents fragmentation).

On this page