Skip to content

MPAC Cloud — Refined Database Schema Design

Status: Draft Version: 1.0 Date: 2026-03-25 Supersedes: psp-admin/terminal_master_schema_design.md, user-and-role/database-schema.mdTarget Database: PostgreSQL 16+ Target Service: svc-portal (single database, shared by svc-smarttab)


1. Overview

This schema unifies two previously independent designs — PSP Admin (terminal lifecycle management) and User & Role (fine-grained RBAC) — into the existing svc-portal database. The result is a single coherent schema that covers:

  • Multi-tenant hierarchy (Platform → PSP → Merchant → Store)
  • Unified RBAC with PLATFORM, MERCHANT, and STORE permission scopes
  • Device lifecycle management (merged devices + terminals)
  • Delivery, billing, and maintenance tracking
  • Audit logging

Design Decisions Summary

DecisionChoiceRationale
Schema strategyEvolve svc-portal's existing schemaAvoid new databases; extend the existing foundation
Primary keyUUIDv7 everywhereTime-ordered, API-safe, conflict-free across services
Business identifiersDrop unused onesOnly keep where actively needed (e.g. order_number)
Role modelUnified RBAC (Option C — flatten)One permission check code path; no role enum
Permission scopesPLATFORM / MERCHANT / STOREPlatform permissions for PSP Admin features; merchant/store for User & Role
Store accessFull model (store_access + store_access_roles)Per-store role differentiation is a real business requirement
User identityUUID PK + email loginusername/login_id deferred to future decision
Device tableMerge devices + terminals into devicesSame physical hardware, one source of truth
Device statusTwo columns: asset_status + connection_statusIndependent lifecycles (physical asset vs software connectivity)
Communication plansReplaced by device_profilesFull device template (billing + hardware + operational config)
is_active columnsDropped everywherestatus column is the single source of truth
FK namingmerchant_pk for FK to merchantsAvoids confusion with MID (Merchant ID from payment providers)
Tenant scopingpsp_id + merchant_pk on usersDerived from nullable FKs, no separate tenant_type enum

2. Entity Relationship Overview

psps ─────────────────────────────────────────┐
  │                                             │
  ├──► merchants                                │
  │       │                                     │
  │       ├──► stores                           │
  │       │       │                             │
  │       │       ├──► devices ◄── device_profiles
  │       │       │     │  │  │        ◄── device_models
  │       │       │     │  │  │
  │       │       │     │  │  ├──► device_activation_history
  │       │       │     │  │  ├──► device_status_history
  │       │       │     │  │  ├──► breakdown_history
  │       │       │     │  │  └──► billing_records
  │       │       │     │  │
  │       │       │     │  └──► delivery_orders ──► device_returns
  │       │       │     │
  │       │       │     └──► iot_credentials
  │       │       │
  │       │       └──► store_access ──► store_access_roles
  │       │
  │       └──► users ──► user_default_roles
  │               │
  │               └──► audit_logs

  ├──► users (psp-level)

  └──► users (platform-level, psp_id = NULL)

roles ──► role_permissions ◄── permissions ◄── permission_groups

  ├──► user_default_roles
  └──► store_access_roles

3. Table Inventory (26 tables)

#TableStatusCategoryDescription
Tenant Hierarchy
1pspsMODIFIEDReferencePayment Service Providers. Migrate PK to UUIDv7.
2merchantsMODIFIEDReferenceMerchants under PSPs. Migrate PK to UUIDv7.
3storesMODIFIEDReferenceStores under merchants. Migrate PK to UUIDv7. Add address/GPS.
User & Role (RBAC)
4usersMODIFIEDCoreAll user accounts. Drop role enum + store_id. Add RBAC fields.
5rolesNEWCoreNamed permission sets. Scope: PLATFORM / MERCHANT / STORE.
6permissionsNEWSeedPermission constants (~46 keys).
7permission_groupsNEWSeedLogical groupings for UI rendering.
8role_permissionsNEWJoinWhich permissions a role grants.
9user_default_rolesNEWJoinUser's baseline roles (PLATFORM + MERCHANT scoped).
10store_accessNEWJoinPer-store access mode: NO_ACCESS / DEFAULT / CUSTOM.
11store_access_rolesNEWJoinCustom role overrides per store (STORE scoped).
Device Management
12devicesMODIFIEDCoreUnified device/terminal table. Two status columns.
13device_modelsNEWReferenceModel series + name + lot number.
14device_profilesNEWReferenceReusable config templates (billing + hardware + ops).
15iot_credentialsMODIFIEDCoreAWS IoT metadata. FK migration only.
16device_activation_historyNEWHistoryActivation events. One active per device.
17device_status_historyNEWHistoryAsset + connection status transition log.
18breakdown_historyNEWMaintenanceBreakdown, replacement, battery records.
Delivery & Billing
19delivery_ordersNEWDeliveryOrder → Picking → Shipment → Delivery lifecycle.
20device_returnsNEWDeliveryReturn tracking and disposal.
21billing_recordsNEWBillingLease / communication / commission fees. May be removed later.
Auth & Audit
22jwt_keysKEEPAuthSigning keys.
23sessionsKEEPAuthActive sessions.
24mfa_codesKEEPAuthMFA verification codes.
25authentication_logsKEEPAuditAuth event log.
26audit_logsNEWAuditGeneral entity change log. JSONB before/after snapshots.

Dropped:

  • staff — replaced by users with STORE-scoped roles
  • communication_plans — replaced by device_profiles
  • users.role (enum column) — replaced by RBAC roles table
  • users.store_id — replaced by store_access table
  • devices.device_code — replaced by device_model_id FK
  • is_active column on all tables — status is the single source of truth

Not in scope (unchanged, owned by svc-smarttab): orders, order_items, bills, splits, payments, refunds, receipts, invoices, bill_operation_histories, customers, coupons, loyalty_programs, loyalty_transactions, settlements, payment_methods, payment_providers, payment_provider_methods


4. ID Convention

All tables use UUIDv7 as the surrogate primary key. UUIDv7 is time-ordered (monotonically increasing), so B-tree insert performance is equivalent to auto-increment. It is opaque (safe to expose in APIs), conflict-free across services, and consistent across all tables.

Available natively in PostgreSQL 17+ via uuidv7(), or via the pg_uuidv7 extension for PostgreSQL 16.

Business-facing identifiers (e.g. order_number on delivery_orders) are kept only where actively needed by external systems or humans. They are UNIQUE but never used as FKs.


5. Column-Level Design

5.1 Tenant Hierarchy

psps

sql
CREATE TABLE psps (
    id                      UUID PRIMARY KEY DEFAULT uuidv7(),
    psp_code                VARCHAR(8) NOT NULL UNIQUE,     -- PSPコード (human-readable, generated, e.g. "KAZAPI01")
    psp_name                VARCHAR(255) NOT NULL,
    contact_email           VARCHAR(255),
    contact_phone           VARCHAR(50),
    payment_config          JSONB,
    shipping_timing_policy  TEXT,
    status                  TEXT NOT NULL DEFAULT 'active'
                            CHECK (status IN ('active', 'inactive', 'suspended')),
    created_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at              TIMESTAMPTZ NOT NULL DEFAULT now()
);

merchants

sql
CREATE TABLE merchants (
    id                          UUID PRIMARY KEY DEFAULT uuidv7(),
    psp_id                      UUID NOT NULL REFERENCES psps(id) ON DELETE RESTRICT,
    merchant_code               VARCHAR(8) NOT NULL UNIQUE,  -- 加盟店コード (human-readable, generated, e.g. "KANON001")
    merchant_name               VARCHAR(255) NOT NULL,
    payment_config              JSONB,
    business_registration_number VARCHAR(50),
    status                      TEXT NOT NULL DEFAULT 'active'
                                CHECK (status IN ('active', 'inactive', 'suspended')),
    created_at                  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at                  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_merchants_psp_id ON merchants(psp_id);

stores

sql
CREATE TABLE stores (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    merchant_pk     UUID NOT NULL REFERENCES merchants(id) ON DELETE RESTRICT,
    store_name      VARCHAR(255) NOT NULL,
    mps_id          VARCHAR(64) UNIQUE,         -- maps to PGW store
    -- Address
    postal_code     VARCHAR(10),
    prefecture      VARCHAR(50),
    city            VARCHAR(100),
    address_line    TEXT,
    latitude        NUMERIC(9,6) CHECK (latitude BETWEEN -90 AND 90),
    longitude       NUMERIC(9,6) CHECK (longitude BETWEEN -180 AND 180),
    -- Config
    payment_config  JSONB,
    printer_items   JSONB,
    -- Status
    status          TEXT NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'inactive', 'suspended', 'archived')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_stores_merchant_pk ON stores(merchant_pk);

Note: archived status is used by the RBAC store access model — archived stores preserve existing access records but block new access assignments (see invariant #13).

5.2 Users

users

sql
CREATE TABLE users (
    id                  UUID PRIMARY KEY DEFAULT uuidv7(),
    -- Tenant scoping
    psp_id              UUID REFERENCES psps(id),           -- NULL = platform user
    merchant_pk         UUID REFERENCES merchants(id),      -- NULL = PSP-level user
    -- Identity
    email               VARCHAR(255) NOT NULL UNIQUE,
    name                VARCHAR(255) NOT NULL,
    description         TEXT,                               -- role/job label
    password_hash       TEXT NOT NULL,
    password_changed_at TIMESTAMPTZ,
    is_protected        BOOLEAN NOT NULL DEFAULT false,     -- cannot be deactivated
    -- MFA
    mfa_enabled         BOOLEAN NOT NULL DEFAULT false,
    mfa_method          TEXT CHECK (mfa_method IN ('NONE', 'SMS', 'EMAIL', 'TOTP')),
    totp_secret         TEXT,
    -- Security
    failed_login_attempts INTEGER NOT NULL DEFAULT 0,
    locked_until        TIMESTAMPTZ,
    -- Status
    status              TEXT NOT NULL DEFAULT 'active'
                        CHECK (status IN ('active', 'inactive')),
    last_login_at       TIMESTAMPTZ,
    created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_users_psp_id ON users(psp_id);
CREATE INDEX idx_users_merchant_pk ON users(merchant_pk);

Tenant scoping rules (application-level enforcement):

  • psp_id = NULL, merchant_pk = NULL → Platform user (sees all data)
  • psp_id = set, merchant_pk = NULL → PSP user (sees data under their PSP)
  • psp_id = set, merchant_pk = set → Merchant user (sees data under their merchant, store access via store_access table)
  • If merchant_pk is set, psp_id must also be set
  • If merchant_pk is set, merchants.psp_id must match users.psp_id

5.3 RBAC

roles

sql
CREATE TABLE roles (
    id          UUID PRIMARY KEY DEFAULT uuidv7(),  -- deterministic UUIDs for system roles
    name        VARCHAR(100) NOT NULL,
    scope       TEXT NOT NULL CHECK (scope IN ('PLATFORM', 'MERCHANT', 'STORE')),
    is_system   BOOLEAN NOT NULL DEFAULT false,
    merchant_pk UUID REFERENCES merchants(id),      -- NULL for system/platform roles
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Role names must be unique within a merchant (or globally for system roles)
CREATE UNIQUE INDEX uq_roles_name_per_merchant
    ON roles(name, COALESCE(merchant_pk, '00000000-0000-0000-0000-000000000000'));

System Roles (fixed UUIDs):

UUIDNameScopeDescription
00000000-0000-0000-0000-000000000001System AdminPLATFORMFull system access (MPS internal)
00000000-0000-0000-0000-000000000002OperatorPLATFORMOperational access (MPS internal)
00000000-0000-0000-0000-000000000003CS AgentPLATFORMRead-only terminal search (call center)
00000000-0000-0000-0000-000000000004PSP ManagerPLATFORMAll data under their PSP
00000000-0000-0000-0000-000000000005ViewerPLATFORMRead-only within tenant scope
00000000-0000-0000-0000-000000000010HQ Admin (本部管理者)MERCHANTFull merchant + all store permissions
00000000-0000-0000-0000-000000000011Area Manager (エリアマネージャー)MERCHANTMulti-store oversight + merchant admin
00000000-0000-0000-0000-000000000012General (一般)MERCHANTBasic org-wide + standard store access
00000000-0000-0000-0000-000000000013Accounting (経理)MERCHANTSales/financial data access
00000000-0000-0000-0000-000000000020Store Manager (店長)STOREFull store operations for a specific store
00000000-0000-0000-0000-000000000021Staff (スタッフ)STOREBasic store operations for a specific store

permission_groups

sql
CREATE TABLE permission_groups (
    group_id    VARCHAR(64) PRIMARY KEY,     -- e.g. 'TERMINAL_MGMT', 'ORDER_MGMT'
    scope       TEXT NOT NULL CHECK (scope IN ('PLATFORM', 'MERCHANT', 'STORE')),
    label       VARCHAR(255) NOT NULL,       -- display name
    description TEXT,
    sort_order  SMALLINT NOT NULL DEFAULT 0
);

permissions

sql
CREATE TABLE permissions (
    permission_key  VARCHAR(64) PRIMARY KEY,     -- e.g. 'TERMINAL_VIEW', 'ORDER_CREATE'
    group_id        VARCHAR(64) NOT NULL REFERENCES permission_groups(group_id),
    scope           TEXT NOT NULL CHECK (scope IN ('PLATFORM', 'MERCHANT', 'STORE')),
    label           VARCHAR(255) NOT NULL,       -- display label
    sort_order      SMALLINT NOT NULL DEFAULT 0
);

CREATE INDEX idx_permissions_group_id ON permissions(group_id);

Permission Registry

Platform-scoped permissions (~15 keys):

GroupPermission Keys
PSP_MGMTPSP_VIEW, PSP_MANAGE
TERMINAL_MGMTTERMINAL_VIEW, TERMINAL_MANAGE, TERMINAL_STATUS_CHANGE
DELIVERY_MGMTDELIVERY_VIEW, DELIVERY_MANAGE
DEVICE_BILLINGBILLING_VIEW, BILLING_MANAGE
DEVICE_MODEL_MGMTDEVICE_MODEL_VIEW, DEVICE_MODEL_MANAGE
DEVICE_PROFILE_MGMTDEVICE_PROFILE_VIEW, DEVICE_PROFILE_MANAGE
BREAKDOWN_MGMTBREAKDOWN_VIEW, BREAKDOWN_MANAGE

Merchant-scoped permissions (from User & Role spec):

GroupPermission Keys
MERCHANT_INFOMERCHANT_VIEW
MERCHANT_SETTINGSMERCHANT_EDIT
ACCOUNT_MGMTACCOUNT_VIEW, ACCOUNT_CREATE, ACCOUNT_EDIT, ACCOUNT_DISABLE, USER_DEFAULT_ROLE_ASSIGN, USER_STORE_ACCESS_ASSIGN
ROLE_MGMTROLE_VIEW, ROLE_CREATE, ROLE_EDIT, ROLE_DELETE
ARCHIVE_MGMTARCHIVE_VIEW, ARCHIVE_DOWNLOAD, ARCHIVE_MANAGE

Store-scoped permissions (from User & Role spec):

GroupPermission Keys
ORDER_MGMTORDER_VIEW, ORDER_CREATE, ORDER_REFUND, RECEIPT_ISSUE
ORDER_EXPORTORDER_CSV_DOWNLOAD
SALES_VIEW_GROUPSALES_VIEW, SALES_DETAIL_VIEW, SALES_SUMMARY_VIEW
SALES_CSV_EXPORTSALES_CSV_DOWNLOAD
SALES_PDF_EXPORTSALES_PDF_DOWNLOAD
CLOSING_OPSCLOSING_EXECUTE
STORE_MGMTSTORE_VIEW, STORE_EDIT, STORE_MANAGE
STORE_ARCHIVESTORE_ARCHIVE, STORE_UNARCHIVE

Total: ~46 permissions across 3 scopes.

role_permissions

sql
CREATE TABLE role_permissions (
    role_id         UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    permission_key  VARCHAR(64) NOT NULL REFERENCES permissions(permission_key),
    PRIMARY KEY (role_id, permission_key)
);

user_default_roles

sql
CREATE TABLE user_default_roles (
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role_id     UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
    PRIMARY KEY (user_id, role_id)
);

Only PLATFORM and MERCHANT scoped roles can be assigned here. Enforced at application level.

PLATFORM roles and store access: Platform and PSP users bypass the store_access table entirely — they see all stores within their tenant scope. The store_access / store_access_roles tables only apply to merchant-level users (where merchant_pk is set). PLATFORM roles assigned via user_default_roles contribute their platform/merchant permissions globally; they do NOT participate in store-level DEFAULT resolution. If a merchant-level user somehow holds a PLATFORM role as a default role, only that role's merchant-scoped and platform-scoped permissions apply globally — store permissions still resolve via the store_access mechanism.

store_access

sql
CREATE TABLE store_access (
    user_id     UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    store_id    UUID NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
    access_mode TEXT NOT NULL CHECK (access_mode IN ('NO_ACCESS', 'DEFAULT', 'CUSTOM')),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (user_id, store_id)
);

Missing row = implicit NO_ACCESS. Only applies to merchant-level users. Platform/PSP users see all stores within their tenant scope automatically.

store_access_roles

sql
CREATE TABLE store_access_roles (
    user_id     UUID NOT NULL,
    store_id    UUID NOT NULL,
    role_id     UUID NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
    PRIMARY KEY (user_id, store_id, role_id),
    FOREIGN KEY (user_id, store_id) REFERENCES store_access(user_id, store_id) ON DELETE CASCADE
);

Only STORE-scoped roles allowed. Rows only exist when store_access.access_mode = 'CUSTOM'.

5.4 Device Management

device_models

sql
CREATE TABLE device_models (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    model_series    VARCHAR(100) NOT NULL,      -- e.g. "S-Pitt AIR"
    model_name      VARCHAR(100) NOT NULL,      -- e.g. "R25"
    lot_number      VARCHAR(50),                -- price may vary by lot
    unit_price      NUMERIC(10,2),
    description     TEXT,
    status          TEXT NOT NULL DEFAULT 'active'
                    CHECK (status IN ('active', 'inactive')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Functional unique: same series+name+lot = same model (handles NULL lot)
CREATE UNIQUE INDEX uq_device_models_series_name_lot
    ON device_models(model_series, model_name, COALESCE(lot_number, ''));

device_profiles

sql
CREATE TABLE device_profiles (
    id                      UUID PRIMARY KEY DEFAULT uuidv7(),
    profile_name            VARCHAR(255) NOT NULL UNIQUE,
    description             TEXT,
    psp_id                  UUID REFERENCES psps(id) ON DELETE SET NULL,  -- optional PSP-specific
    -- Billing config
    monthly_lease_fee       NUMERIC(10,2),
    monthly_comm_fee        NUMERIC(10,2),                  -- default 500 yen
    commission_rate         NUMERIC(5,4),
    -- Hardware config
    target_firmware_version VARCHAR(50),
    cloudsim_enabled        BOOLEAN NOT NULL DEFAULT false,
    -- Operational config
    data_usage_limit_mb     INTEGER,                        -- NULL = unlimited
    heartbeat_alert_minutes INTEGER NOT NULL DEFAULT 1440,  -- 24h default
    billing_destination     TEXT CHECK (billing_destination IN ('PSP', 'ZERO')),
    -- Status
    status                  TEXT NOT NULL DEFAULT 'active'
                            CHECK (status IN ('active', 'inactive')),
    created_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at              TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_device_profiles_psp_id ON device_profiles(psp_id);

devices

sql
CREATE TABLE devices (
    id                  UUID PRIMARY KEY DEFAULT uuidv7(),
    -- Identity
    serial_number       VARCHAR(100) NOT NULL UNIQUE,
    imei_number         VARCHAR(20) UNIQUE,
    client_id           VARCHAR(255) UNIQUE,            -- OAuth2 client identifier
    device_name         VARCHAR(255),                   -- optional user-assigned
    -- Model & hardware
    device_model_id     UUID REFERENCES device_models(id) ON DELETE SET NULL,
    device_profile_id   UUID REFERENCES device_profiles(id) ON DELETE SET NULL,
    firmware_version    VARCHAR(50),
    -- Tenant scoping
    psp_id              UUID NOT NULL REFERENCES psps(id) ON DELETE RESTRICT,
    current_store_id    UUID REFERENCES stores(id) ON DELETE SET NULL,  -- NULL = unassigned
    -- Location
    shipping_address    TEXT,
    gps_latitude        NUMERIC(9,6) CHECK (gps_latitude BETWEEN -90 AND 90),
    gps_longitude       NUMERIC(9,6) CHECK (gps_longitude BETWEEN -180 AND 180),
    gps_updated_at      TIMESTAMPTZ,
    -- Contract / lease
    ownership           TEXT CHECK (ownership IN ('rental', 'purchase')),
    contract_start_date DATE,
    contract_end_date   DATE,
    -- Communication / billing (can override device_profile)
    monthly_data_usage_mb   NUMERIC(10,2) CHECK (monthly_data_usage_mb >= 0),
    cloudsim_usage_flag     BOOLEAN,
    -- Two status columns
    asset_status        TEXT NOT NULL DEFAULT 'in_storage'
                        CHECK (asset_status IN (
                            'under_cleaning', 'in_storage', 'on_lease',
                            'returned', 'disposed'
                        )),
    connection_status   TEXT NOT NULL DEFAULT 'pending_activation'
                        CHECK (connection_status IN (
                            'pending_activation', 'active', 'suspended', 'decommissioned'
                        )),
    -- Connectivity
    public_key_pem      TEXT,
    activation_code     VARCHAR(20),
    activated_at        TIMESTAMPTZ,
    last_connected_at   TIMESTAMPTZ,
    -- Auth PINs
    staff_pin_hash      TEXT,
    admin_pin_hash      TEXT,
    -- Timestamps
    created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- Constraints
    CONSTRAINT chk_contract_dates CHECK (contract_end_date >= contract_start_date)
);

CREATE INDEX idx_devices_psp_id ON devices(psp_id);
CREATE INDEX idx_devices_current_store_id ON devices(current_store_id);
CREATE INDEX idx_devices_asset_status ON devices(asset_status);
CREATE INDEX idx_devices_connection_status ON devices(connection_status);
CREATE INDEX idx_devices_last_connected_at ON devices(last_connected_at);
CREATE INDEX idx_devices_device_model_id ON devices(device_model_id);
CREATE INDEX idx_devices_device_profile_id ON devices(device_profile_id);

Merchant is derived via current_store_id → stores.merchant_pk (not denormalized on devices).

iot_credentials

sql
CREATE TABLE iot_credentials (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    device_id       UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    thing_name      VARCHAR(255) NOT NULL,
    certificate_arn TEXT NOT NULL,
    certificate_id  VARCHAR(100) NOT NULL,
    policy_name     VARCHAR(255) NOT NULL,
    status          TEXT NOT NULL CHECK (status IN ('active', 'revoked')),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_iot_credentials_device_id ON iot_credentials(device_id);

device_activation_history

sql
CREATE TABLE device_activation_history (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    device_id       UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    store_id        UUID NOT NULL REFERENCES stores(id),
    activated_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    deactivated_at  TIMESTAMPTZ,
    activated_by    UUID REFERENCES users(id),
    notes           TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- At most one active activation per device
CREATE UNIQUE INDEX uq_activation_one_active_per_device
    ON device_activation_history(device_id) WHERE deactivated_at IS NULL;

CREATE INDEX idx_device_activation_device_id ON device_activation_history(device_id);
CREATE INDEX idx_device_activation_store_id ON device_activation_history(store_id);

device_status_history

sql
CREATE TABLE device_status_history (
    id          UUID PRIMARY KEY DEFAULT uuidv7(),
    device_id   UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    status_type TEXT NOT NULL CHECK (status_type IN ('asset', 'connection')),
    old_status  TEXT NOT NULL,
    new_status  TEXT NOT NULL,
    reason      TEXT,
    changed_by  UUID REFERENCES users(id),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_device_status_history_device_id ON device_status_history(device_id);
CREATE INDEX idx_device_status_history_created_at ON device_status_history(created_at DESC);

breakdown_history

sql
CREATE TABLE breakdown_history (
    id                      UUID PRIMARY KEY DEFAULT uuidv7(),
    device_id               UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
    breakdown_type          TEXT NOT NULL
                            CHECK (breakdown_type IN ('breakdown', 'replacement', 'battery_replacement')),
    description             TEXT,
    reported_at             TIMESTAMPTZ NOT NULL DEFAULT now(),
    resolved_at             TIMESTAMPTZ,
    replacement_device_id   UUID REFERENCES devices(id),
    old_battery_sn          VARCHAR(100),
    new_battery_sn          VARCHAR(100),
    reported_by             UUID REFERENCES users(id),
    created_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at              TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_breakdown_history_device_id ON breakdown_history(device_id);

5.5 Delivery & Billing

delivery_orders

sql
CREATE TABLE delivery_orders (
    id                  UUID PRIMARY KEY DEFAULT uuidv7(),
    order_number        VARCHAR(50) NOT NULL UNIQUE,
    device_id           UUID REFERENCES devices(id) ON DELETE SET NULL,  -- linked at picking phase
    psp_id              UUID NOT NULL REFERENCES psps(id) ON DELETE RESTRICT,
    merchant_pk         UUID REFERENCES merchants(id) ON DELETE SET NULL,
    store_id            UUID REFERENCES stores(id) ON DELETE SET NULL,
    destination_address TEXT,
    -- Lifecycle
    status              TEXT NOT NULL DEFAULT 'ordered'
                        CHECK (status IN ('ordered', 'picking', 'shipped', 'delivered', 'cancelled')),
    ordered_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
    picked_at           TIMESTAMPTZ,
    shipped_at          TIMESTAMPTZ,
    delivered_at        TIMESTAMPTZ,
    tracking_number     VARCHAR(100),
    shipping_box_sent   BOOLEAN NOT NULL DEFAULT false,
    billing_start_date  DATE,       -- auto: next month after (shipped_at + 3 days)
    -- Audit
    created_by          UUID REFERENCES users(id),
    created_at          TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_delivery_orders_psp_id ON delivery_orders(psp_id);
CREATE INDEX idx_delivery_orders_device_id ON delivery_orders(device_id);
CREATE INDEX idx_delivery_orders_merchant_pk ON delivery_orders(merchant_pk);
CREATE INDEX idx_delivery_orders_store_id ON delivery_orders(store_id);
CREATE INDEX idx_delivery_orders_status ON delivery_orders(status);

device_returns

sql
CREATE TABLE device_returns (
    id                      UUID PRIMARY KEY DEFAULT uuidv7(),
    delivery_order_id       UUID NOT NULL REFERENCES delivery_orders(id) ON DELETE RESTRICT,
    device_id               UUID NOT NULL REFERENCES devices(id) ON DELETE RESTRICT,
    return_tracking_number  VARCHAR(100),
    is_returned             BOOLEAN NOT NULL DEFAULT false,
    returned_at             TIMESTAMPTZ,
    disposed_at             TIMESTAMPTZ,
    reason                  TEXT,
    notes                   TEXT,
    created_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at              TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_device_returns_device_id ON device_returns(device_id);
CREATE INDEX idx_device_returns_delivery_order_id ON device_returns(delivery_order_id);

billing_records

sql
CREATE TABLE billing_records (
    id              UUID PRIMARY KEY DEFAULT uuidv7(),
    device_id       UUID NOT NULL REFERENCES devices(id) ON DELETE RESTRICT,
    psp_id          UUID NOT NULL REFERENCES psps(id) ON DELETE RESTRICT,
    billing_type    TEXT NOT NULL
                    CHECK (billing_type IN ('lease_fee', 'communication_fee', 'commission_fee')),
    billing_period  VARCHAR(7) NOT NULL,            -- YYYY-MM format
    amount          NUMERIC(10,2) NOT NULL CHECK (amount >= 0),
    is_invoiced     BOOLEAN NOT NULL DEFAULT false,
    invoiced_at     TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- Prevent duplicate billing per device/type/period
    UNIQUE (device_id, billing_type, billing_period)
);

CREATE INDEX idx_billing_records_psp_id ON billing_records(psp_id);
CREATE INDEX idx_billing_records_period ON billing_records(billing_type, billing_period);

5.6 Audit

audit_logs

sql
CREATE TABLE audit_logs (
    id          UUID PRIMARY KEY DEFAULT uuidv7(),
    user_id     UUID REFERENCES users(id),
    action      TEXT NOT NULL,          -- 'CREATE', 'UPDATE', 'DELETE', 'STATUS_CHANGE'
    entity_type TEXT NOT NULL,          -- 'device', 'user', 'role', 'delivery_order', etc.
    entity_id   VARCHAR(255) NOT NULL,  -- UUIDv7 of target entity
    before_state JSONB,
    after_state  JSONB,
    ip_address  INET,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Append-only: no UPDATE or DELETE
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id);
CREATE INDEX idx_audit_logs_user ON audit_logs(user_id, created_at DESC);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);

Separate from authentication_logs which tracks login/auth events.


6. Authorization Model

6.1 Two Independent Axes

  • Tenant scope (data visibility): determined by users.psp_id + users.merchant_pk. Middleware-level filtering.
  • Permissions (what actions): determined by RBAC roles. Application-level checks.

6.2 Permission Resolution

1. Tenant filter (middleware)
   ├── psp_id = NULL, merchant_pk = NULL → no data filter (platform)
   ├── psp_id = set,  merchant_pk = NULL → WHERE psp_id = user.psp_id
   └── psp_id = set,  merchant_pk = set  → WHERE merchant_pk = user.merchant_pk

2. Global permissions (always active)
   └── SELECT permission_key FROM role_permissions
       WHERE role_id IN (user's default roles)
       AND scope IN ('PLATFORM', 'MERCHANT')

3. Store permissions (per-store resolution)
   ├── store_access row missing or NO_ACCESS → zero store perms
   ├── DEFAULT → store perms from user's default roles
   └── CUSTOM  → store perms from store_access_roles only

4. Effective permissions = global ∪ store perms

6.3 Scope Rules

Role ScopeCan hold permissionsAssigned where
PLATFORMAny permission (platform + merchant + store)user_default_roles
MERCHANTMerchant + store permissionsuser_default_roles
STOREStore permissions onlystore_access_roles (per-store custom)

7. Application-Level Invariants

These rules require cross-table logic and must be enforced in application code, not as DB constraints.

#InvariantEnforcement point
1PLATFORM roles can hold any permissioncreateRole, editRole
2MERCHANT roles can hold merchant + store permissions onlycreateRole, editRole
3STORE roles can only hold store-scoped permissionscreateRole, editRole
4Default roles must have scope PLATFORM or MERCHANTassignDefaultRoles
5Store custom roles must have scope STOREassignStoreAccess
6store_access_roles rows only exist when access_mode = 'CUSTOM'assignStoreAccess
7At least 1 active protected user with System Admin role must existdeactivateUser, removeDefaultRole
8Cannot deactivate own accountCompare session user vs target
9Cannot deactivate is_protected = true userdeactivateUser
10Cannot delete system role (is_system = true)deleteRole
11Cannot delete role that is in useCheck user_default_roles + store_access_roles before delete
12User with any store set to DEFAULT must retain >= 1 default roleremoveDefaultRole
13New access assignments on archived stores are disallowedCheck store.status in assignStoreAccess
14If merchant_pk is set, psp_id must also be setcreateUser, editUser
15merchants.psp_id must match users.psp_id when merchant_pk is setcreateUser, editUser
16At most one active activation per devicePartial unique index enforces at DB level
17billing_start_date = next month after (shipped_at + 3 days)Application logic at shipment time

8. Index Strategy

TableIndexPurpose
devicesUNIQUE on serial_number, imei_number, client_idIdentity lookup (auto B-tree)
devicesasset_status, connection_statusStatus filtering
deviceslast_connected_atSilent device / heartbeat queries
devicespsp_id, current_store_idTenant-scoped queries
device_activation_historyPartial unique on device_id WHERE deactivated_at IS NULLOne active per device
device_status_historydevice_id, created_at DESCDevice history timeline
billing_recordsUNIQUE on (device_id, billing_type, billing_period)Prevent duplicate billing
billing_records(billing_type, billing_period)Monthly aggregation
audit_logs(entity_type, entity_id), (user_id, created_at DESC), (created_at DESC)Entity lookup, user activity, chronological
All FK columnsStandard B-treeJoin performance

9. Open Items

ItemStatusNotes
Battery S/N managementDeferredCurrent schema has fields in breakdown_history. May need a dedicated batteries table if serial numbers need independent lifecycle tracking.
billing_records retentionPendingMay be removed after business finalizes billing workflow. PSP-side billing system may take over.
Username / login_idDeferredMay add username column to users for non-email login after customer decision.

MPAC — MP-Solution Advanced Cloud Service