Files
flatrender/backend/db/migrations/03_identity_billing.sql
T
soroush.asadi 90ac0b81d1 feat: V2 microservices stack — backend services, gateway, JWT auth
Add full V2 architecture: identity, content, studio (.NET 10) and file,
render, notification, gateway (Go) services with vendored deps, plus DB
migrations, event/API contracts, and an init-db script.

Wire the Next.js frontend to the gateway: server-side JWT auth routes
(login/register/refresh/logout/me), gateway fetch helper, and session/
cookie/jwt helpers under src/lib.

Containerize the stack via docker-compose.v2.yml and per-service
Dockerfiles. Base images resolve through a Nexus mirror (Docker Hub) and
MCR directly; npm/NuGet pull from Nexus groups. Self-host fonts via
next/font/local to avoid Google Fonts (geo-blocked).

Add CI workflow and ignore .env.v2, *.stackdump, and .NET bin/obj.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:29:31 +03:30

315 lines
14 KiB
SQL

-- =====================================================================
-- IDENTITY SCHEMA — Part 3: Plans, Subscriptions, Payments, Discounts
-- =====================================================================
SET search_path TO identity, public;
CREATE TYPE plan_scope AS ENUM ('User','Tenant');
CREATE TYPE billing_period AS ENUM ('Monthly','Quarterly','SemiAnnual','Annual','Lifetime','OneTime');
CREATE TYPE payment_gateway AS ENUM ('ZarinPal','IdPay','Bazaar','Stripe','Balance','Manual');
CREATE TYPE payment_status AS ENUM ('Pending','Succeeded','Failed','Refunded','Cancelled');
CREATE TYPE payment_action AS ENUM ('PlanPurchase','BalanceCharge','ProjectRender','UserProject','StorageUpgrade','Other');
CREATE TYPE discount_kind AS ENUM ('Percentage','FixedAmount','FreeMonths','RenderCredits');
-- ---------------------------------------------------------------------
-- plans — subscription tiers (FlatRender or tenant-defined)
-- ---------------------------------------------------------------------
CREATE TABLE plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
-- NULL tenant_id = global plan available to all tenants
scope plan_scope NOT NULL DEFAULT 'User',
code TEXT NOT NULL, -- 'pro_monthly', 'business_annual'
name TEXT NOT NULL,
description TEXT,
-- Pricing
price_minor BIGINT NOT NULL DEFAULT 0,
before_price_minor BIGINT, -- shown crossed-out
currency TEXT NOT NULL DEFAULT 'IRR',
discount_percentage NUMERIC(5,2) NOT NULL DEFAULT 0,
billing_period billing_period NOT NULL DEFAULT 'Monthly',
months_duration INT, -- for non-monthly
-- Quotas
seconds_charge INT NOT NULL DEFAULT 0, -- render seconds included
monthly_renders_quota INT, -- NULL = unlimited
storage_gb INT NOT NULL DEFAULT 1,
parallel_renders INT NOT NULL DEFAULT 1,
max_resolution TEXT NOT NULL DEFAULT 'FullHD',
min_video_length_sec INT NOT NULL DEFAULT 0,
render_speed_factor NUMERIC(4,2) NOT NULL DEFAULT 1.0,
-- UI
sort INT NOT NULL DEFAULT 0,
icon TEXT,
cover TEXT,
loyalty_mark TEXT,
color TEXT,
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
-- Feature flags carried by this plan
features JSONB NOT NULL DEFAULT '{}'::jsonb,
-- Lifecycle
is_active BOOLEAN NOT NULL DEFAULT TRUE,
available_from TIMESTAMPTZ,
available_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
CONSTRAINT uq_plans_tenant_code UNIQUE (tenant_id, code)
);
CREATE INDEX idx_plans_tenant_active ON plans(tenant_id) WHERE is_active = TRUE AND deleted_at IS NULL;
CREATE TRIGGER tg_plans_updated_at
BEFORE UPDATE ON plans
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- user_plans — active subscriptions
-- ---------------------------------------------------------------------
CREATE TABLE user_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
plan_id UUID NOT NULL REFERENCES plans(id),
-- Snapshot at purchase time
plan_code TEXT NOT NULL,
plan_name TEXT NOT NULL,
price_minor_paid BIGINT NOT NULL,
currency TEXT NOT NULL,
-- Quota state
initial_seconds_charge INT NOT NULL,
remain_charge_sec INT NOT NULL,
added_charge_from_past_plan INT NOT NULL DEFAULT 0,
monthly_renders_used INT NOT NULL DEFAULT 0,
monthly_renders_reset_at TIMESTAMPTZ,
-- Lifecycle
register_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
cancelled_at TIMESTAMPTZ,
cancel_reason TEXT,
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
-- Reference to payment
payment_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_user_plans_active ON user_plans(user_id) WHERE cancelled_at IS NULL;
CREATE INDEX idx_user_plans_expire ON user_plans(expires_at) WHERE cancelled_at IS NULL;
CREATE TRIGGER tg_user_plans_updated_at
BEFORE UPDATE ON user_plans
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- discounts — coupons / affiliate codes
-- ---------------------------------------------------------------------
CREATE TABLE discounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
code CITEXT NOT NULL,
kind discount_kind NOT NULL,
value NUMERIC(14,2) NOT NULL, -- percent or amount
-- Affiliate split
owner_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
owner_profit_percentage NUMERIC(5,2) NOT NULL DEFAULT 0,
only_owner BOOLEAN NOT NULL DEFAULT FALSE, -- usable only by owner
-- Constraints
max_use_count INT, -- NULL = unlimited
used_count INT NOT NULL DEFAULT 0,
min_purchase_minor BIGINT NOT NULL DEFAULT 0,
applies_to_plan_ids UUID[],
starts_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_discounts_tenant_code UNIQUE (tenant_id, code)
);
CREATE INDEX idx_discounts_active ON discounts(tenant_id) WHERE is_active = TRUE;
CREATE INDEX idx_discounts_owner ON discounts(owner_user_id) WHERE owner_user_id IS NOT NULL;
CREATE TRIGGER tg_discounts_updated_at
BEFORE UPDATE ON discounts
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- ---------------------------------------------------------------------
-- used_discounts — usage log per user
-- ---------------------------------------------------------------------
CREATE TABLE used_discounts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discount_id UUID NOT NULL REFERENCES discounts(id) ON DELETE RESTRICT,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
payment_id UUID,
code CITEXT NOT NULL,
amount_discounted_minor BIGINT NOT NULL,
use_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_used_discounts_user ON used_discounts(user_id, use_date DESC);
-- ---------------------------------------------------------------------
-- payments
-- ---------------------------------------------------------------------
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
gateway payment_gateway NOT NULL,
status payment_status NOT NULL DEFAULT 'Pending',
action payment_action NOT NULL,
-- Amounts
amount_minor BIGINT NOT NULL,
currency TEXT NOT NULL DEFAULT 'IRR',
balance_reducer_minor BIGINT NOT NULL DEFAULT 0, -- portion paid from balance
discount_value_minor BIGINT NOT NULL DEFAULT 0,
-- Gateway refs
gateway_token TEXT,
gateway_order_id TEXT,
gateway_track_id TEXT,
gateway_response JSONB,
card_last4 TEXT,
card_hash TEXT,
-- Description
title TEXT,
description TEXT,
-- Affiliate
owner_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
affiliate_profit_minor BIGINT NOT NULL DEFAULT 0,
after_pay_score NUMERIC(8,2) NOT NULL DEFAULT 0,
-- Polymorphic product reference
used_discount_id UUID REFERENCES used_discounts(id),
plan_id UUID REFERENCES plans(id),
user_project_id UUID, -- FK added later if user_projects keeps existing
render_job_id UUID,
product_id UUID,
-- Lifecycle
confirmed_at TIMESTAMPTZ,
failed_at TIMESTAMPTZ,
failure_reason TEXT,
refunded_at TIMESTAMPTZ,
refund_amount_minor BIGINT,
refund_reason TEXT,
-- Checkout/receipt
checkouted BOOLEAN NOT NULL DEFAULT FALSE,
checkout_date TIMESTAMPTZ,
checkout_recipe TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_payments_user ON payments(user_id, created_at DESC);
CREATE INDEX idx_payments_tenant ON payments(tenant_id, created_at DESC);
CREATE INDEX idx_payments_status ON payments(status) WHERE status IN ('Pending','Failed');
CREATE INDEX idx_payments_track ON payments(gateway, gateway_track_id) WHERE gateway_track_id IS NOT NULL;
CREATE TRIGGER tg_payments_updated_at
BEFORE UPDATE ON payments
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
-- Now wire up the FKs that were dangling
ALTER TABLE used_discounts
ADD CONSTRAINT fk_used_disc_payment
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE SET NULL;
ALTER TABLE user_plans
ADD CONSTRAINT fk_user_plans_payment
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE SET NULL;
-- ---------------------------------------------------------------------
-- checkouts — receipts / affiliate payouts log
-- ---------------------------------------------------------------------
CREATE TABLE checkouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
affiliate_owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
title TEXT,
description TEXT,
amount_minor BIGINT NOT NULL,
currency TEXT NOT NULL DEFAULT 'IRR',
card_number_last4 TEXT,
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
paid_at TIMESTAMPTZ,
recipe TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_checkouts_user ON checkouts(user_id, requested_at DESC);
-- ---------------------------------------------------------------------
-- user_projects — freelance custom-template requests
-- ---------------------------------------------------------------------
CREATE TYPE user_project_status AS ENUM (
'Draft','Submitted','Quoted','InProgress','Review','Completed','Cancelled'
);
CREATE TABLE user_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
title TEXT,
description TEXT,
graphist_description TEXT,
attachment_path TEXT,
price_minor BIGINT,
user_suggested_price_minor BIGINT,
status user_project_status NOT NULL DEFAULT 'Draft',
user_max_requested_day_to_create INT,
user_graphist_max_requested_day_to_create INT,
created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
end_time TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_user_projects_user ON user_projects(user_id, status);
CREATE TRIGGER tg_user_projects_updated_at
BEFORE UPDATE ON user_projects
FOR EACH ROW EXECUTE FUNCTION public.tg_set_updated_at();
ALTER TABLE payments
ADD CONSTRAINT fk_payments_user_project
FOREIGN KEY (user_project_id) REFERENCES user_projects(id) ON DELETE SET NULL;