[SAAS-021] feat: Add MLM module DDL

- 6 tables: structures, ranks, nodes, commissions, bonuses, rank_history
- 5 enums: structure_type, node_status, commission_type, commission_status, bonus_type
- LTREE extension for hierarchical path queries
- 24 RLS policies for multi-tenancy
- GIST index for LTREE path column

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-25 06:48:34 -06:00
parent 6e5244b612
commit 2837480e17
5 changed files with 523 additions and 0 deletions

View File

@ -0,0 +1,20 @@
-- =============================================
-- Schema: mlm
-- Module: SAAS-021 MLM (Multi-Level Marketing)
-- =============================================
-- Crear schema
CREATE SCHEMA IF NOT EXISTS mlm;
-- Comentario
COMMENT ON SCHEMA mlm IS 'MLM multi-level marketing module - network structures, nodes, ranks, and commissions';
-- Grants
GRANT USAGE ON SCHEMA mlm TO template_saas_user;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA mlm TO template_saas_user;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA mlm TO template_saas_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA mlm GRANT ALL PRIVILEGES ON TABLES TO template_saas_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA mlm GRANT ALL PRIVILEGES ON SEQUENCES TO template_saas_user;
-- Enable LTREE extension (required for hierarchical path queries)
CREATE EXTENSION IF NOT EXISTS ltree;

View File

@ -0,0 +1,55 @@
-- =============================================
-- Enums: mlm
-- Module: SAAS-021 MLM (Multi-Level Marketing)
-- =============================================
-- Structure type
CREATE TYPE mlm.structure_type AS ENUM (
'unilevel', -- Unlimited width, limited depth
'binary', -- Max 2 children per node
'matrix', -- Fixed width x depth
'hybrid' -- Custom configuration
);
COMMENT ON TYPE mlm.structure_type IS 'Types of MLM network structures';
-- Node status
CREATE TYPE mlm.node_status AS ENUM (
'pending', -- Awaiting activation
'active', -- Active in network
'inactive', -- Temporarily inactive
'suspended' -- Administratively suspended
);
COMMENT ON TYPE mlm.node_status IS 'Status of a node in the network';
-- Commission type
CREATE TYPE mlm.commission_type AS ENUM (
'level', -- Direct level commission (1st gen, 2nd gen, etc.)
'matching', -- Matching bonus from downline earnings
'infinity', -- Infinity bonus (unlimited depth after rank)
'leadership', -- Leadership bonus for qualified ranks
'pool' -- Pool share bonus
);
COMMENT ON TYPE mlm.commission_type IS 'Types of MLM commissions';
-- Commission status
CREATE TYPE mlm.commission_status AS ENUM (
'pending', -- Awaiting approval
'approved', -- Approved for payment
'paid', -- Paid out
'cancelled' -- Cancelled
);
COMMENT ON TYPE mlm.commission_status IS 'Status of a commission entry';
-- Bonus type
CREATE TYPE mlm.bonus_type AS ENUM (
'rank_achievement', -- One-time bonus for reaching rank
'rank_maintenance', -- Monthly bonus for maintaining rank
'fast_start', -- Fast start bonus for quick enrollments
'pool_share' -- Share of global pool
);
COMMENT ON TYPE mlm.bonus_type IS 'Types of bonuses in MLM';

View File

@ -0,0 +1,235 @@
-- =============================================
-- Tables: mlm
-- Module: SAAS-021 MLM (Multi-Level Marketing)
-- =============================================
-- ─────────────────────────────────────────────
-- structures - MLM network structure configuration
-- ─────────────────────────────────────────────
CREATE TABLE mlm.structures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
type mlm.structure_type NOT NULL,
-- Configuration per type (JSONB)
-- Unilevel: { max_width: null, max_depth: 10 }
-- Binary: { spillover: 'left_first' | 'weak_leg' | 'balanced' }
-- Matrix: { width: 3, depth: 7 }
config JSONB NOT NULL DEFAULT '{}',
-- Commission rates by level (JSONB array)
-- [{ level: 1, rate: 0.10 }, { level: 2, rate: 0.05 }, ...]
level_rates JSONB NOT NULL DEFAULT '[]',
-- Matching bonus rates (for matching commissions)
matching_rates JSONB DEFAULT '[]',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES users.users(id) ON DELETE SET NULL,
CONSTRAINT unique_structure_name_per_tenant UNIQUE (tenant_id, name)
);
COMMENT ON TABLE mlm.structures IS 'MLM network structure configurations';
COMMENT ON COLUMN mlm.structures.config IS 'Structure-specific configuration (max_depth, spillover, etc.)';
COMMENT ON COLUMN mlm.structures.level_rates IS 'Commission percentages by level depth';
-- Trigger for updated_at
CREATE TRIGGER set_structures_updated_at
BEFORE UPDATE ON mlm.structures
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- ─────────────────────────────────────────────
-- ranks - MLM qualification ranks
-- ─────────────────────────────────────────────
CREATE TABLE mlm.ranks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
structure_id UUID NOT NULL REFERENCES mlm.structures(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
level INTEGER NOT NULL, -- 1=Entry, 2=Bronze, 3=Silver, etc.
badge_url VARCHAR(500),
color VARCHAR(7), -- Hex color for UI
-- Requirements to achieve rank (JSONB)
-- {
-- personal_volume: 1000,
-- group_volume: 10000,
-- direct_referrals: 3,
-- active_legs: 2,
-- rank_in_legs: { rank_level: 2, count: 1 }
-- }
requirements JSONB NOT NULL DEFAULT '{}',
-- Benefits for this rank
bonus_rate DECIMAL(10,4), -- Additional bonus percentage
benefits JSONB DEFAULT '{}', -- Other benefits (discounts, access, etc.)
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_rank_level_per_structure UNIQUE (structure_id, level)
);
COMMENT ON TABLE mlm.ranks IS 'MLM qualification ranks with requirements and benefits';
COMMENT ON COLUMN mlm.ranks.requirements IS 'Conditions to achieve this rank';
-- Trigger for updated_at
CREATE TRIGGER set_ranks_updated_at
BEFORE UPDATE ON mlm.ranks
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- ─────────────────────────────────────────────
-- nodes - MLM network nodes (distributors)
-- ─────────────────────────────────────────────
CREATE TABLE mlm.nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
structure_id UUID NOT NULL REFERENCES mlm.structures(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Hierarchy
parent_id UUID REFERENCES mlm.nodes(id) ON DELETE SET NULL,
sponsor_id UUID REFERENCES mlm.nodes(id) ON DELETE SET NULL, -- Who referred them
position INTEGER, -- For binary: 1=left, 2=right. For matrix: 1,2,3...width
-- Materialized path for efficient queries (LTREE)
path LTREE,
depth INTEGER DEFAULT 0,
-- Current and highest rank
rank_id UUID REFERENCES mlm.ranks(id) ON DELETE SET NULL,
highest_rank_id UUID REFERENCES mlm.ranks(id) ON DELETE SET NULL,
-- Performance metrics
personal_volume DECIMAL(15,2) DEFAULT 0,
group_volume DECIMAL(15,2) DEFAULT 0,
direct_referrals INTEGER DEFAULT 0,
total_downline INTEGER DEFAULT 0,
-- Lifetime earnings
total_earnings DECIMAL(15,2) DEFAULT 0,
-- Status
status mlm.node_status NOT NULL DEFAULT 'active',
joined_at TIMESTAMPTZ DEFAULT NOW(),
-- Invitation
invite_code VARCHAR(20) UNIQUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT unique_user_per_structure UNIQUE (structure_id, user_id)
);
COMMENT ON TABLE mlm.nodes IS 'MLM network nodes representing distributors in the hierarchy';
COMMENT ON COLUMN mlm.nodes.path IS 'LTREE path for efficient ancestor/descendant queries';
COMMENT ON COLUMN mlm.nodes.position IS 'Position under parent (left/right for binary, slot for matrix)';
-- Trigger for updated_at
CREATE TRIGGER set_nodes_updated_at
BEFORE UPDATE ON mlm.nodes
FOR EACH ROW
EXECUTE FUNCTION public.set_updated_at();
-- ─────────────────────────────────────────────
-- commissions - MLM commission entries
-- ─────────────────────────────────────────────
CREATE TABLE mlm.commissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
node_id UUID NOT NULL REFERENCES mlm.nodes(id) ON DELETE CASCADE, -- Who receives
source_node_id UUID NOT NULL REFERENCES mlm.nodes(id) ON DELETE CASCADE, -- Who generated
-- Commission type
type mlm.commission_type NOT NULL,
-- Level difference (1 = direct, 2 = second level, etc.)
level INTEGER NOT NULL,
-- Amounts
source_amount DECIMAL(15,2) NOT NULL, -- Original sale/volume amount
rate_applied DECIMAL(10,4) NOT NULL, -- Rate used for calculation
commission_amount DECIMAL(15,2) NOT NULL, -- Final commission
currency VARCHAR(3) DEFAULT 'USD',
-- Reference to period and source
period_id UUID REFERENCES commissions.periods(id) ON DELETE SET NULL,
source_reference VARCHAR(200), -- Reference to sale/transaction
-- Status
status mlm.commission_status NOT NULL DEFAULT 'pending',
paid_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE mlm.commissions IS 'MLM commission entries from downline activity';
COMMENT ON COLUMN mlm.commissions.level IS 'Level depth from source to beneficiary';
-- ─────────────────────────────────────────────
-- bonuses - MLM bonus entries
-- ─────────────────────────────────────────────
CREATE TABLE mlm.bonuses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
node_id UUID NOT NULL REFERENCES mlm.nodes(id) ON DELETE CASCADE,
rank_id UUID REFERENCES mlm.ranks(id) ON DELETE SET NULL,
type mlm.bonus_type NOT NULL,
amount DECIMAL(15,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
-- Reference to period
period_id UUID REFERENCES commissions.periods(id) ON DELETE SET NULL,
-- Status
status mlm.commission_status NOT NULL DEFAULT 'pending',
paid_at TIMESTAMPTZ,
achieved_at TIMESTAMPTZ DEFAULT NOW(),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE mlm.bonuses IS 'MLM bonus entries for rank achievements and other bonuses';
-- ─────────────────────────────────────────────
-- rank_history - Historical rank achievements
-- ─────────────────────────────────────────────
CREATE TABLE mlm.rank_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
node_id UUID NOT NULL REFERENCES mlm.nodes(id) ON DELETE CASCADE,
rank_id UUID NOT NULL REFERENCES mlm.ranks(id) ON DELETE CASCADE,
previous_rank_id UUID REFERENCES mlm.ranks(id) ON DELETE SET NULL,
-- Snapshot of metrics at achievement
personal_volume_at DECIMAL(15,2),
group_volume_at DECIMAL(15,2),
direct_referrals_at INTEGER,
achieved_at TIMESTAMPTZ DEFAULT NOW()
);
COMMENT ON TABLE mlm.rank_history IS 'Historical record of rank achievements';

126
ddl/schemas/mlm/04-rls.sql Normal file
View File

@ -0,0 +1,126 @@
-- =============================================
-- RLS Policies: mlm
-- Module: SAAS-021 MLM (Multi-Level Marketing)
-- =============================================
-- Enable RLS on all tables
ALTER TABLE mlm.structures ENABLE ROW LEVEL SECURITY;
ALTER TABLE mlm.ranks ENABLE ROW LEVEL SECURITY;
ALTER TABLE mlm.nodes ENABLE ROW LEVEL SECURITY;
ALTER TABLE mlm.commissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE mlm.bonuses ENABLE ROW LEVEL SECURITY;
ALTER TABLE mlm.rank_history ENABLE ROW LEVEL SECURITY;
-- ─────────────────────────────────────────────
-- structures policies
-- ─────────────────────────────────────────────
CREATE POLICY structures_tenant_isolation_select
ON mlm.structures FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY structures_tenant_isolation_insert
ON mlm.structures FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY structures_tenant_isolation_update
ON mlm.structures FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY structures_tenant_isolation_delete
ON mlm.structures FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ─────────────────────────────────────────────
-- ranks policies
-- ─────────────────────────────────────────────
CREATE POLICY ranks_tenant_isolation_select
ON mlm.ranks FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY ranks_tenant_isolation_insert
ON mlm.ranks FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY ranks_tenant_isolation_update
ON mlm.ranks FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY ranks_tenant_isolation_delete
ON mlm.ranks FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ─────────────────────────────────────────────
-- nodes policies
-- ─────────────────────────────────────────────
CREATE POLICY nodes_tenant_isolation_select
ON mlm.nodes FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY nodes_tenant_isolation_insert
ON mlm.nodes FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY nodes_tenant_isolation_update
ON mlm.nodes FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY nodes_tenant_isolation_delete
ON mlm.nodes FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ─────────────────────────────────────────────
-- commissions policies
-- ─────────────────────────────────────────────
CREATE POLICY commissions_tenant_isolation_select
ON mlm.commissions FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY commissions_tenant_isolation_insert
ON mlm.commissions FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY commissions_tenant_isolation_update
ON mlm.commissions FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY commissions_tenant_isolation_delete
ON mlm.commissions FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ─────────────────────────────────────────────
-- bonuses policies
-- ─────────────────────────────────────────────
CREATE POLICY bonuses_tenant_isolation_select
ON mlm.bonuses FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY bonuses_tenant_isolation_insert
ON mlm.bonuses FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY bonuses_tenant_isolation_update
ON mlm.bonuses FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY bonuses_tenant_isolation_delete
ON mlm.bonuses FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- ─────────────────────────────────────────────
-- rank_history policies
-- ─────────────────────────────────────────────
CREATE POLICY rank_history_tenant_isolation_select
ON mlm.rank_history FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY rank_history_tenant_isolation_insert
ON mlm.rank_history FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY rank_history_tenant_isolation_update
ON mlm.rank_history FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY rank_history_tenant_isolation_delete
ON mlm.rank_history FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);

View File

@ -0,0 +1,87 @@
-- =============================================
-- Indexes: mlm
-- Module: SAAS-021 MLM (Multi-Level Marketing)
-- =============================================
-- ─────────────────────────────────────────────
-- structures indexes
-- ─────────────────────────────────────────────
CREATE INDEX idx_structures_tenant ON mlm.structures (tenant_id);
CREATE INDEX idx_structures_active ON mlm.structures (tenant_id, is_active) WHERE is_active = true;
CREATE INDEX idx_structures_type ON mlm.structures (tenant_id, type);
-- ─────────────────────────────────────────────
-- ranks indexes
-- ─────────────────────────────────────────────
CREATE INDEX idx_ranks_tenant ON mlm.ranks (tenant_id);
CREATE INDEX idx_ranks_structure ON mlm.ranks (structure_id);
CREATE INDEX idx_ranks_level ON mlm.ranks (structure_id, level);
CREATE INDEX idx_ranks_active ON mlm.ranks (structure_id, is_active) WHERE is_active = true;
-- ─────────────────────────────────────────────
-- nodes indexes (CRITICAL for performance)
-- ─────────────────────────────────────────────
-- Tenant isolation
CREATE INDEX idx_nodes_tenant ON mlm.nodes (tenant_id);
-- Structure lookup
CREATE INDEX idx_nodes_structure ON mlm.nodes (structure_id);
-- User lookup
CREATE INDEX idx_nodes_user ON mlm.nodes (user_id);
-- Hierarchy lookups
CREATE INDEX idx_nodes_parent ON mlm.nodes (parent_id);
CREATE INDEX idx_nodes_sponsor ON mlm.nodes (sponsor_id);
-- LTREE path index (for ancestor/descendant queries)
CREATE INDEX idx_nodes_path ON mlm.nodes USING GIST (path);
-- Depth-based queries
CREATE INDEX idx_nodes_depth ON mlm.nodes (structure_id, depth);
-- Status filtering
CREATE INDEX idx_nodes_status ON mlm.nodes (structure_id, status);
CREATE INDEX idx_nodes_active ON mlm.nodes (structure_id) WHERE status = 'active';
-- Rank lookups
CREATE INDEX idx_nodes_rank ON mlm.nodes (rank_id);
-- Invite code lookup
CREATE INDEX idx_nodes_invite_code ON mlm.nodes (invite_code) WHERE invite_code IS NOT NULL;
-- Combined for common queries
CREATE INDEX idx_nodes_structure_parent ON mlm.nodes (structure_id, parent_id);
CREATE INDEX idx_nodes_structure_user ON mlm.nodes (structure_id, user_id);
-- ─────────────────────────────────────────────
-- commissions indexes
-- ─────────────────────────────────────────────
CREATE INDEX idx_commissions_tenant ON mlm.commissions (tenant_id);
CREATE INDEX idx_commissions_node ON mlm.commissions (node_id);
CREATE INDEX idx_commissions_source_node ON mlm.commissions (source_node_id);
CREATE INDEX idx_commissions_type ON mlm.commissions (tenant_id, type);
CREATE INDEX idx_commissions_level ON mlm.commissions (tenant_id, level);
CREATE INDEX idx_commissions_status ON mlm.commissions (tenant_id, status);
CREATE INDEX idx_commissions_period ON mlm.commissions (period_id);
CREATE INDEX idx_commissions_pending ON mlm.commissions (tenant_id) WHERE status = 'pending';
CREATE INDEX idx_commissions_created ON mlm.commissions (tenant_id, created_at DESC);
-- ─────────────────────────────────────────────
-- bonuses indexes
-- ─────────────────────────────────────────────
CREATE INDEX idx_bonuses_tenant ON mlm.bonuses (tenant_id);
CREATE INDEX idx_bonuses_node ON mlm.bonuses (node_id);
CREATE INDEX idx_bonuses_rank ON mlm.bonuses (rank_id);
CREATE INDEX idx_bonuses_type ON mlm.bonuses (tenant_id, type);
CREATE INDEX idx_bonuses_status ON mlm.bonuses (tenant_id, status);
CREATE INDEX idx_bonuses_period ON mlm.bonuses (period_id);
CREATE INDEX idx_bonuses_pending ON mlm.bonuses (tenant_id) WHERE status = 'pending';
-- ─────────────────────────────────────────────
-- rank_history indexes
-- ─────────────────────────────────────────────
CREATE INDEX idx_rank_history_tenant ON mlm.rank_history (tenant_id);
CREATE INDEX idx_rank_history_node ON mlm.rank_history (node_id);
CREATE INDEX idx_rank_history_rank ON mlm.rank_history (rank_id);
CREATE INDEX idx_rank_history_achieved ON mlm.rank_history (node_id, achieved_at DESC);