feat(p2p): add Phase 4 merchant tier system and migrations

- Add merchant tier system (Lite/Super/Diamond) with tier badges
- Add advanced order filters (token, fiat, payment method, amount range)
- Add merchant dashboard with stats, ads management, tier upgrade
- Add fraud prevention system with risk scoring and trade limits
- Rename migrations to timestamp format for Supabase CLI compatibility
- Add new migrations: phase2_phase3_tables, fraud_prevention, merchant_system
This commit is contained in:
2025-12-11 10:39:08 +03:00
parent 7330b2e7a6
commit df58d26893
326 changed files with 5197 additions and 174 deletions
+8
View File
@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local
+382
View File
@@ -0,0 +1,382 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "web"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
@@ -30,8 +30,8 @@ CREATE TABLE IF NOT EXISTS public.payment_methods (
CONSTRAINT unique_payment_method UNIQUE (currency, method_name)
);
CREATE INDEX idx_payment_methods_currency_active ON public.payment_methods(currency, is_active);
CREATE INDEX idx_payment_methods_type ON public.payment_methods(method_type);
CREATE INDEX IF NOT EXISTS idx_payment_methods_currency_active ON public.payment_methods(currency, is_active);
CREATE INDEX IF NOT EXISTS idx_payment_methods_type ON public.payment_methods(method_type);
-- =====================================================
-- P2P FIAT OFFERS TABLE
@@ -84,10 +84,10 @@ CREATE TABLE IF NOT EXISTS public.p2p_fiat_offers (
)
);
CREATE INDEX idx_p2p_offers_seller ON public.p2p_fiat_offers(seller_id);
CREATE INDEX idx_p2p_offers_currency ON public.p2p_fiat_offers(fiat_currency, token);
CREATE INDEX idx_p2p_offers_status ON public.p2p_fiat_offers(status)WHERE status IN ('open', 'paused');
CREATE INDEX idx_p2p_offers_active ON public.p2p_fiat_offers(status, fiat_currency, token)
CREATE INDEX IF NOT EXISTS idx_p2p_offers_seller ON public.p2p_fiat_offers(seller_id);
CREATE INDEX IF NOT EXISTS idx_p2p_offers_currency ON public.p2p_fiat_offers(fiat_currency, token);
CREATE INDEX IF NOT EXISTS idx_p2p_offers_status ON public.p2p_fiat_offers(status)WHERE status IN ('open', 'paused');
CREATE INDEX IF NOT EXISTS idx_p2p_offers_active ON public.p2p_fiat_offers(status, fiat_currency, token)
WHERE status = 'open' AND remaining_amount > 0;
-- =====================================================
@@ -141,11 +141,11 @@ CREATE TABLE IF NOT EXISTS public.p2p_fiat_trades (
CONSTRAINT different_users CHECK (seller_id != buyer_id)
);
CREATE INDEX idx_p2p_trades_offer ON public.p2p_fiat_trades(offer_id);
CREATE INDEX idx_p2p_trades_seller ON public.p2p_fiat_trades(seller_id);
CREATE INDEX idx_p2p_trades_buyer ON public.p2p_fiat_trades(buyer_id);
CREATE INDEX idx_p2p_trades_status ON public.p2p_fiat_trades(status);
CREATE INDEX idx_p2p_trades_deadlines ON public.p2p_fiat_trades(payment_deadline, confirmation_deadline)
CREATE INDEX IF NOT EXISTS idx_p2p_trades_offer ON public.p2p_fiat_trades(offer_id);
CREATE INDEX IF NOT EXISTS idx_p2p_trades_seller ON public.p2p_fiat_trades(seller_id);
CREATE INDEX IF NOT EXISTS idx_p2p_trades_buyer ON public.p2p_fiat_trades(buyer_id);
CREATE INDEX IF NOT EXISTS idx_p2p_trades_status ON public.p2p_fiat_trades(status);
CREATE INDEX IF NOT EXISTS idx_p2p_trades_deadlines ON public.p2p_fiat_trades(payment_deadline, confirmation_deadline)
WHERE status IN ('pending', 'payment_sent');
-- =====================================================
@@ -185,9 +185,9 @@ CREATE TABLE IF NOT EXISTS public.p2p_fiat_disputes (
CONSTRAINT one_dispute_per_trade UNIQUE (trade_id)
);
CREATE INDEX idx_disputes_trade ON public.p2p_fiat_disputes(trade_id);
CREATE INDEX idx_disputes_status ON public.p2p_fiat_disputes(status)WHERE status IN ('open', 'under_review');
CREATE INDEX idx_disputes_moderator ON public.p2p_fiat_disputes(assigned_moderator_id) WHERE status = 'under_review';
CREATE INDEX IF NOT EXISTS idx_disputes_trade ON public.p2p_fiat_disputes(trade_id);
CREATE INDEX IF NOT EXISTS idx_disputes_status ON public.p2p_fiat_disputes(status)WHERE status IN ('open', 'under_review');
CREATE INDEX IF NOT EXISTS idx_disputes_moderator ON public.p2p_fiat_disputes(assigned_moderator_id) WHERE status = 'under_review';
-- =====================================================
-- P2P REPUTATION TABLE
@@ -233,8 +233,8 @@ CREATE TABLE IF NOT EXISTS public.p2p_reputation (
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_reputation_score ON public.p2p_reputation(reputation_score DESC);
CREATE INDEX idx_reputation_verified ON public.p2p_reputation(verified_merchant) WHERE verified_merchant = true;
CREATE INDEX IF NOT EXISTS idx_reputation_score ON public.p2p_reputation(reputation_score DESC);
CREATE INDEX IF NOT EXISTS idx_reputation_verified ON public.p2p_reputation(verified_merchant) WHERE verified_merchant = true;
-- =====================================================
-- PLATFORM ESCROW TRACKING
@@ -264,9 +264,9 @@ CREATE TABLE IF NOT EXISTS public.p2p_audit_log (
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_audit_log_user ON public.p2p_audit_log(user_id, created_at DESC);
CREATE INDEX idx_audit_log_entity ON public.p2p_audit_log(entity_type, entity_id);
CREATE INDEX idx_audit_log_created ON public.p2p_audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON public.p2p_audit_log(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON public.p2p_audit_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON public.p2p_audit_log(created_at DESC);
-- =====================================================
-- TRIGGERS FOR UPDATED_AT
@@ -279,15 +279,19 @@ BEGIN
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS update_payment_methods_updated_at ON public.payment_methods;
CREATE TRIGGER update_payment_methods_updated_at BEFORE UPDATE ON public.payment_methods
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_p2p_offers_updated_at ON public.p2p_fiat_offers;
CREATE TRIGGER update_p2p_offers_updated_at BEFORE UPDATE ON public.p2p_fiat_offers
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_p2p_trades_updated_at ON public.p2p_fiat_trades;
CREATE TRIGGER update_p2p_trades_updated_at BEFORE UPDATE ON public.p2p_fiat_trades
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
DROP TRIGGER IF EXISTS update_p2p_disputes_updated_at ON public.p2p_fiat_disputes;
CREATE TRIGGER update_p2p_disputes_updated_at BEFORE UPDATE ON public.p2p_fiat_disputes
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
@@ -0,0 +1,566 @@
-- =====================================================
-- P2P FIAT SYSTEM - PHASE 2 & 3 ADDITIONAL TABLES
-- Messages, Ratings, Notifications, Evidence, Fraud Reports
-- =====================================================
-- =====================================================
-- P2P MESSAGES TABLE (Phase 2 - Chat System)
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trade_id UUID NOT NULL REFERENCES public.p2p_fiat_trades(id) ON DELETE CASCADE,
sender_id UUID NOT NULL REFERENCES auth.users(id),
-- Message content
message TEXT NOT NULL CHECK (LENGTH(message) > 0 AND LENGTH(message) <= 2000),
message_type VARCHAR(20) DEFAULT 'text' CHECK (message_type IN ('text', 'image', 'system')),
attachment_url TEXT, -- Supabase Storage URL
-- Read status
is_read BOOLEAN DEFAULT FALSE,
read_at TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for p2p_messages
CREATE INDEX idx_p2p_messages_trade ON public.p2p_messages(trade_id, created_at DESC);
CREATE INDEX idx_p2p_messages_sender ON public.p2p_messages(sender_id);
CREATE INDEX idx_p2p_messages_unread ON public.p2p_messages(trade_id, is_read) WHERE is_read = false;
-- Enable RLS
ALTER TABLE public.p2p_messages ENABLE ROW LEVEL SECURITY;
-- Policy: Only trade participants can read/write messages
CREATE POLICY "p2p_messages_trade_participants" ON public.p2p_messages
FOR ALL USING (
EXISTS (
SELECT 1 FROM public.p2p_fiat_trades t
WHERE t.id = trade_id
AND (t.seller_id = auth.uid() OR t.buyer_id = auth.uid())
)
);
-- =====================================================
-- P2P RATINGS TABLE (Phase 2 - Trust System)
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_ratings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trade_id UUID NOT NULL REFERENCES public.p2p_fiat_trades(id) ON DELETE CASCADE,
rater_id UUID NOT NULL REFERENCES auth.users(id),
rated_id UUID NOT NULL REFERENCES auth.users(id),
-- Rating details
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
review TEXT CHECK (LENGTH(review) <= 500),
-- Rating aspects (optional breakdown)
communication_rating INT CHECK (communication_rating BETWEEN 1 AND 5),
speed_rating INT CHECK (speed_rating BETWEEN 1 AND 5),
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT unique_rating_per_trade UNIQUE(trade_id, rater_id),
CONSTRAINT cannot_rate_self CHECK (rater_id != rated_id)
);
-- Indexes for p2p_ratings
CREATE INDEX idx_p2p_ratings_trade ON public.p2p_ratings(trade_id);
CREATE INDEX idx_p2p_ratings_rated ON public.p2p_ratings(rated_id, created_at DESC);
CREATE INDEX idx_p2p_ratings_rater ON public.p2p_ratings(rater_id);
-- Enable RLS
ALTER TABLE public.p2p_ratings ENABLE ROW LEVEL SECURITY;
-- Policy: Public read (for reputation display)
CREATE POLICY "p2p_ratings_public_read" ON public.p2p_ratings
FOR SELECT USING (true);
-- Policy: Only trade participants can insert ratings
CREATE POLICY "p2p_ratings_trade_participants_insert" ON public.p2p_ratings
FOR INSERT WITH CHECK (
rater_id = auth.uid() AND
EXISTS (
SELECT 1 FROM public.p2p_fiat_trades t
WHERE t.id = trade_id
AND t.status = 'completed'
AND (t.seller_id = auth.uid() OR t.buyer_id = auth.uid())
)
);
-- =====================================================
-- P2P NOTIFICATIONS TABLE (Phase 2 - Real-time Alerts)
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- Notification content
type VARCHAR(50) NOT NULL CHECK (type IN (
'new_order', 'payment_sent', 'payment_confirmed', 'trade_cancelled',
'dispute_opened', 'dispute_resolved', 'new_message', 'rating_received',
'offer_matched', 'trade_reminder', 'system'
)),
title TEXT NOT NULL,
message TEXT,
-- Reference
reference_type VARCHAR(20) CHECK (reference_type IN ('trade', 'offer', 'dispute', 'message')),
reference_id UUID,
-- Status
is_read BOOLEAN DEFAULT FALSE,
read_at TIMESTAMPTZ,
-- Action URL (frontend route)
action_url TEXT,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for p2p_notifications
CREATE INDEX idx_p2p_notifications_user ON public.p2p_notifications(user_id, created_at DESC);
CREATE INDEX idx_p2p_notifications_unread ON public.p2p_notifications(user_id, is_read) WHERE is_read = false;
CREATE INDEX idx_p2p_notifications_type ON public.p2p_notifications(user_id, type);
-- Enable RLS
ALTER TABLE public.p2p_notifications ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see their own notifications
CREATE POLICY "p2p_notifications_own" ON public.p2p_notifications
FOR ALL USING (user_id = auth.uid());
-- =====================================================
-- P2P DISPUTE EVIDENCE TABLE (Phase 3 - Dispute System)
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_dispute_evidence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
dispute_id UUID NOT NULL REFERENCES public.p2p_fiat_disputes(id) ON DELETE CASCADE,
uploaded_by UUID NOT NULL REFERENCES auth.users(id),
-- Evidence details
evidence_type VARCHAR(30) NOT NULL CHECK (evidence_type IN (
'screenshot', 'receipt', 'bank_statement', 'chat_log',
'transaction_proof', 'identity_doc', 'other'
)),
file_url TEXT NOT NULL,
file_name TEXT,
file_size INT, -- bytes
mime_type TEXT,
-- Description
description TEXT CHECK (LENGTH(description) <= 1000),
-- Admin review
reviewed_by UUID REFERENCES auth.users(id),
reviewed_at TIMESTAMPTZ,
review_notes TEXT,
is_valid BOOLEAN,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for p2p_dispute_evidence
CREATE INDEX idx_p2p_evidence_dispute ON public.p2p_dispute_evidence(dispute_id, created_at);
CREATE INDEX idx_p2p_evidence_uploader ON public.p2p_dispute_evidence(uploaded_by);
-- Enable RLS
ALTER TABLE public.p2p_dispute_evidence ENABLE ROW LEVEL SECURITY;
-- Policy: Dispute parties can view evidence
CREATE POLICY "p2p_evidence_parties_read" ON public.p2p_dispute_evidence
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.p2p_fiat_disputes d
JOIN public.p2p_fiat_trades t ON t.id = d.trade_id
WHERE d.id = dispute_id
AND (t.seller_id = auth.uid() OR t.buyer_id = auth.uid() OR d.opened_by = auth.uid())
)
);
-- Policy: Dispute parties can upload evidence
CREATE POLICY "p2p_evidence_parties_insert" ON public.p2p_dispute_evidence
FOR INSERT WITH CHECK (
uploaded_by = auth.uid() AND
EXISTS (
SELECT 1 FROM public.p2p_fiat_disputes d
JOIN public.p2p_fiat_trades t ON t.id = d.trade_id
WHERE d.id = dispute_id
AND d.status IN ('open', 'under_review')
AND (t.seller_id = auth.uid() OR t.buyer_id = auth.uid())
)
);
-- Policy: Admins can view all evidence
CREATE POLICY "p2p_evidence_admin_read" ON public.p2p_dispute_evidence
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid() AND role IN ('moderator', 'admin')
)
);
-- Policy: Admins can update evidence (for review)
CREATE POLICY "p2p_evidence_admin_update" ON public.p2p_dispute_evidence
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid() AND role IN ('moderator', 'admin')
)
);
-- =====================================================
-- P2P FRAUD REPORTS TABLE (Phase 3 - Security)
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_fraud_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reporter_id UUID NOT NULL REFERENCES auth.users(id),
reported_user_id UUID NOT NULL REFERENCES auth.users(id),
-- Report details
trade_id UUID REFERENCES public.p2p_fiat_trades(id),
reason VARCHAR(50) NOT NULL CHECK (reason IN (
'fake_payment', 'fake_proof', 'scam_attempt', 'harassment',
'money_laundering', 'identity_fraud', 'multiple_accounts', 'other'
)),
description TEXT NOT NULL CHECK (LENGTH(description) >= 20 AND LENGTH(description) <= 2000),
evidence_urls TEXT[] DEFAULT '{}',
-- Investigation
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN (
'pending', 'investigating', 'confirmed', 'dismissed', 'escalated'
)),
assigned_to UUID REFERENCES auth.users(id),
assigned_at TIMESTAMPTZ,
-- Resolution
resolution TEXT,
resolution_notes TEXT,
resolved_by UUID REFERENCES auth.users(id),
resolved_at TIMESTAMPTZ,
-- Action taken
action_taken VARCHAR(30) CHECK (action_taken IN (
'warning_issued', 'temporary_ban', 'permanent_ban',
'trade_restricted', 'no_action', 'referred_to_authorities'
)),
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Constraints
CONSTRAINT cannot_report_self CHECK (reporter_id != reported_user_id)
);
-- Indexes for p2p_fraud_reports
CREATE INDEX idx_p2p_fraud_reporter ON public.p2p_fraud_reports(reporter_id);
CREATE INDEX idx_p2p_fraud_reported ON public.p2p_fraud_reports(reported_user_id);
CREATE INDEX idx_p2p_fraud_status ON public.p2p_fraud_reports(status) WHERE status IN ('pending', 'investigating');
CREATE INDEX idx_p2p_fraud_trade ON public.p2p_fraud_reports(trade_id) WHERE trade_id IS NOT NULL;
-- Enable RLS
ALTER TABLE public.p2p_fraud_reports ENABLE ROW LEVEL SECURITY;
-- Policy: Users can view their own reports
CREATE POLICY "p2p_fraud_own_reports" ON public.p2p_fraud_reports
FOR SELECT USING (reporter_id = auth.uid());
-- Policy: Users can create reports
CREATE POLICY "p2p_fraud_create" ON public.p2p_fraud_reports
FOR INSERT WITH CHECK (reporter_id = auth.uid());
-- Policy: Admins can view all reports
CREATE POLICY "p2p_fraud_admin_read" ON public.p2p_fraud_reports
FOR SELECT USING (
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid() AND role IN ('moderator', 'admin')
)
);
-- Policy: Admins can update reports
CREATE POLICY "p2p_fraud_admin_update" ON public.p2p_fraud_reports
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid() AND role IN ('moderator', 'admin')
)
);
-- =====================================================
-- TRIGGERS FOR UPDATED_AT
-- =====================================================
CREATE TRIGGER update_p2p_messages_updated_at BEFORE UPDATE ON public.p2p_messages
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_p2p_fraud_reports_updated_at BEFORE UPDATE ON public.p2p_fraud_reports
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- =====================================================
-- ENABLE REALTIME FOR NEW TABLES
-- =====================================================
ALTER PUBLICATION supabase_realtime ADD TABLE public.p2p_messages;
ALTER PUBLICATION supabase_realtime ADD TABLE public.p2p_notifications;
-- =====================================================
-- NOTIFICATION TRIGGER FUNCTIONS
-- =====================================================
-- Function to create notification
CREATE OR REPLACE FUNCTION public.create_p2p_notification(
p_user_id UUID,
p_type TEXT,
p_title TEXT,
p_message TEXT DEFAULT NULL,
p_reference_type TEXT DEFAULT NULL,
p_reference_id UUID DEFAULT NULL,
p_action_url TEXT DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'
) RETURNS UUID AS $$
DECLARE
v_notification_id UUID;
BEGIN
INSERT INTO public.p2p_notifications (
user_id, type, title, message,
reference_type, reference_id, action_url, metadata
) VALUES (
p_user_id, p_type, p_title, p_message,
p_reference_type, p_reference_id, p_action_url, p_metadata
)
RETURNING id INTO v_notification_id;
RETURN v_notification_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger: Notify on new trade
CREATE OR REPLACE FUNCTION notify_on_new_trade()
RETURNS TRIGGER AS $$
DECLARE
v_offer RECORD;
BEGIN
-- Get offer details
SELECT * INTO v_offer FROM public.p2p_fiat_offers WHERE id = NEW.offer_id;
-- Notify seller about new order
PERFORM public.create_p2p_notification(
NEW.seller_id,
'new_order',
'New P2P Order',
format('Someone wants to buy %s %s', NEW.crypto_amount, v_offer.token),
'trade',
NEW.id,
format('/p2p/trade/%s', NEW.id)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trigger_notify_new_trade
AFTER INSERT ON public.p2p_fiat_trades
FOR EACH ROW EXECUTE FUNCTION notify_on_new_trade();
-- Trigger: Notify on payment sent
CREATE OR REPLACE FUNCTION notify_on_payment_sent()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status = 'pending' AND NEW.status = 'payment_sent' THEN
PERFORM public.create_p2p_notification(
NEW.seller_id,
'payment_sent',
'Payment Marked as Sent',
'The buyer has marked the payment as sent. Please verify and release.',
'trade',
NEW.id,
format('/p2p/trade/%s', NEW.id)
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trigger_notify_payment_sent
AFTER UPDATE ON public.p2p_fiat_trades
FOR EACH ROW EXECUTE FUNCTION notify_on_payment_sent();
-- Trigger: Notify on trade completed
CREATE OR REPLACE FUNCTION notify_on_trade_completed()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status != 'completed' AND NEW.status = 'completed' THEN
-- Notify buyer
PERFORM public.create_p2p_notification(
NEW.buyer_id,
'payment_confirmed',
'Trade Completed!',
'The seller has released the crypto. Check your wallet.',
'trade',
NEW.id,
format('/p2p/trade/%s', NEW.id)
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trigger_notify_trade_completed
AFTER UPDATE ON public.p2p_fiat_trades
FOR EACH ROW EXECUTE FUNCTION notify_on_trade_completed();
-- Trigger: Notify on dispute opened
CREATE OR REPLACE FUNCTION notify_on_dispute_opened()
RETURNS TRIGGER AS $$
DECLARE
v_trade RECORD;
v_other_party UUID;
BEGIN
-- Get trade details
SELECT * INTO v_trade FROM public.p2p_fiat_trades WHERE id = NEW.trade_id;
-- Determine other party
IF NEW.opened_by = v_trade.seller_id THEN
v_other_party := v_trade.buyer_id;
ELSE
v_other_party := v_trade.seller_id;
END IF;
-- Notify the other party
PERFORM public.create_p2p_notification(
v_other_party,
'dispute_opened',
'Dispute Opened',
'A dispute has been opened for your trade. Please provide evidence.',
'dispute',
NEW.id,
format('/p2p/dispute/%s', NEW.id)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trigger_notify_dispute_opened
AFTER INSERT ON public.p2p_fiat_disputes
FOR EACH ROW EXECUTE FUNCTION notify_on_dispute_opened();
-- Trigger: Notify on new message
CREATE OR REPLACE FUNCTION notify_on_new_message()
RETURNS TRIGGER AS $$
DECLARE
v_trade RECORD;
v_recipient_id UUID;
BEGIN
-- Skip system messages
IF NEW.message_type = 'system' THEN
RETURN NEW;
END IF;
-- Get trade details
SELECT * INTO v_trade FROM public.p2p_fiat_trades WHERE id = NEW.trade_id;
-- Determine recipient
IF NEW.sender_id = v_trade.seller_id THEN
v_recipient_id := v_trade.buyer_id;
ELSE
v_recipient_id := v_trade.seller_id;
END IF;
-- Create notification
PERFORM public.create_p2p_notification(
v_recipient_id,
'new_message',
'New Message',
LEFT(NEW.message, 100),
'trade',
NEW.trade_id,
format('/p2p/trade/%s', NEW.trade_id)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trigger_notify_new_message
AFTER INSERT ON public.p2p_messages
FOR EACH ROW EXECUTE FUNCTION notify_on_new_message();
-- =====================================================
-- RATING HELPER FUNCTION
-- =====================================================
CREATE OR REPLACE FUNCTION public.update_user_rating_stats(p_user_id UUID)
RETURNS void AS $$
DECLARE
v_avg_rating NUMERIC;
v_total_ratings INT;
BEGIN
-- Calculate average rating
SELECT
AVG(rating)::NUMERIC(3,2),
COUNT(*)
INTO v_avg_rating, v_total_ratings
FROM public.p2p_ratings
WHERE rated_id = p_user_id;
-- Update reputation with rating info
UPDATE public.p2p_reputation
SET
updated_at = NOW()
-- Note: You could add avg_rating column to p2p_reputation if needed
WHERE user_id = p_user_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger: Update stats after rating
CREATE OR REPLACE FUNCTION update_rating_stats_trigger()
RETURNS TRIGGER AS $$
BEGIN
PERFORM public.update_user_rating_stats(NEW.rated_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trigger_update_rating_stats
AFTER INSERT ON public.p2p_ratings
FOR EACH ROW EXECUTE FUNCTION update_rating_stats_trigger();
-- =====================================================
-- GRANT EXECUTE PERMISSIONS
-- =====================================================
GRANT EXECUTE ON FUNCTION public.create_p2p_notification TO authenticated;
GRANT EXECUTE ON FUNCTION public.update_user_rating_stats TO authenticated;
-- =====================================================
-- ADD INDEXES FOR PERFORMANCE
-- =====================================================
-- Index for finding user's average rating quickly
CREATE INDEX idx_p2p_ratings_avg ON public.p2p_ratings(rated_id, rating);
-- Index for unread notification count
CREATE INDEX idx_p2p_notifications_unread_count ON public.p2p_notifications(user_id)
WHERE is_read = false;
-- =====================================================
-- COMMENTS FOR DOCUMENTATION
-- =====================================================
COMMENT ON TABLE public.p2p_messages IS 'Real-time chat messages between trade participants';
COMMENT ON TABLE public.p2p_ratings IS 'Post-trade ratings and reviews';
COMMENT ON TABLE public.p2p_notifications IS 'User notifications for P2P trading events';
COMMENT ON TABLE public.p2p_dispute_evidence IS 'Evidence files uploaded during disputes';
COMMENT ON TABLE public.p2p_fraud_reports IS 'Fraud reports submitted by users';
@@ -0,0 +1,456 @@
-- =====================================================
-- P2P FRAUD PREVENTION SYSTEM
-- Auto-detection rules and triggers
-- =====================================================
-- =====================================================
-- USER FRAUD INDICATORS TABLE
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_user_fraud_indicators (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
-- Trade statistics
cancel_rate DECIMAL(5,2) DEFAULT 0, -- Percentage
dispute_rate DECIMAL(5,2) DEFAULT 0, -- Percentage
avg_trade_amount DECIMAL(18,2) DEFAULT 0,
-- Recent activity
recent_cancellations_24h INT DEFAULT 0,
recent_disputes_7d INT DEFAULT 0,
trades_today INT DEFAULT 0,
volume_today DECIMAL(18,2) DEFAULT 0,
-- Risk assessment
risk_score INT DEFAULT 0 CHECK (risk_score BETWEEN 0 AND 100),
risk_level VARCHAR(10) DEFAULT 'low' CHECK (risk_level IN ('low', 'medium', 'high', 'critical')),
active_flags TEXT[] DEFAULT '{}',
-- Restrictions
is_blocked BOOLEAN DEFAULT FALSE,
blocked_reason TEXT,
blocked_at TIMESTAMPTZ,
blocked_until TIMESTAMPTZ,
requires_review BOOLEAN DEFAULT FALSE,
-- Cooldowns
last_cancellation_at TIMESTAMPTZ,
last_dispute_at TIMESTAMPTZ,
last_trade_at TIMESTAMPTZ,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_fraud_indicators_risk ON public.p2p_user_fraud_indicators(risk_score DESC);
CREATE INDEX idx_fraud_indicators_blocked ON public.p2p_user_fraud_indicators(is_blocked) WHERE is_blocked = true;
CREATE INDEX idx_fraud_indicators_review ON public.p2p_user_fraud_indicators(requires_review) WHERE requires_review = true;
-- Enable RLS
ALTER TABLE public.p2p_user_fraud_indicators ENABLE ROW LEVEL SECURITY;
-- Users can view their own indicators (limited)
CREATE POLICY "p2p_fraud_indicators_own_read" ON public.p2p_user_fraud_indicators
FOR SELECT USING (user_id = auth.uid());
-- Admins can view and update all
CREATE POLICY "p2p_fraud_indicators_admin" ON public.p2p_user_fraud_indicators
FOR ALL USING (
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid() AND role IN ('moderator', 'admin')
)
);
-- =====================================================
-- SUSPICIOUS ACTIVITY LOG
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_suspicious_activity (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
trade_id UUID REFERENCES public.p2p_fiat_trades(id),
-- Activity details
activity_type VARCHAR(50) NOT NULL CHECK (activity_type IN (
'high_cancel_rate', 'frequent_disputes', 'rapid_trading',
'unusual_amount', 'new_account_large_trade', 'payment_name_mismatch',
'suspected_multi_account', 'ip_anomaly', 'device_anomaly', 'other'
)),
severity VARCHAR(10) NOT NULL CHECK (severity IN ('low', 'medium', 'high', 'critical')),
description TEXT,
metadata JSONB DEFAULT '{}',
-- Resolution
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'reviewed', 'dismissed', 'actioned')),
reviewed_by UUID REFERENCES auth.users(id),
reviewed_at TIMESTAMPTZ,
action_taken TEXT,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_suspicious_activity_user ON public.p2p_suspicious_activity(user_id, created_at DESC);
CREATE INDEX idx_suspicious_activity_status ON public.p2p_suspicious_activity(status) WHERE status = 'pending';
CREATE INDEX idx_suspicious_activity_severity ON public.p2p_suspicious_activity(severity, created_at DESC);
-- Enable RLS
ALTER TABLE public.p2p_suspicious_activity ENABLE ROW LEVEL SECURITY;
-- Only admins can view
CREATE POLICY "p2p_suspicious_activity_admin" ON public.p2p_suspicious_activity
FOR ALL USING (
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid() AND role IN ('moderator', 'admin')
)
);
-- =====================================================
-- FUNCTION: Calculate User Risk Score
-- =====================================================
CREATE OR REPLACE FUNCTION public.calculate_user_risk_score(p_user_id UUID)
RETURNS TABLE(
risk_score INT,
risk_level TEXT,
flags TEXT[]
) AS $$
DECLARE
v_indicators RECORD;
v_score INT := 0;
v_flags TEXT[] := '{}';
BEGIN
-- Get current indicators
SELECT * INTO v_indicators
FROM public.p2p_user_fraud_indicators
WHERE user_id = p_user_id;
-- If no record exists, create one
IF NOT FOUND THEN
INSERT INTO public.p2p_user_fraud_indicators (user_id)
VALUES (p_user_id)
RETURNING * INTO v_indicators;
END IF;
-- Calculate from reputation data
SELECT
COALESCE(
CASE WHEN total_trades > 0
THEN (cancelled_trades::DECIMAL / total_trades * 100)
ELSE 0
END, 0),
COALESCE(
CASE WHEN total_trades > 0
THEN (disputed_trades::DECIMAL / total_trades * 100)
ELSE 0
END, 0)
INTO v_indicators.cancel_rate, v_indicators.dispute_rate
FROM public.p2p_reputation
WHERE user_id = p_user_id;
-- Apply rules and calculate score
-- Rule 1: High cancel rate (>30%)
IF v_indicators.cancel_rate > 30 THEN
v_score := v_score + 25;
v_flags := array_append(v_flags, 'High Cancellation Rate');
END IF;
-- Rule 2: High dispute rate (>20%)
IF v_indicators.dispute_rate > 20 THEN
v_score := v_score + 30;
v_flags := array_append(v_flags, 'Frequent Disputes');
END IF;
-- Rule 3: Multiple recent cancellations (>3 in 24h)
IF v_indicators.recent_cancellations_24h > 3 THEN
v_score := v_score + 35;
v_flags := array_append(v_flags, 'Multiple Recent Cancellations');
END IF;
-- Rule 4: Recent disputes (>2 in 7d)
IF v_indicators.recent_disputes_7d > 2 THEN
v_score := v_score + 25;
v_flags := array_append(v_flags, 'Recent Disputes');
END IF;
-- Cap score at 100
v_score := LEAST(v_score, 100);
-- Determine risk level
risk_level := CASE
WHEN v_score >= 95 THEN 'critical'
WHEN v_score >= 80 THEN 'high'
WHEN v_score >= 50 THEN 'medium'
ELSE 'low'
END;
-- Update indicators table
UPDATE public.p2p_user_fraud_indicators
SET
risk_score = v_score,
risk_level = risk_level,
active_flags = v_flags,
is_blocked = (v_score >= 95),
requires_review = (v_score >= 80),
updated_at = NOW()
WHERE user_id = p_user_id;
risk_score := v_score;
flags := v_flags;
RETURN NEXT;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =====================================================
-- FUNCTION: Check Trade Allowed
-- =====================================================
CREATE OR REPLACE FUNCTION public.check_trade_allowed(
p_user_id UUID,
p_trade_amount DECIMAL
) RETURNS TABLE(
allowed BOOLEAN,
reason TEXT
) AS $$
DECLARE
v_indicators RECORD;
v_reputation RECORD;
v_limits RECORD;
v_cooldown_ms BIGINT;
BEGIN
-- Get fraud indicators
SELECT * INTO v_indicators
FROM public.p2p_user_fraud_indicators
WHERE user_id = p_user_id;
-- Check if blocked
IF v_indicators IS NOT NULL AND v_indicators.is_blocked THEN
allowed := FALSE;
reason := COALESCE(v_indicators.blocked_reason, 'Your account is temporarily restricted from trading.');
RETURN NEXT;
RETURN;
END IF;
-- Check cooldown after cancellation (5 minutes)
IF v_indicators IS NOT NULL AND v_indicators.last_cancellation_at IS NOT NULL THEN
v_cooldown_ms := EXTRACT(EPOCH FROM (NOW() - v_indicators.last_cancellation_at)) * 1000;
IF v_cooldown_ms < 300000 THEN -- 5 minutes
allowed := FALSE;
reason := format('Please wait %s seconds before creating a new trade.', ((300000 - v_cooldown_ms) / 1000)::INT);
RETURN NEXT;
RETURN;
END IF;
END IF;
-- Check cooldown after dispute (24 hours)
IF v_indicators IS NOT NULL AND v_indicators.last_dispute_at IS NOT NULL THEN
v_cooldown_ms := EXTRACT(EPOCH FROM (NOW() - v_indicators.last_dispute_at)) * 1000;
IF v_cooldown_ms < 86400000 THEN -- 24 hours
allowed := FALSE;
reason := 'Trading is restricted for 24 hours after a dispute.';
RETURN NEXT;
RETURN;
END IF;
END IF;
-- Get reputation for trust level
SELECT * INTO v_reputation
FROM public.p2p_reputation
WHERE user_id = p_user_id;
-- Define limits based on trust level
SELECT * INTO v_limits FROM (
SELECT
CASE COALESCE(v_reputation.trust_level, 'new')
WHEN 'verified' THEN 50000
WHEN 'advanced' THEN 10000
WHEN 'intermediate' THEN 2000
WHEN 'basic' THEN 500
ELSE 100
END as max_trade,
CASE COALESCE(v_reputation.trust_level, 'new')
WHEN 'verified' THEN 50
WHEN 'advanced' THEN 20
WHEN 'intermediate' THEN 10
WHEN 'basic' THEN 5
ELSE 3
END as max_daily_trades,
CASE COALESCE(v_reputation.trust_level, 'new')
WHEN 'verified' THEN 100000
WHEN 'advanced' THEN 25000
WHEN 'intermediate' THEN 5000
WHEN 'basic' THEN 1000
ELSE 200
END as max_daily_volume
) limits;
-- Check trade amount limit
IF p_trade_amount > v_limits.max_trade THEN
allowed := FALSE;
reason := format('Trade amount exceeds your limit of $%s. Complete more trades to increase your limit.', v_limits.max_trade);
RETURN NEXT;
RETURN;
END IF;
-- Check daily trade count
IF v_indicators IS NOT NULL AND v_indicators.trades_today >= v_limits.max_daily_trades THEN
allowed := FALSE;
reason := format('You have reached your daily limit of %s trades.', v_limits.max_daily_trades);
RETURN NEXT;
RETURN;
END IF;
-- Check daily volume
IF v_indicators IS NOT NULL AND (v_indicators.volume_today + p_trade_amount) > v_limits.max_daily_volume THEN
allowed := FALSE;
reason := format('This trade would exceed your daily volume limit of $%s.', v_limits.max_daily_volume);
RETURN NEXT;
RETURN;
END IF;
-- All checks passed
allowed := TRUE;
reason := NULL;
RETURN NEXT;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =====================================================
-- FUNCTION: Log Suspicious Activity
-- =====================================================
CREATE OR REPLACE FUNCTION public.log_suspicious_activity(
p_user_id UUID,
p_trade_id UUID,
p_activity_type TEXT,
p_severity TEXT,
p_description TEXT DEFAULT NULL,
p_metadata JSONB DEFAULT '{}'
) RETURNS UUID AS $$
DECLARE
v_activity_id UUID;
BEGIN
INSERT INTO public.p2p_suspicious_activity (
user_id, trade_id, activity_type, severity, description, metadata
) VALUES (
p_user_id, p_trade_id, p_activity_type, p_severity, p_description, p_metadata
)
RETURNING id INTO v_activity_id;
-- Auto-recalculate risk score
PERFORM public.calculate_user_risk_score(p_user_id);
RETURN v_activity_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =====================================================
-- TRIGGER: Update Fraud Indicators on Trade Completion
-- =====================================================
CREATE OR REPLACE FUNCTION update_fraud_indicators_on_trade()
RETURNS TRIGGER AS $$
BEGIN
-- On trade completion
IF NEW.status = 'completed' AND (OLD.status IS NULL OR OLD.status != 'completed') THEN
-- Update buyer indicators
INSERT INTO public.p2p_user_fraud_indicators (user_id, trades_today, volume_today, last_trade_at)
VALUES (NEW.buyer_id, 1, NEW.fiat_amount, NOW())
ON CONFLICT (user_id) DO UPDATE SET
trades_today = p2p_user_fraud_indicators.trades_today + 1,
volume_today = p2p_user_fraud_indicators.volume_today + NEW.fiat_amount,
last_trade_at = NOW(),
updated_at = NOW();
-- Update seller indicators
INSERT INTO public.p2p_user_fraud_indicators (user_id, trades_today, volume_today, last_trade_at)
VALUES (NEW.seller_id, 1, NEW.fiat_amount, NOW())
ON CONFLICT (user_id) DO UPDATE SET
trades_today = p2p_user_fraud_indicators.trades_today + 1,
volume_today = p2p_user_fraud_indicators.volume_today + NEW.fiat_amount,
last_trade_at = NOW(),
updated_at = NOW();
END IF;
-- On trade cancellation
IF NEW.status = 'cancelled' AND (OLD.status IS NULL OR OLD.status != 'cancelled') THEN
IF NEW.cancelled_by IS NOT NULL THEN
UPDATE public.p2p_user_fraud_indicators
SET
recent_cancellations_24h = recent_cancellations_24h + 1,
last_cancellation_at = NOW(),
updated_at = NOW()
WHERE user_id = NEW.cancelled_by;
-- Recalculate risk score
PERFORM public.calculate_user_risk_score(NEW.cancelled_by);
END IF;
END IF;
-- On dispute
IF NEW.status = 'disputed' AND (OLD.status IS NULL OR OLD.status != 'disputed') THEN
-- Update both parties
UPDATE public.p2p_user_fraud_indicators
SET
recent_disputes_7d = recent_disputes_7d + 1,
last_dispute_at = NOW(),
updated_at = NOW()
WHERE user_id IN (NEW.buyer_id, NEW.seller_id);
-- Recalculate risk scores
PERFORM public.calculate_user_risk_score(NEW.buyer_id);
PERFORM public.calculate_user_risk_score(NEW.seller_id);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trigger_update_fraud_indicators
AFTER UPDATE ON public.p2p_fiat_trades
FOR EACH ROW EXECUTE FUNCTION update_fraud_indicators_on_trade();
-- =====================================================
-- SCHEDULED JOB: Reset Daily Counters
-- (Run at midnight UTC via pg_cron or external scheduler)
-- =====================================================
CREATE OR REPLACE FUNCTION public.reset_daily_fraud_counters()
RETURNS void AS $$
BEGIN
UPDATE public.p2p_user_fraud_indicators
SET
trades_today = 0,
volume_today = 0,
updated_at = NOW();
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =====================================================
-- SCHEDULED JOB: Reset Weekly Counters
-- =====================================================
CREATE OR REPLACE FUNCTION public.reset_weekly_fraud_counters()
RETURNS void AS $$
BEGIN
UPDATE public.p2p_user_fraud_indicators
SET
recent_disputes_7d = 0,
updated_at = NOW();
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =====================================================
-- GRANT PERMISSIONS
-- =====================================================
GRANT EXECUTE ON FUNCTION public.calculate_user_risk_score TO authenticated;
GRANT EXECUTE ON FUNCTION public.check_trade_allowed TO authenticated;
GRANT EXECUTE ON FUNCTION public.log_suspicious_activity TO authenticated;
GRANT EXECUTE ON FUNCTION public.reset_daily_fraud_counters TO authenticated;
GRANT EXECUTE ON FUNCTION public.reset_weekly_fraud_counters TO authenticated;
-- =====================================================
-- COMMENTS
-- =====================================================
COMMENT ON TABLE public.p2p_user_fraud_indicators IS 'Tracks fraud indicators and risk scores for P2P users';
COMMENT ON TABLE public.p2p_suspicious_activity IS 'Log of suspicious activities detected by the fraud system';
COMMENT ON FUNCTION public.calculate_user_risk_score IS 'Calculate and update user risk score based on trading behavior';
COMMENT ON FUNCTION public.check_trade_allowed IS 'Check if a user is allowed to make a trade based on limits and restrictions';
@@ -0,0 +1,534 @@
-- =====================================================
-- P2P MERCHANT SYSTEM - PHASE 4
-- Merchant tiers, stats, and advanced features
-- =====================================================
-- =====================================================
-- MERCHANT TIERS TABLE
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_merchant_tiers (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
-- Tier info
tier VARCHAR(20) DEFAULT 'lite' CHECK (tier IN ('lite', 'super', 'diamond')),
-- Deposit (for higher tiers)
deposit_amount DECIMAL(18,2) DEFAULT 0,
deposit_token VARCHAR(10) DEFAULT 'HEZ',
deposit_tx_hash TEXT,
deposit_locked_at TIMESTAMPTZ,
-- Limits based on tier
max_pending_orders INT DEFAULT 5,
max_order_amount DECIMAL(18,2) DEFAULT 10000,
featured_ads_allowed INT DEFAULT 0,
-- Application status
application_status VARCHAR(20) CHECK (application_status IN ('pending', 'approved', 'rejected', 'suspended')),
applied_at TIMESTAMPTZ,
applied_for_tier VARCHAR(20),
approved_at TIMESTAMPTZ,
approved_by UUID REFERENCES auth.users(id),
rejection_reason TEXT,
-- Review
last_review_at TIMESTAMPTZ,
next_review_at TIMESTAMPTZ,
review_notes TEXT,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_merchant_tiers_tier ON public.p2p_merchant_tiers(tier);
CREATE INDEX idx_merchant_tiers_pending ON public.p2p_merchant_tiers(application_status)
WHERE application_status = 'pending';
-- Enable RLS
ALTER TABLE public.p2p_merchant_tiers ENABLE ROW LEVEL SECURITY;
-- Users can view their own tier
CREATE POLICY "p2p_merchant_own_read" ON public.p2p_merchant_tiers
FOR SELECT USING (user_id = auth.uid());
-- Users can apply for tier (insert/update own)
CREATE POLICY "p2p_merchant_own_apply" ON public.p2p_merchant_tiers
FOR INSERT WITH CHECK (user_id = auth.uid());
CREATE POLICY "p2p_merchant_own_update" ON public.p2p_merchant_tiers
FOR UPDATE USING (user_id = auth.uid())
WITH CHECK (
-- Can only update application_status to 'pending' and applied_for_tier
user_id = auth.uid()
);
-- Public can see tier info (for display in ads)
CREATE POLICY "p2p_merchant_public_tier" ON public.p2p_merchant_tiers
FOR SELECT USING (true);
-- Admins can manage all
CREATE POLICY "p2p_merchant_admin" ON public.p2p_merchant_tiers
FOR ALL USING (
EXISTS (
SELECT 1 FROM public.admin_roles
WHERE user_id = auth.uid() AND role IN ('admin')
)
);
-- =====================================================
-- MERCHANT STATS TABLE (Rolling 30-day stats)
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_merchant_stats (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
-- Volume stats (30 day rolling)
total_volume_30d DECIMAL(18,2) DEFAULT 0,
total_trades_30d INT DEFAULT 0,
buy_volume_30d DECIMAL(18,2) DEFAULT 0,
sell_volume_30d DECIMAL(18,2) DEFAULT 0,
-- Performance metrics
completion_rate_30d DECIMAL(5,2) DEFAULT 0,
avg_release_time_minutes INT,
avg_payment_time_minutes INT,
-- Lifetime stats
total_volume_lifetime DECIMAL(18,2) DEFAULT 0,
total_trades_lifetime INT DEFAULT 0,
-- Ranking
volume_rank INT,
trade_count_rank INT,
-- Timestamps
last_calculated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_merchant_stats_volume ON public.p2p_merchant_stats(total_volume_30d DESC);
CREATE INDEX idx_merchant_stats_trades ON public.p2p_merchant_stats(total_trades_30d DESC);
-- Enable RLS
ALTER TABLE public.p2p_merchant_stats ENABLE ROW LEVEL SECURITY;
-- Public read (for leaderboards)
CREATE POLICY "p2p_merchant_stats_public" ON public.p2p_merchant_stats
FOR SELECT USING (true);
-- =====================================================
-- FEATURED ADS TABLE
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_featured_ads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
offer_id UUID NOT NULL REFERENCES public.p2p_fiat_offers(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id),
-- Featuring details
position INT DEFAULT 1, -- 1 = top, 2 = second, etc.
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
-- Payment
fee_amount DECIMAL(18,2) NOT NULL,
fee_token VARCHAR(10) DEFAULT 'HEZ',
fee_tx_hash TEXT,
paid_at TIMESTAMPTZ,
-- Status
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'expired', 'cancelled')),
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_featured_ads_active ON public.p2p_featured_ads(status, start_at, end_at)
WHERE status = 'active';
CREATE INDEX idx_featured_ads_offer ON public.p2p_featured_ads(offer_id);
-- Enable RLS
ALTER TABLE public.p2p_featured_ads ENABLE ROW LEVEL SECURITY;
-- Users can view active featured ads
CREATE POLICY "p2p_featured_public_read" ON public.p2p_featured_ads
FOR SELECT USING (status = 'active' OR user_id = auth.uid());
-- Users can create featured ads for own offers
CREATE POLICY "p2p_featured_own_create" ON public.p2p_featured_ads
FOR INSERT WITH CHECK (
user_id = auth.uid() AND
EXISTS (
SELECT 1 FROM public.p2p_fiat_offers
WHERE id = offer_id AND seller_id = auth.uid()
)
);
-- =====================================================
-- USER PAYMENT METHODS (Saved methods)
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_user_payment_methods (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
payment_method_id UUID NOT NULL REFERENCES public.payment_methods(id),
-- Account details (encrypted)
account_details_encrypted TEXT NOT NULL,
account_name TEXT, -- For display/verification
-- Settings
is_default BOOLEAN DEFAULT FALSE,
is_verified BOOLEAN DEFAULT FALSE,
verified_at TIMESTAMPTZ,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_user_payment_methods_user ON public.p2p_user_payment_methods(user_id);
CREATE UNIQUE INDEX idx_user_payment_methods_default ON public.p2p_user_payment_methods(user_id)
WHERE is_default = true;
-- Enable RLS
ALTER TABLE public.p2p_user_payment_methods ENABLE ROW LEVEL SECURITY;
-- Users can manage own payment methods
CREATE POLICY "p2p_user_payment_own" ON public.p2p_user_payment_methods
FOR ALL USING (user_id = auth.uid());
-- =====================================================
-- TIER REQUIREMENTS CONSTANTS
-- =====================================================
CREATE TABLE IF NOT EXISTS public.p2p_tier_requirements (
tier VARCHAR(20) PRIMARY KEY,
min_trades INT NOT NULL,
min_completion_rate DECIMAL(5,2) NOT NULL,
min_volume_30d DECIMAL(18,2) NOT NULL,
deposit_required DECIMAL(18,2) NOT NULL,
deposit_token VARCHAR(10) DEFAULT 'HEZ',
max_pending_orders INT NOT NULL,
max_order_amount DECIMAL(18,2) NOT NULL,
featured_ads_allowed INT NOT NULL,
description TEXT
);
-- Insert tier requirements
INSERT INTO public.p2p_tier_requirements (tier, min_trades, min_completion_rate, min_volume_30d, deposit_required, max_pending_orders, max_order_amount, featured_ads_allowed, description)
VALUES
('lite', 0, 0, 0, 0, 5, 10000, 0, 'Basic tier for all verified users'),
('super', 20, 90, 5000, 10000, 20, 100000, 3, 'Professional trader tier with higher limits'),
('diamond', 100, 95, 25000, 50000, 50, 150000, 10, 'Elite merchant tier with maximum privileges')
ON CONFLICT (tier) DO UPDATE SET
min_trades = EXCLUDED.min_trades,
min_completion_rate = EXCLUDED.min_completion_rate,
min_volume_30d = EXCLUDED.min_volume_30d,
deposit_required = EXCLUDED.deposit_required,
max_pending_orders = EXCLUDED.max_pending_orders,
max_order_amount = EXCLUDED.max_order_amount,
featured_ads_allowed = EXCLUDED.featured_ads_allowed,
description = EXCLUDED.description;
-- =====================================================
-- FUNCTION: Check Tier Eligibility
-- =====================================================
CREATE OR REPLACE FUNCTION public.check_tier_eligibility(
p_user_id UUID,
p_target_tier VARCHAR(20)
) RETURNS TABLE(
eligible BOOLEAN,
missing_requirements TEXT[]
) AS $$
DECLARE
v_reputation RECORD;
v_stats RECORD;
v_requirements RECORD;
v_missing TEXT[] := '{}';
BEGIN
-- Get requirements
SELECT * INTO v_requirements
FROM public.p2p_tier_requirements
WHERE tier = p_target_tier;
IF NOT FOUND THEN
eligible := FALSE;
missing_requirements := ARRAY['Invalid tier'];
RETURN NEXT;
RETURN;
END IF;
-- Get user reputation
SELECT * INTO v_reputation
FROM public.p2p_reputation
WHERE user_id = p_user_id;
-- Get user stats
SELECT * INTO v_stats
FROM public.p2p_merchant_stats
WHERE user_id = p_user_id;
-- Check completed trades
IF COALESCE(v_reputation.completed_trades, 0) < v_requirements.min_trades THEN
v_missing := array_append(v_missing,
format('Need %s completed trades (have %s)',
v_requirements.min_trades,
COALESCE(v_reputation.completed_trades, 0)));
END IF;
-- Check completion rate
IF COALESCE(v_stats.completion_rate_30d, 0) < v_requirements.min_completion_rate THEN
v_missing := array_append(v_missing,
format('Need %s%% completion rate (have %s%%)',
v_requirements.min_completion_rate,
COALESCE(v_stats.completion_rate_30d, 0)));
END IF;
-- Check 30-day volume
IF COALESCE(v_stats.total_volume_30d, 0) < v_requirements.min_volume_30d THEN
v_missing := array_append(v_missing,
format('Need $%s 30-day volume (have $%s)',
v_requirements.min_volume_30d,
COALESCE(v_stats.total_volume_30d, 0)));
END IF;
-- Check deposit requirement
IF v_requirements.deposit_required > 0 THEN
v_missing := array_append(v_missing,
format('Deposit of %s %s required',
v_requirements.deposit_required,
v_requirements.deposit_token));
END IF;
eligible := array_length(v_missing, 1) IS NULL OR array_length(v_missing, 1) = 0;
missing_requirements := v_missing;
RETURN NEXT;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =====================================================
-- FUNCTION: Apply for Tier Upgrade
-- =====================================================
CREATE OR REPLACE FUNCTION public.apply_for_tier_upgrade(
p_user_id UUID,
p_target_tier VARCHAR(20)
) RETURNS TABLE(
success BOOLEAN,
message TEXT
) AS $$
DECLARE
v_eligibility RECORD;
v_current_tier RECORD;
BEGIN
-- Check current tier
SELECT * INTO v_current_tier
FROM public.p2p_merchant_tiers
WHERE user_id = p_user_id;
-- Check if already at or above target tier
IF v_current_tier IS NOT NULL THEN
IF v_current_tier.tier = p_target_tier THEN
success := FALSE;
message := 'You are already at this tier';
RETURN NEXT;
RETURN;
END IF;
IF v_current_tier.application_status = 'pending' THEN
success := FALSE;
message := 'You already have a pending application';
RETURN NEXT;
RETURN;
END IF;
END IF;
-- Check eligibility
SELECT * INTO v_eligibility
FROM public.check_tier_eligibility(p_user_id, p_target_tier);
IF NOT v_eligibility.eligible THEN
success := FALSE;
message := 'Not eligible: ' || array_to_string(v_eligibility.missing_requirements, ', ');
RETURN NEXT;
RETURN;
END IF;
-- Create or update application
INSERT INTO public.p2p_merchant_tiers (
user_id, application_status, applied_at, applied_for_tier
) VALUES (
p_user_id, 'pending', NOW(), p_target_tier
)
ON CONFLICT (user_id) DO UPDATE SET
application_status = 'pending',
applied_at = NOW(),
applied_for_tier = p_target_tier,
updated_at = NOW();
success := TRUE;
message := 'Application submitted successfully';
RETURN NEXT;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =====================================================
-- FUNCTION: Approve Tier Application (Admin)
-- =====================================================
CREATE OR REPLACE FUNCTION public.approve_tier_application(
p_user_id UUID,
p_admin_id UUID
) RETURNS void AS $$
DECLARE
v_application RECORD;
v_requirements RECORD;
BEGIN
-- Get application
SELECT * INTO v_application
FROM public.p2p_merchant_tiers
WHERE user_id = p_user_id AND application_status = 'pending';
IF NOT FOUND THEN
RAISE EXCEPTION 'No pending application found';
END IF;
-- Get tier requirements
SELECT * INTO v_requirements
FROM public.p2p_tier_requirements
WHERE tier = v_application.applied_for_tier;
-- Update tier
UPDATE public.p2p_merchant_tiers
SET
tier = v_application.applied_for_tier,
application_status = 'approved',
approved_at = NOW(),
approved_by = p_admin_id,
max_pending_orders = v_requirements.max_pending_orders,
max_order_amount = v_requirements.max_order_amount,
featured_ads_allowed = v_requirements.featured_ads_allowed,
updated_at = NOW()
WHERE user_id = p_user_id;
-- Create notification
PERFORM public.create_p2p_notification(
p_user_id,
'system',
'Tier Upgrade Approved!',
format('Congratulations! You have been upgraded to %s tier.', v_application.applied_for_tier),
NULL,
NULL,
'/p2p/merchant'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =====================================================
-- FUNCTION: Calculate Merchant Stats
-- =====================================================
CREATE OR REPLACE FUNCTION public.calculate_merchant_stats(p_user_id UUID)
RETURNS void AS $$
DECLARE
v_stats RECORD;
BEGIN
-- Calculate 30-day stats
SELECT
COUNT(*) as trades_30d,
COALESCE(SUM(fiat_amount), 0) as volume_30d,
COALESCE(SUM(CASE WHEN buyer_id = p_user_id THEN fiat_amount ELSE 0 END), 0) as buy_volume,
COALESCE(SUM(CASE WHEN seller_id = p_user_id THEN fiat_amount ELSE 0 END), 0) as sell_volume,
COALESCE(
(COUNT(*) FILTER (WHERE status = 'completed')::DECIMAL /
NULLIF(COUNT(*), 0) * 100), 0
) as completion_rate,
AVG(EXTRACT(EPOCH FROM (seller_confirmed_at - buyer_marked_paid_at)) / 60)
FILTER (WHERE seller_id = p_user_id AND seller_confirmed_at IS NOT NULL) as avg_release,
AVG(EXTRACT(EPOCH FROM (buyer_marked_paid_at - created_at)) / 60)
FILTER (WHERE buyer_id = p_user_id AND buyer_marked_paid_at IS NOT NULL) as avg_payment
INTO v_stats
FROM public.p2p_fiat_trades
WHERE (buyer_id = p_user_id OR seller_id = p_user_id)
AND created_at >= NOW() - INTERVAL '30 days';
-- Upsert stats
INSERT INTO public.p2p_merchant_stats (
user_id,
total_volume_30d,
total_trades_30d,
buy_volume_30d,
sell_volume_30d,
completion_rate_30d,
avg_release_time_minutes,
avg_payment_time_minutes,
last_calculated_at
) VALUES (
p_user_id,
v_stats.volume_30d,
v_stats.trades_30d,
v_stats.buy_volume,
v_stats.sell_volume,
v_stats.completion_rate,
v_stats.avg_release::INT,
v_stats.avg_payment::INT,
NOW()
)
ON CONFLICT (user_id) DO UPDATE SET
total_volume_30d = v_stats.volume_30d,
total_trades_30d = v_stats.trades_30d,
buy_volume_30d = v_stats.buy_volume,
sell_volume_30d = v_stats.sell_volume,
completion_rate_30d = v_stats.completion_rate,
avg_release_time_minutes = v_stats.avg_release::INT,
avg_payment_time_minutes = v_stats.avg_payment::INT,
last_calculated_at = NOW();
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- =====================================================
-- TRIGGER: Update stats on trade completion
-- =====================================================
CREATE OR REPLACE FUNCTION update_merchant_stats_on_trade()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.status = 'completed' AND (OLD.status IS NULL OR OLD.status != 'completed') THEN
PERFORM public.calculate_merchant_stats(NEW.buyer_id);
PERFORM public.calculate_merchant_stats(NEW.seller_id);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER trigger_update_merchant_stats
AFTER UPDATE ON public.p2p_fiat_trades
FOR EACH ROW EXECUTE FUNCTION update_merchant_stats_on_trade();
-- =====================================================
-- ADD is_featured COLUMN TO OFFERS
-- =====================================================
ALTER TABLE public.p2p_fiat_offers
ADD COLUMN IF NOT EXISTS is_featured BOOLEAN DEFAULT FALSE;
ALTER TABLE public.p2p_fiat_offers
ADD COLUMN IF NOT EXISTS featured_until TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_p2p_offers_featured ON public.p2p_fiat_offers(is_featured, featured_until)
WHERE is_featured = true;
-- =====================================================
-- GRANTS
-- =====================================================
GRANT EXECUTE ON FUNCTION public.check_tier_eligibility TO authenticated;
GRANT EXECUTE ON FUNCTION public.apply_for_tier_upgrade TO authenticated;
GRANT EXECUTE ON FUNCTION public.approve_tier_application TO authenticated;
GRANT EXECUTE ON FUNCTION public.calculate_merchant_stats TO authenticated;
-- =====================================================
-- COMMENTS
-- =====================================================
COMMENT ON TABLE public.p2p_merchant_tiers IS 'Merchant tier assignments and applications';
COMMENT ON TABLE public.p2p_merchant_stats IS 'Rolling 30-day trading statistics for merchants';
COMMENT ON TABLE public.p2p_featured_ads IS 'Featured/promoted P2P advertisements';
COMMENT ON TABLE public.p2p_tier_requirements IS 'Requirements for each merchant tier level';