create table if not exists public.platform_saas_plans ( id uuid primary key default gen_random_uuid(), code text not null unique, name text not null, description text, billing_cycle text not null default 'yearly' check (billing_cycle in ('monthly', 'quarterly', 'yearly', 'one_time')), base_amount_cents integer not null default 0, currency text not null default 'CNY', included_quotas jsonb not null default '{}'::jsonb, overage_prices jsonb not null default '{}'::jsonb, feature_flags jsonb not null default '{}'::jsonb, status text not null default 'active' check (status in ('active', 'archived')), sort_order integer not null default 0, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.tenant_billing_profiles ( tenant_id uuid primary key references public.tenants(id) on delete cascade, billing_name text, tax_id text, contact_name text, contact_phone text, contact_email citext, billing_address text, invoice_title text, invoice_type text check (invoice_type in ('none', 'normal_vat', 'special_vat')), bank_name text, bank_account_masked text, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.tenant_invoices ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, invoice_no text not null unique, invoice_type text not null default 'subscription' check (invoice_type in ('subscription', 'service_fee', 'usage_overage', 'manual_adjustment')), status text not null default 'draft' check (status in ('draft', 'issued', 'paid', 'void', 'overdue')), currency text not null default 'CNY', subtotal_cents integer not null default 0, discount_cents integer not null default 0, tax_cents integer not null default 0, total_cents integer not null default 0, paid_cents integer not null default 0, balance_cents integer not null default 0, billing_period_start date, billing_period_end date, due_date date, issued_at timestamptz, paid_at timestamptz, note text, metadata jsonb not null default '{}'::jsonb, created_by uuid references public.platform_users(id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create table if not exists public.tenant_invoice_items ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, invoice_id uuid not null references public.tenant_invoices(id) on delete cascade, item_type text not null default 'subscription', item_ref_id uuid, description text not null, quantity numeric(12,2) not null default 1, unit_amount_cents integer not null default 0, amount_cents integer not null default 0, metadata jsonb not null default '{}'::jsonb, created_at timestamptz not null default now() ); create table if not exists public.tenant_invoice_payments ( id uuid primary key default gen_random_uuid(), tenant_id uuid not null references public.tenants(id) on delete cascade, invoice_id uuid not null references public.tenant_invoices(id) on delete cascade, payment_no text not null unique, provider text not null default 'manual', method text, status text not null default 'paid' check (status in ('pending', 'paid', 'failed', 'refunded')), amount_cents integer not null default 0, paid_at timestamptz, provider_trade_no text, received_by uuid references public.platform_users(id) on delete set null, raw_payload jsonb not null default '{}'::jsonb, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create index if not exists idx_saas_plans_status on public.platform_saas_plans(status, sort_order); create index if not exists idx_tenant_subscriptions_tenant_status on public.tenant_subscriptions(tenant_id, status, expires_at desc); create index if not exists idx_tenant_usage_records_lookup on public.tenant_usage_records(tenant_id, metric_key, period_start, period_end); create index if not exists idx_tenant_invoices_tenant_status on public.tenant_invoices(tenant_id, status, due_date desc); create index if not exists idx_tenant_invoice_items_invoice on public.tenant_invoice_items(invoice_id); create index if not exists idx_tenant_invoice_payments_invoice on public.tenant_invoice_payments(invoice_id, status); alter table public.platform_saas_plans enable row level security; alter table public.tenant_billing_profiles enable row level security; alter table public.tenant_invoices enable row level security; alter table public.tenant_invoice_items enable row level security; alter table public.tenant_invoice_payments enable row level security; drop policy if exists platform_admin_saas_plans on public.platform_saas_plans; create policy platform_admin_saas_plans on public.platform_saas_plans for all using (app.is_platform_admin()) with check (app.is_platform_admin()); do $$ declare table_name text; begin foreach table_name in array array[ 'tenant_billing_profiles', 'tenant_invoices', 'tenant_invoice_items', 'tenant_invoice_payments' ] loop execute format('drop policy if exists tenant_isolation on public.%I', table_name); execute format( 'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())', table_name ); end loop; end $$; do $$ declare table_name text; begin foreach table_name in array array[ 'platform_saas_plans', 'tenant_billing_profiles', 'tenant_invoices', 'tenant_invoice_payments' ] loop execute format('drop trigger if exists set_updated_at on public.%I', table_name); execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name); end loop; end $$; insert into public.platform_saas_plans ( code, name, description, billing_cycle, base_amount_cents, included_quotas, overage_prices, feature_flags, sort_order ) values ( 'starter_yearly', '合作商基础版', '适合单地区题库合作商,含基础品牌与域名能力。', 'yearly', 980000, '{"students":1000,"questions":5000,"storageGb":20}'::jsonb, '{"studentsExtraPerYearCents":500,"storageGbPerYearCents":12000}'::jsonb, '{"customDomain":true,"tenantBranding":true,"paymentTenantCollect":false}'::jsonb, 10 ), ( 'pro_yearly', '合作商专业版', '适合多地区、多课程运营,支持更多运营工具。', 'yearly', 1980000, '{"students":5000,"questions":30000,"storageGb":100}'::jsonb, '{"studentsExtraPerYearCents":300,"storageGbPerYearCents":9000}'::jsonb, '{"customDomain":true,"tenantBranding":true,"paymentTenantCollect":true,"crmWebhook":true}'::jsonb, 20 ) on conflict (code) do update set name = excluded.name, description = excluded.description, billing_cycle = excluded.billing_cycle, base_amount_cents = excluded.base_amount_cents, included_quotas = excluded.included_quotas, overage_prices = excluded.overage_prices, feature_flags = excluded.feature_flags, sort_order = excluded.sort_order, updated_at = now();