Files
zclaw_openfang/crates/zclaw-saas/migrations/20260402000001_billing_tables.sql
iven 9487cd7f72 feat(saas): add billing infrastructure — tables, types, service, handlers
B1.1 Billing database:
- 5 tables: billing_plans, billing_subscriptions, billing_invoices,
  billing_payments, billing_usage_quotas
- Seed data: Free(¥0)/Pro(¥49)/Team(¥199) plans
- JSONB limits for flexible plan configuration

Billing module (crates/zclaw-saas/src/billing/):
- types.rs: BillingPlan, Subscription, Invoice, Payment, UsageQuota
- service.rs: plan CRUD, subscription lookup, usage tracking, quota check
- handlers.rs: REST API (plans list/detail, subscription, usage)
- mod.rs: routes registered at /api/v1/billing/*

Cargo.toml: added chrono feature to sqlx for DateTime<Utc> support
2026-04-01 23:59:46 +08:00

134 lines
6.3 KiB
SQL

-- Migration: Billing tables for subscription management
-- Supports: Free/Pro/Team plans, Alipay + WeChat Pay, usage quotas
-- Plan definitions (Free/Pro/Team)
CREATE TABLE IF NOT EXISTS billing_plans (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
description TEXT,
price_cents INTEGER NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'CNY',
interval TEXT NOT NULL DEFAULT 'month',
features JSONB NOT NULL DEFAULT '{}',
limits JSONB NOT NULL DEFAULT '{}',
is_default BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_billing_plans_status ON billing_plans(status);
-- Account subscriptions
CREATE TABLE IF NOT EXISTS billing_subscriptions (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
plan_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
current_period_start TIMESTAMPTZ NOT NULL DEFAULT NOW(),
current_period_end TIMESTAMPTZ NOT NULL,
trial_end TIMESTAMPTZ,
canceled_at TIMESTAMPTZ,
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY (plan_id) REFERENCES billing_plans(id)
);
CREATE INDEX IF NOT EXISTS idx_billing_sub_account ON billing_subscriptions(account_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_billing_sub_active
ON billing_subscriptions(account_id)
WHERE status IN ('trial', 'active', 'past_due');
-- Invoices
CREATE TABLE IF NOT EXISTS billing_invoices (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
subscription_id TEXT,
plan_id TEXT,
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
description TEXT,
status TEXT NOT NULL DEFAULT 'pending',
due_at TIMESTAMPTZ,
paid_at TIMESTAMPTZ,
voided_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
FOREIGN KEY (subscription_id) REFERENCES billing_subscriptions(id) ON DELETE SET NULL,
FOREIGN KEY (plan_id) REFERENCES billing_plans(id)
);
CREATE INDEX IF NOT EXISTS idx_billing_inv_account ON billing_invoices(account_id);
CREATE INDEX IF NOT EXISTS idx_billing_inv_status ON billing_invoices(status);
CREATE INDEX IF NOT EXISTS idx_billing_inv_time ON billing_invoices(created_at);
-- Payment records (Alipay / WeChat Pay)
CREATE TABLE IF NOT EXISTS billing_payments (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
account_id TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
method TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
external_trade_no TEXT,
paid_at TIMESTAMPTZ,
refunded_at TIMESTAMPTZ,
failure_reason TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
FOREIGN KEY (invoice_id) REFERENCES billing_invoices(id) ON DELETE CASCADE,
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_billing_pay_invoice ON billing_payments(invoice_id);
CREATE INDEX IF NOT EXISTS idx_billing_pay_account ON billing_payments(account_id);
CREATE INDEX IF NOT EXISTS idx_billing_pay_trade_no ON billing_payments(external_trade_no);
CREATE INDEX IF NOT EXISTS idx_billing_pay_status ON billing_payments(status);
-- Monthly usage quotas (per account per billing period)
CREATE TABLE IF NOT EXISTS billing_usage_quotas (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
input_tokens BIGINT NOT NULL DEFAULT 0,
output_tokens BIGINT NOT NULL DEFAULT 0,
relay_requests INTEGER NOT NULL DEFAULT 0,
hand_executions INTEGER NOT NULL DEFAULT 0,
pipeline_runs INTEGER NOT NULL DEFAULT 0,
max_input_tokens BIGINT,
max_output_tokens BIGINT,
max_relay_requests INTEGER,
max_hand_executions INTEGER,
max_pipeline_runs INTEGER,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
UNIQUE(account_id, period_start)
);
CREATE INDEX IF NOT EXISTS idx_billing_usage_account ON billing_usage_quotas(account_id);
CREATE INDEX IF NOT EXISTS idx_billing_usage_period ON billing_usage_quotas(period_start, period_end);
-- Seed: default plans
INSERT INTO billing_plans (id, name, display_name, description, price_cents, interval, features, limits, is_default, sort_order)
VALUES
('plan-free', 'free', '免费版', '基础功能,适合个人体验', 0, 'month',
'{"hands": ["browser", "collector", "researcher"], "chat_modes": ["flash", "thinking"], "pipelines": 3, "support": "community"}'::jsonb,
'{"max_input_tokens_monthly": 500000, "max_output_tokens_monthly": 500000, "max_relay_requests_monthly": 100, "max_hand_executions_monthly": 20, "max_pipeline_runs_monthly": 5}'::jsonb,
TRUE, 0),
('plan-pro', 'pro', '专业版', '全功能解锁,适合知识工作者', 4900, 'month',
'{"hands": "all", "chat_modes": "all", "pipelines": -1, "support": "priority", "memory": true, "export": true}'::jsonb,
'{"max_input_tokens_monthly": 5000000, "max_output_tokens_monthly": 5000000, "max_relay_requests_monthly": 2000, "max_hand_executions_monthly": 200, "max_pipeline_runs_monthly": 100}'::jsonb,
FALSE, 1),
('plan-team', 'team', '团队版', '多席位协作,适合企业团队', 19900, 'month',
'{"hands": "all", "chat_modes": "all", "pipelines": -1, "support": "dedicated", "memory": true, "export": true, "sharing": true, "admin": true}'::jsonb,
'{"max_input_tokens_monthly": 50000000, "max_output_tokens_monthly": 50000000, "max_relay_requests_monthly": 20000, "max_hand_executions_monthly": 1000, "max_pipeline_runs_monthly": 500}'::jsonb,
FALSE, 2)
ON CONFLICT (name) DO NOTHING;