admin — Phase 6
Schema locked 2026-06-10. 11 tables, 145 cols: tenant_business_profile (17), tenant_branding (12), compliance_document (14), tenant_setting (11), hardware_device (13), integration_config (13), webhook_config (12), api_key (14), approval_workflow (10), approval_routing_rule (11), approval_request (18).
Tenant configuration module only — business profile, branding, generic settings (absorbs config deferred from POS/Orders/Billing), compliance docs, hardware registry, integration/webhook/API config, and approval workflow definitions. HR/scheduling/labor is out of scope for Vrida. Roles/permissions/users live in identity. See PROJECT_DECISIONS "Admin Module Scope (Decided 2026-06-10)".
Design rules:
- All 11 tables are tenant-scoped with RLS on
tenant_id. tenant_settingis the generic catch-all for all module config.site_id NULL= tenant-wide default;site_id SET= site-level override. Consuming modules callAdminService.getSetting(tenantId, category, key, siteId?).- Secrets stored as
*_refvault references only — never raw credentials. - File assets (logos, PDFs) stored as R2 text key forward-refs — Files module owns the storage layer.
- Approval workflows are cross-module infrastructure. The engine routes; source records (
pos.sale,purchasing.purchase_order) record outcomes. No change to locked schemas —source_refis a plain UUID discriminated bysource_module.
Cross-Phase FK seams:
| Column | References | Status |
|---|---|---|
*.tenant_id |
platform.tenant |
Locked — enforced |
tenant_setting.site_id, hardware_device.site_id, approval_routing_rule.site_id |
multi_loc.site |
Locked — enforced (nullable) |
*.updated_by_user_id, *.created_by_user_id, *.revoked_by_user_id, *.requested_by_user_id, *.resolved_by_user_id |
identity.identity_user |
Locked — enforced (nullable) |
compliance_document.document_ref, tenant_branding.logo_ref |
Files module (R2 key) | FORWARD-REF — plain text seam; FK added when Files module locks |
integration_config.credentials_ref, webhook_config.secret_ref |
Vault — secrets | Text seam — never enforced as FK |
api_key.token_hash |
Hashed — stored token hash, raw shown once at creation | Hash — never FK |
approval_request.source_ref |
pos.sale.id / purchasing.purchase_order.id / orders.order_header.id — discriminated by source_module |
Plain UUID — cross-module; not enforced FK; consuming schemas are locked and unchanged |
Deferred items (explicit triggers):
tenant_setting.valueJSONB shape per (category, key) pair: documented at service layer when each consuming module reads the setting.approval_workflow.stepsJSONB step schema: formal validation deferred to implementation.approval_request.step_historyJSONB per-step audit: formal shape deferred to implementation.tenant_business_profile.business_typeCHECK enum vs. free-text: whentenant_business_profileservice is built (currently open text with examples).compliance_document.expires_atpartial index (add WHEREdeleted_at IS NULL): when expiry alert job is written.
admin.tenant_business_profile — 17 cols
One row per tenant. Holds the legal identity, operating addresses, contact info, and trade name list.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
legal_name |
text | NOT NULL | — | Legal business name |
display_name |
text | nullable | — | Trade name / DBA used in customer-facing UI; falls back to legal_name if NULL |
ein_ref |
text | nullable | — | Vault reference — encrypted EIN; never stored raw |
business_type |
text | nullable | — | e.g. 'LLC', 'Corporation', 'SoleProprietorship', 'Partnership' |
business_classification |
text | nullable | — | NAICS code or similar |
primary_address |
JSONB | nullable | — | {"line1":"...","line2":"...","city":"...","state":"...","postal":"...","country":"..."} |
mailing_address |
JSONB | nullable | — | Same shape as primary_address; NULL = same as primary |
phone |
text | nullable | — | Primary business phone |
email |
text | nullable | — | Primary business email |
website |
text | nullable | — | |
dbas |
JSONB | nullable | — | Array of doing-business-as names: ["Garden Supply Co","Rose Garden Nursery"] |
attributes |
JSONB | nullable | — | Tenant-custom extensible fields |
Indexes:
- PK on
id - on (
tenant_id) - UNIQUE on (
tenant_id) WHEREdeleted_at IS NULL— one profile per tenant
admin.tenant_branding — 12 cols
One row per tenant. Logo, colors, fonts, slogan, and social handles for customer-facing theming.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
logo_ref |
text | nullable | — | FORWARD-REF — R2 object key; Files module owns storage |
primary_color |
text | nullable | — | Hex color string, e.g. '#2D6A4F' |
secondary_color |
text | nullable | — | |
accent_color |
text | nullable | — | |
font_family |
text | nullable | — | Google Fonts family name or system font |
slogan |
text | nullable | — | Short tagline for receipts / app |
social_handles |
JSONB | nullable | — | {"instagram":"@handle","facebook":"page-slug","twitter":"@handle"} |
Indexes:
- PK on
id - on (
tenant_id) - UNIQUE on (
tenant_id) WHEREdeleted_at IS NULL— one branding record per tenant
admin.compliance_document — 14 cols
Tenant's own business permits, insurance certs, and compliance documents. Expiry tracking for renewal alerts.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
document_type |
text | NOT NULL | — | CHECK IN ('business_license','insurance','seller_permit','resale_cert','other') |
name |
text | NOT NULL | — | Human-readable label, e.g. "Workers Comp – 2026" |
document_ref |
text | nullable | — | FORWARD-REF — R2 object key for the PDF; Files module owns storage |
issuing_authority |
text | nullable | — | State agency, insurer, etc. |
identifier |
text | nullable | — | License or permit number |
issued_date |
date | nullable | — | |
expires_at |
date | nullable | — | Expiry date; NULL = no expiry |
status |
text | NOT NULL | 'active' |
CHECK IN ('active','expired','pending','revoked') |
note |
text | nullable | — |
Indexes:
- PK on
id - on (
tenant_id) - on (
document_type) - on (
expires_at) — renewal alert sweeps
admin.tenant_setting — 11 cols
Generic key-value configuration store. One row per (tenant, category, key) pair, with an optional site-level override. Absorbs all settings deferred from POS (hold expiry, receipt config, discount-approval thresholds), Orders, Billing, and notification preferences.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
site_id |
UUID | nullable | — | FK → multi_loc.site; NULL = tenant-wide default; NOT NULL = site-level override |
category |
text | NOT NULL | — | Logical grouping, e.g. 'pos', 'orders', 'billing', 'notifications', 'general' |
key |
text | NOT NULL | — | Setting identifier within category, e.g. 'hold_expiry_hours', 'receipt_footer', 'discount_approval_threshold_cents' |
value |
JSONB | NOT NULL | — | Flexible value — shape defined per (category, key) at service layer |
description |
text | nullable | — | Human-readable note for the Owner Dashboard |
updated_by_user_id |
UUID | nullable | — | FK → identity.identity_user — who last changed this setting |
Indexes:
- PK on
id - on (
tenant_id) - on (
tenant_id,category) - UNIQUE on (
tenant_id,category,key) WHEREsite_id IS NULL AND deleted_at IS NULL— one tenant-wide value per key; prevents duplicate tenant-wide settings (a single four-column unique on (tenant_id, category, key, site_id) cannot block two rows both with site_id=NULL — NULL ≠ NULL in Postgres) - UNIQUE on (
tenant_id,category,key,site_id) WHEREsite_id IS NOT NULL AND deleted_at IS NULL— one site-level override per (tenant, category, key, site); tenant-wide and site-specific rows for the same key coexist by design
admin.hardware_device — 13 cols
Physical device registry per site. Records every register, printer, cash drawer, card reader, and scanner. POS register may reference a hardware_device for its card reader; this table is the physical inventory.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
site_id |
UUID | NOT NULL | — | FK → multi_loc.site |
device_type |
text | NOT NULL | — | CHECK IN ('register','printer','cash_drawer','card_reader','scanner','other') |
name |
text | NOT NULL | — | Human label, e.g. "Front Counter Register" |
identifier |
text | nullable | — | Serial number, Stripe reader ID, MAC address, etc. |
config |
JSONB | nullable | — | Device-specific configuration (Bluetooth pairing info, print settings, etc.) |
status |
text | NOT NULL | 'active' |
CHECK IN ('active','inactive','maintenance') |
last_seen_at |
timestamptz | nullable | — | Last heartbeat or diagnostic ping |
note |
text | nullable | — |
Indexes:
- PK on
id - on (
tenant_id) - on (
site_id) - on (
device_type) - UNIQUE on (
tenant_id,site_id,name) WHEREdeleted_at IS NULL
admin.integration_config — 13 cols
Per-tenant integration settings — enabled/disabled toggle, credentials vault reference, and sync schedule. Config only; runtime and connector code live in the Integrations or Payments modules.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
integration_type |
text | NOT NULL | — | CHECK IN ('quickbooks','stripe','mailchimp','twilio','resend','other') |
is_enabled |
boolean | NOT NULL | false |
Tenant-level on/off toggle |
credentials_ref |
text | nullable | — | Vault reference — OAuth tokens, API keys; never raw |
settings |
JSONB | nullable | — | Integration-specific options (field mappings, preferences, etc.) |
sync_schedule |
text | nullable | — | Cron expression or named interval, e.g. '0 2 * * *' |
last_sync_at |
timestamptz | nullable | — | When last sync completed |
last_sync_status |
text | nullable | — | CHECK (last_sync_status IS NULL OR last_sync_status IN ('success','failed','partial')) |
status |
text | NOT NULL | 'inactive' |
CHECK IN ('active','inactive','error') |
Indexes:
- PK on
id - on (
tenant_id) - on (
integration_type) - UNIQUE on (
tenant_id,integration_type) WHEREdeleted_at IS NULL— one config row per integration per tenant
admin.webhook_config — 12 cols
Tenant-configured outbound webhook endpoints. Delivery runtime lives in the Integrations module.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
name |
text | NOT NULL | — | Human label |
target_url |
text | NOT NULL | — | Destination endpoint |
event_subscriptions |
JSONB | NOT NULL | — | Array of event types to deliver: ["pos.sale_completed","inventory.stock_low",...] |
secret_ref |
text | nullable | — | Vault reference — HMAC signing secret; never stored raw |
is_active |
boolean | NOT NULL | true |
|
last_delivery_at |
timestamptz | nullable | — | When last event was delivered |
last_delivery_status |
text | nullable | — | CHECK (last_delivery_status IS NULL OR last_delivery_status IN ('success','failed')) |
Indexes:
- PK on
id - on (
tenant_id) - on (
is_active)
admin.api_key — 14 cols
Tenant API keys for external integrations or third-party access (Enterprise tier). Stored hashed — raw value shown once at creation.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
name |
text | NOT NULL | — | Human label for the key |
key_prefix |
text | NOT NULL | — | First 8 chars of the raw key — displayed for identification; not secret |
token_hash |
text | NOT NULL | — | SHA-256 hash of the raw key; raw value shown once at creation, never stored |
scopes |
JSONB | nullable | — | Permissions array: ["inventory.read","orders.write",...] |
last_used_at |
timestamptz | nullable | — | |
expires_at |
timestamptz | nullable | — | NULL = never expires |
revoked_at |
timestamptz | nullable | — | When the key was revoked; NULL = active |
revoked_by_user_id |
UUID | nullable | — | FK → identity.identity_user |
created_by_user_id |
UUID | nullable | — | FK → identity.identity_user |
Indexes:
- PK on
id - on (
tenant_id) - UNIQUE on (
token_hash) WHEREdeleted_at IS NULL— inbound key validation lookup - on (
key_prefix) — display listing - on (
expires_at) WHEREexpires_at IS NOT NULL AND deleted_at IS NULL AND revoked_at IS NULL— expiry sweep
admin.approval_workflow — 10 cols
Configurable approval chain definition. Each workflow type has one active definition per tenant; the steps JSONB defines the ordered sequence of approvers and conditions.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
name |
text | NOT NULL | — | Human label, e.g. "Large PO Approval" |
workflow_type |
text | NOT NULL | — | CHECK IN ('po_approval','discount_approval','refund_approval','other') |
is_active |
boolean | NOT NULL | true |
Only one active workflow per (tenant, workflow_type) — DB-enforced via partial unique (see indexes) |
steps |
JSONB | NOT NULL | — | Ordered approval steps: [{"step":1,"approver_role":"manager","condition":"amount_cents > 50000"},...] |
description |
text | nullable | — |
Indexes:
- PK on
id - on (
tenant_id) - on (
workflow_type) - UNIQUE on (
tenant_id,name) WHEREdeleted_at IS NULL - UNIQUE on (
tenant_id,workflow_type) WHEREis_active = true AND deleted_at IS NULL— one active workflow per type per tenant; DB-enforced
admin.approval_routing_rule — 11 cols
Routes a workflow trigger to the correct approval_workflow based on type, amount threshold, and optional site. Multiple rules per type allowed; priority breaks ties.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
approval_workflow_id |
UUID | NOT NULL | — | FK → admin.approval_workflow |
workflow_type |
text | NOT NULL | — | CHECK IN ('po_approval','discount_approval','refund_approval','other') |
threshold_cents |
bigint | nullable | — | Amount above which this rule applies; NULL = applies always |
site_id |
UUID | nullable | — | FK → multi_loc.site; NULL = tenant-wide rule |
priority |
integer | NOT NULL | 0 |
Higher value = higher priority when multiple rules match |
is_active |
boolean | NOT NULL | true |
Indexes:
- PK on
id - on (
tenant_id) - on (
workflow_type) - on (
approval_workflow_id)
admin.approval_request — 18 cols
In-flight and resolved approval instances. The engine routes; the source record (pos.sale, purchasing.purchase_order) records the outcome — no changes to locked schemas.
RLS: tenant-isolated on tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
approval_workflow_id |
UUID | NOT NULL | — | FK → admin.approval_workflow |
workflow_type |
text | NOT NULL | — | CHECK IN ('po_approval','discount_approval','refund_approval','other') |
source_module |
text | NOT NULL | — | CHECK IN ('purchasing','pos','orders','other') |
source_type |
text | NOT NULL | — | e.g. 'purchase_order', 'sale_discount', 'sale_refund' |
source_ref |
UUID | NOT NULL | — | The record needing approval — pos.sale.id / purchasing.purchase_order.id / etc.; plain UUID, cross-module by source_module discriminator |
amount_cents |
bigint | nullable | — | The amount that triggered approval routing |
status |
text | NOT NULL | 'pending' |
CHECK IN ('pending','approved','rejected','cancelled') |
current_step |
integer | NOT NULL | 1 |
Which step in the workflow is currently awaiting action |
requested_by_user_id |
UUID | nullable | — | FK → identity.identity_user — who triggered the approval request |
resolved_by_user_id |
UUID | nullable | — | FK → identity.identity_user — who made the final decision |
resolved_at |
timestamptz | nullable | — | When status reached 'approved'/'rejected'/'cancelled' |
resolution_note |
text | nullable | — | Approver comment |
step_history |
JSONB | nullable | — | Per-step audit: [{"step":1,"approver_id":"uuid","decision":"approved","at":"...","note":"..."},...] |
Indexes:
- PK on
id - on (
tenant_id) - on (
status) WHEREstatus = 'pending'— pending-approval queue - on (
source_module,source_ref) — look up approval state for a given source record - on (
approval_workflow_id)
Column counts: tenant_business_profile(17) + tenant_branding(12) + compliance_document(14) + tenant_setting(11) + hardware_device(13) + integration_config(13) + webhook_config(12) + api_key(14) + approval_workflow(10) + approval_routing_rule(11) + approval_request(18) = 145