1925 lines
69 KiB
Python
1925 lines
69 KiB
Python
"""
|
||
InsightFlow Phase 8 - 订阅与计费系统模块
|
||
|
||
功能:
|
||
1. 多层级订阅计划(Free/Pro/Enterprise)
|
||
2. 按量计费(转录时长、存储空间、API 调用次数)
|
||
3. 支付集成(Stripe、支付宝、微信支付)
|
||
4. 发票管理、退款处理、账单历史
|
||
|
||
作者: InsightFlow Team
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import sqlite3
|
||
import uuid
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta
|
||
from enum import StrEnum
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class SubscriptionStatus(StrEnum):
|
||
"""订阅状态"""
|
||
ACTIVE = "active" # 活跃
|
||
CANCELLED = "cancelled" # 已取消
|
||
EXPIRED = "expired" # 已过期
|
||
PAST_DUE = "past_due" # 逾期
|
||
TRIAL = "trial" # 试用中
|
||
PENDING = "pending" # 待支付
|
||
|
||
|
||
class PaymentProvider(StrEnum):
|
||
"""支付提供商"""
|
||
STRIPE = "stripe" # Stripe
|
||
ALIPAY = "alipay" # 支付宝
|
||
WECHAT = "wechat" # 微信支付
|
||
BANK_TRANSFER = "bank_transfer" # 银行转账
|
||
|
||
|
||
class PaymentStatus(StrEnum):
|
||
"""支付状态"""
|
||
PENDING = "pending" # 待支付
|
||
PROCESSING = "processing" # 处理中
|
||
COMPLETED = "completed" # 已完成
|
||
FAILED = "failed" # 失败
|
||
REFUNDED = "refunded" # 已退款
|
||
PARTIAL_REFUNDED = "partial_refunded" # 部分退款
|
||
|
||
|
||
class InvoiceStatus(StrEnum):
|
||
"""发票状态"""
|
||
DRAFT = "draft" # 草稿
|
||
ISSUED = "issued" # 已开具
|
||
PAID = "paid" # 已支付
|
||
OVERDUE = "overdue" # 逾期
|
||
VOID = "void" # 作废
|
||
CREDIT_NOTE = "credit_note" # 贷项通知单
|
||
|
||
|
||
class RefundStatus(StrEnum):
|
||
"""退款状态"""
|
||
PENDING = "pending" # 待处理
|
||
APPROVED = "approved" # 已批准
|
||
REJECTED = "rejected" # 已拒绝
|
||
COMPLETED = "completed" # 已完成
|
||
FAILED = "failed" # 失败
|
||
|
||
|
||
@dataclass
|
||
class SubscriptionPlan:
|
||
"""订阅计划数据类"""
|
||
id: str
|
||
name: str
|
||
tier: str # free/pro/enterprise
|
||
description: str
|
||
price_monthly: float # 月付价格
|
||
price_yearly: float # 年付价格
|
||
currency: str # CNY/USD
|
||
features: list[str] # 功能列表
|
||
limits: dict[str, Any] # 资源限制
|
||
is_active: bool
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
metadata: dict[str, Any]
|
||
|
||
|
||
@dataclass
|
||
class Subscription:
|
||
"""订阅数据类"""
|
||
id: str
|
||
tenant_id: str
|
||
plan_id: str
|
||
status: str
|
||
current_period_start: datetime
|
||
current_period_end: datetime
|
||
cancel_at_period_end: bool
|
||
canceled_at: datetime | None
|
||
trial_start: datetime | None
|
||
trial_end: datetime | None
|
||
payment_provider: str | None
|
||
provider_subscription_id: str | None # 支付提供商的订阅ID
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
metadata: dict[str, Any]
|
||
|
||
|
||
@dataclass
|
||
class UsageRecord:
|
||
"""用量记录数据类"""
|
||
id: str
|
||
tenant_id: str
|
||
resource_type: str # transcription/storage/api_call
|
||
quantity: float # 使用量
|
||
unit: str # minutes/mb/count
|
||
recorded_at: datetime
|
||
cost: float # 费用
|
||
description: str | None
|
||
metadata: dict[str, Any]
|
||
|
||
|
||
@dataclass
|
||
class Payment:
|
||
"""支付记录数据类"""
|
||
id: str
|
||
tenant_id: str
|
||
subscription_id: str | None
|
||
invoice_id: str | None
|
||
amount: float
|
||
currency: str
|
||
provider: str
|
||
provider_payment_id: str | None
|
||
status: str
|
||
payment_method: str | None
|
||
payment_details: dict[str, Any]
|
||
paid_at: datetime | None
|
||
failed_at: datetime | None
|
||
failure_reason: str | None
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
|
||
@dataclass
|
||
class Invoice:
|
||
"""发票数据类"""
|
||
id: str
|
||
tenant_id: str
|
||
subscription_id: str | None
|
||
invoice_number: str
|
||
status: str
|
||
amount_due: float
|
||
amount_paid: float
|
||
currency: str
|
||
period_start: datetime
|
||
period_end: datetime
|
||
description: str
|
||
line_items: list[dict[str, Any]]
|
||
due_date: datetime
|
||
paid_at: datetime | None
|
||
voided_at: datetime | None
|
||
void_reason: str | None
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
|
||
@dataclass
|
||
class Refund:
|
||
"""退款数据类"""
|
||
id: str
|
||
tenant_id: str
|
||
payment_id: str
|
||
invoice_id: str | None
|
||
amount: float
|
||
currency: str
|
||
reason: str
|
||
status: str
|
||
requested_by: str
|
||
requested_at: datetime
|
||
approved_by: str | None
|
||
approved_at: str | None
|
||
completed_at: datetime | None
|
||
provider_refund_id: str | None
|
||
metadata: dict[str, Any]
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
|
||
@dataclass
|
||
class BillingHistory:
|
||
"""账单历史数据类"""
|
||
id: str
|
||
tenant_id: str
|
||
type: str # subscription/usage/payment/refund
|
||
amount: float
|
||
currency: str
|
||
description: str
|
||
reference_id: str # 关联的订阅/支付/退款ID
|
||
balance_after: float # 操作后余额
|
||
created_at: datetime
|
||
metadata: dict[str, Any]
|
||
|
||
|
||
class SubscriptionManager:
|
||
"""订阅与计费管理器"""
|
||
|
||
# 默认订阅计划配置
|
||
DEFAULT_PLANS = {
|
||
"free": {
|
||
"name": "Free",
|
||
"tier": "free",
|
||
"description": "免费版,适合个人用户试用",
|
||
"price_monthly": 0.0,
|
||
"price_yearly": 0.0,
|
||
"currency": "CNY",
|
||
"features": [
|
||
"basic_analysis",
|
||
"export_png",
|
||
"3_projects",
|
||
"100_mb_storage",
|
||
"60_min_transcription"
|
||
],
|
||
"limits": {
|
||
"max_projects": 3,
|
||
"max_storage_mb": 100,
|
||
"max_transcription_minutes": 60,
|
||
"max_api_calls_per_day": 100,
|
||
"max_team_members": 2,
|
||
"max_entities": 100
|
||
}
|
||
},
|
||
"pro": {
|
||
"name": "Pro",
|
||
"tier": "pro",
|
||
"description": "专业版,适合小型团队",
|
||
"price_monthly": 99.0,
|
||
"price_yearly": 990.0,
|
||
"currency": "CNY",
|
||
"features": [
|
||
"all_free_features",
|
||
"advanced_analysis",
|
||
"export_all_formats",
|
||
"api_access",
|
||
"webhooks",
|
||
"collaboration",
|
||
"20_projects",
|
||
"10_gb_storage",
|
||
"600_min_transcription"
|
||
],
|
||
"limits": {
|
||
"max_projects": 20,
|
||
"max_storage_mb": 10240,
|
||
"max_transcription_minutes": 600,
|
||
"max_api_calls_per_day": 10000,
|
||
"max_team_members": 10,
|
||
"max_entities": 1000
|
||
}
|
||
},
|
||
"enterprise": {
|
||
"name": "Enterprise",
|
||
"tier": "enterprise",
|
||
"description": "企业版,适合大型企业",
|
||
"price_monthly": 999.0,
|
||
"price_yearly": 9990.0,
|
||
"currency": "CNY",
|
||
"features": [
|
||
"all_pro_features",
|
||
"unlimited_projects",
|
||
"unlimited_storage",
|
||
"unlimited_transcription",
|
||
"priority_support",
|
||
"custom_integration",
|
||
"sla_guarantee",
|
||
"dedicated_manager"
|
||
],
|
||
"limits": {
|
||
"max_projects": -1,
|
||
"max_storage_mb": -1,
|
||
"max_transcription_minutes": -1,
|
||
"max_api_calls_per_day": -1,
|
||
"max_team_members": -1,
|
||
"max_entities": -1
|
||
}
|
||
}
|
||
}
|
||
|
||
# 按量计费单价(CNY)
|
||
USAGE_PRICING = {
|
||
"transcription": {
|
||
"unit": "minute",
|
||
"price": 0.5, # 0.5元/分钟
|
||
"free_quota": 60 # 每月免费额度
|
||
},
|
||
"storage": {
|
||
"unit": "gb",
|
||
"price": 10.0, # 10元/GB/月
|
||
"free_quota": 0.1 # 100MB免费
|
||
},
|
||
"api_call": {
|
||
"unit": "1000_calls",
|
||
"price": 5.0, # 5元/1000次
|
||
"free_quota": 1000 # 每月免费1000次
|
||
},
|
||
"export": {
|
||
"unit": "page",
|
||
"price": 0.1, # 0.1元/页(PDF导出)
|
||
"free_quota": 100
|
||
}
|
||
}
|
||
|
||
def __init__(self, db_path: str = "insightflow.db"):
|
||
self.db_path = db_path
|
||
self._init_db()
|
||
self._init_default_plans()
|
||
|
||
def _get_connection(self) -> sqlite3.Connection:
|
||
"""获取数据库连接"""
|
||
conn = sqlite3.connect(self.db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
return conn
|
||
|
||
def _init_db(self):
|
||
"""初始化数据库表"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
|
||
# 订阅计划表
|
||
cursor.execute("""
|
||
CREATE TABLE IF NOT EXISTS subscription_plans (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
tier TEXT UNIQUE NOT NULL,
|
||
description TEXT,
|
||
price_monthly REAL DEFAULT 0,
|
||
price_yearly REAL DEFAULT 0,
|
||
currency TEXT DEFAULT 'CNY',
|
||
features TEXT DEFAULT '[]',
|
||
limits TEXT DEFAULT '{}',
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
metadata TEXT DEFAULT '{}'
|
||
)
|
||
""")
|
||
|
||
# 订阅表
|
||
cursor.execute("""
|
||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL,
|
||
plan_id TEXT NOT NULL,
|
||
status TEXT DEFAULT 'pending',
|
||
current_period_start TIMESTAMP,
|
||
current_period_end TIMESTAMP,
|
||
cancel_at_period_end INTEGER DEFAULT 0,
|
||
canceled_at TIMESTAMP,
|
||
trial_start TIMESTAMP,
|
||
trial_end TIMESTAMP,
|
||
payment_provider TEXT,
|
||
provider_subscription_id TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
metadata TEXT DEFAULT '{}',
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (plan_id) REFERENCES subscription_plans(id)
|
||
)
|
||
""")
|
||
|
||
# 用量记录表
|
||
cursor.execute("""
|
||
CREATE TABLE IF NOT EXISTS usage_records (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL,
|
||
resource_type TEXT NOT NULL,
|
||
quantity REAL DEFAULT 0,
|
||
unit TEXT NOT NULL,
|
||
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
cost REAL DEFAULT 0,
|
||
description TEXT,
|
||
metadata TEXT DEFAULT '{}',
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||
)
|
||
""")
|
||
|
||
# 支付记录表
|
||
cursor.execute("""
|
||
CREATE TABLE IF NOT EXISTS payments (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL,
|
||
subscription_id TEXT,
|
||
invoice_id TEXT,
|
||
amount REAL NOT NULL,
|
||
currency TEXT DEFAULT 'CNY',
|
||
provider TEXT NOT NULL,
|
||
provider_payment_id TEXT,
|
||
status TEXT DEFAULT 'pending',
|
||
payment_method TEXT,
|
||
payment_details TEXT DEFAULT '{}',
|
||
paid_at TIMESTAMP,
|
||
failed_at TIMESTAMP,
|
||
failure_reason TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL,
|
||
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE SET NULL
|
||
)
|
||
""")
|
||
|
||
# 发票表
|
||
cursor.execute("""
|
||
CREATE TABLE IF NOT EXISTS invoices (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL,
|
||
subscription_id TEXT,
|
||
invoice_number TEXT UNIQUE NOT NULL,
|
||
status TEXT DEFAULT 'draft',
|
||
amount_due REAL DEFAULT 0,
|
||
amount_paid REAL DEFAULT 0,
|
||
currency TEXT DEFAULT 'CNY',
|
||
period_start TIMESTAMP,
|
||
period_end TIMESTAMP,
|
||
description TEXT,
|
||
line_items TEXT DEFAULT '[]',
|
||
due_date TIMESTAMP,
|
||
paid_at TIMESTAMP,
|
||
voided_at TIMESTAMP,
|
||
void_reason TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL
|
||
)
|
||
""")
|
||
|
||
# 退款表
|
||
cursor.execute("""
|
||
CREATE TABLE IF NOT EXISTS refunds (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL,
|
||
payment_id TEXT NOT NULL,
|
||
invoice_id TEXT,
|
||
amount REAL NOT NULL,
|
||
currency TEXT DEFAULT 'CNY',
|
||
reason TEXT,
|
||
status TEXT DEFAULT 'pending',
|
||
requested_by TEXT NOT NULL,
|
||
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
approved_by TEXT,
|
||
approved_at TIMESTAMP,
|
||
completed_at TIMESTAMP,
|
||
provider_refund_id TEXT,
|
||
metadata TEXT DEFAULT '{}',
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE SET NULL
|
||
)
|
||
""")
|
||
|
||
# 账单历史表
|
||
cursor.execute("""
|
||
CREATE TABLE IF NOT EXISTS billing_history (
|
||
id TEXT PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL,
|
||
type TEXT NOT NULL,
|
||
amount REAL NOT NULL,
|
||
currency TEXT DEFAULT 'CNY',
|
||
description TEXT,
|
||
reference_id TEXT,
|
||
balance_after REAL DEFAULT 0,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
metadata TEXT DEFAULT '{}',
|
||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||
)
|
||
""")
|
||
|
||
# 创建索引
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscriptions_tenant ON subscriptions(tenant_id)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_subscriptions_plan ON subscriptions(plan_id)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_usage_tenant ON usage_records(tenant_id)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_usage_type ON usage_records(resource_type)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_usage_recorded ON usage_records(recorded_at)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_payments_tenant ON payments(tenant_id)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_payments_status ON payments(status)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_invoices_tenant ON invoices(tenant_id)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_invoices_number ON invoices(invoice_number)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_refunds_tenant ON refunds(tenant_id)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_refunds_status ON refunds(status)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_billing_tenant ON billing_history(tenant_id)")
|
||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_billing_created ON billing_history(created_at)")
|
||
|
||
conn.commit()
|
||
logger.info("Subscription tables initialized successfully")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error initializing subscription tables: {e}")
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
|
||
def _init_default_plans(self):
|
||
"""初始化默认订阅计划"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
|
||
for tier, plan_data in self.DEFAULT_PLANS.items():
|
||
cursor.execute("""
|
||
INSERT OR IGNORE INTO subscription_plans
|
||
(id, name, tier, description, price_monthly, price_yearly, currency,
|
||
features, limits, is_active, created_at, updated_at, metadata)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
str(uuid.uuid4()),
|
||
plan_data["name"],
|
||
plan_data["tier"],
|
||
plan_data["description"],
|
||
plan_data["price_monthly"],
|
||
plan_data["price_yearly"],
|
||
plan_data["currency"],
|
||
json.dumps(plan_data["features"]),
|
||
json.dumps(plan_data["limits"]),
|
||
1,
|
||
datetime.now(),
|
||
datetime.now(),
|
||
json.dumps({})
|
||
))
|
||
|
||
conn.commit()
|
||
logger.info("Default subscription plans initialized")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error initializing default plans: {e}")
|
||
finally:
|
||
conn.close()
|
||
|
||
# ==================== 订阅计划管理 ====================
|
||
|
||
def get_plan(self, plan_id: str) -> SubscriptionPlan | None:
|
||
"""获取订阅计划"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT * FROM subscription_plans WHERE id = ?", (plan_id,))
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
return self._row_to_plan(row)
|
||
return None
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_plan_by_tier(self, tier: str) -> SubscriptionPlan | None:
|
||
"""通过层级获取订阅计划"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT * FROM subscription_plans WHERE tier = ? AND is_active = 1", (tier,))
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
return self._row_to_plan(row)
|
||
return None
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def list_plans(self, include_inactive: bool = False) -> list[SubscriptionPlan]:
|
||
"""列出所有订阅计划"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
|
||
if include_inactive:
|
||
cursor.execute("SELECT * FROM subscription_plans ORDER BY price_monthly")
|
||
else:
|
||
cursor.execute("SELECT * FROM subscription_plans WHERE is_active = 1 ORDER BY price_monthly")
|
||
|
||
rows = cursor.fetchall()
|
||
return [self._row_to_plan(row) for row in rows]
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def create_plan(self, name: str, tier: str, description: str,
|
||
price_monthly: float, price_yearly: float,
|
||
currency: str = "CNY", features: list[str] = None,
|
||
limits: dict[str, Any] = None) -> SubscriptionPlan:
|
||
"""创建新订阅计划"""
|
||
conn = self._get_connection()
|
||
try:
|
||
plan_id = str(uuid.uuid4())
|
||
|
||
plan = SubscriptionPlan(
|
||
id=plan_id,
|
||
name=name,
|
||
tier=tier,
|
||
description=description,
|
||
price_monthly=price_monthly,
|
||
price_yearly=price_yearly,
|
||
currency=currency,
|
||
features=features or [],
|
||
limits=limits or {},
|
||
is_active=True,
|
||
created_at=datetime.now(),
|
||
updated_at=datetime.now(),
|
||
metadata={}
|
||
)
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
INSERT INTO subscription_plans
|
||
(id, name, tier, description, price_monthly, price_yearly, currency,
|
||
features, limits, is_active, created_at, updated_at, metadata)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
plan.id, plan.name, plan.tier, plan.description,
|
||
plan.price_monthly, plan.price_yearly, plan.currency,
|
||
json.dumps(plan.features), json.dumps(plan.limits),
|
||
int(plan.is_active), plan.created_at, plan.updated_at,
|
||
json.dumps(plan.metadata)
|
||
))
|
||
|
||
conn.commit()
|
||
logger.info(f"Subscription plan created: {plan_id} ({name})")
|
||
return plan
|
||
|
||
except Exception as e:
|
||
conn.rollback()
|
||
logger.error(f"Error creating plan: {e}")
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
|
||
def update_plan(self, plan_id: str, **kwargs) -> SubscriptionPlan | None:
|
||
"""更新订阅计划"""
|
||
conn = self._get_connection()
|
||
try:
|
||
plan = self.get_plan(plan_id)
|
||
if not plan:
|
||
return None
|
||
|
||
updates = []
|
||
params = []
|
||
|
||
allowed_fields = ['name', 'description', 'price_monthly', 'price_yearly',
|
||
'currency', 'features', 'limits', 'is_active']
|
||
|
||
for key, value in kwargs.items():
|
||
if key in allowed_fields:
|
||
updates.append(f"{key} = ?")
|
||
if key in ['features', 'limits']:
|
||
params.append(json.dumps(value) if value else '{}')
|
||
elif key == 'is_active':
|
||
params.append(int(value))
|
||
else:
|
||
params.append(value)
|
||
|
||
if not updates:
|
||
return plan
|
||
|
||
updates.append("updated_at = ?")
|
||
params.append(datetime.now())
|
||
params.append(plan_id)
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute(f"""
|
||
UPDATE subscription_plans SET {', '.join(updates)}
|
||
WHERE id = ?
|
||
""", params)
|
||
|
||
conn.commit()
|
||
return self.get_plan(plan_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
# ==================== 订阅管理 ====================
|
||
|
||
def create_subscription(self, tenant_id: str, plan_id: str,
|
||
payment_provider: str | None = None,
|
||
trial_days: int = 0,
|
||
billing_cycle: str = "monthly") -> Subscription:
|
||
"""创建新订阅"""
|
||
conn = self._get_connection()
|
||
try:
|
||
# 检查是否已有活跃订阅
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT * FROM subscriptions
|
||
WHERE tenant_id = ? AND status IN ('active', 'trial', 'pending')
|
||
""", (tenant_id,))
|
||
|
||
existing = cursor.fetchone()
|
||
if existing:
|
||
raise ValueError(f"Tenant {tenant_id} already has an active subscription")
|
||
|
||
# 获取计划信息
|
||
plan = self.get_plan(plan_id)
|
||
if not plan:
|
||
raise ValueError(f"Plan {plan_id} not found")
|
||
|
||
subscription_id = str(uuid.uuid4())
|
||
now = datetime.now()
|
||
|
||
# 计算周期
|
||
if billing_cycle == "yearly":
|
||
period_end = now + timedelta(days=365)
|
||
else:
|
||
period_end = now + timedelta(days=30)
|
||
|
||
# 试用处理
|
||
trial_start = None
|
||
trial_end = None
|
||
if trial_days > 0:
|
||
trial_start = now
|
||
trial_end = now + timedelta(days=trial_days)
|
||
status = SubscriptionStatus.TRIAL.value
|
||
else:
|
||
status = SubscriptionStatus.PENDING.value
|
||
|
||
subscription = Subscription(
|
||
id=subscription_id,
|
||
tenant_id=tenant_id,
|
||
plan_id=plan_id,
|
||
status=status,
|
||
current_period_start=now,
|
||
current_period_end=period_end,
|
||
cancel_at_period_end=False,
|
||
canceled_at=None,
|
||
trial_start=trial_start,
|
||
trial_end=trial_end,
|
||
payment_provider=payment_provider,
|
||
provider_subscription_id=None,
|
||
created_at=now,
|
||
updated_at=now,
|
||
metadata={"billing_cycle": billing_cycle}
|
||
)
|
||
|
||
cursor.execute("""
|
||
INSERT INTO subscriptions
|
||
(id, tenant_id, plan_id, status, current_period_start, current_period_end,
|
||
cancel_at_period_end, canceled_at, trial_start, trial_end,
|
||
payment_provider, provider_subscription_id, created_at, updated_at, metadata)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
subscription.id, subscription.tenant_id, subscription.plan_id,
|
||
subscription.status, subscription.current_period_start,
|
||
subscription.current_period_end, int(subscription.cancel_at_period_end),
|
||
subscription.canceled_at, subscription.trial_start, subscription.trial_end,
|
||
subscription.payment_provider, subscription.provider_subscription_id,
|
||
subscription.created_at, subscription.updated_at,
|
||
json.dumps(subscription.metadata)
|
||
))
|
||
|
||
# 创建发票
|
||
amount = plan.price_yearly if billing_cycle == "yearly" else plan.price_monthly
|
||
if amount > 0 and trial_days == 0:
|
||
self._create_invoice_internal(
|
||
conn, tenant_id, subscription_id, amount, plan.currency,
|
||
now, period_end, f"{plan.name} Subscription ({billing_cycle})"
|
||
)
|
||
|
||
# 记录账单历史
|
||
self._add_billing_history_internal(
|
||
conn, tenant_id, "subscription", 0, plan.currency,
|
||
f"Subscription created: {plan.name}", subscription_id, 0
|
||
)
|
||
|
||
conn.commit()
|
||
logger.info(f"Subscription created: {subscription_id} for tenant {tenant_id}")
|
||
return subscription
|
||
|
||
except Exception as e:
|
||
conn.rollback()
|
||
logger.error(f"Error creating subscription: {e}")
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_subscription(self, subscription_id: str) -> Subscription | None:
|
||
"""获取订阅信息"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT * FROM subscriptions WHERE id = ?", (subscription_id,))
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
return self._row_to_subscription(row)
|
||
return None
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_tenant_subscription(self, tenant_id: str) -> Subscription | None:
|
||
"""获取租户的当前订阅"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT * FROM subscriptions
|
||
WHERE tenant_id = ? AND status IN ('active', 'trial', 'past_due', 'pending')
|
||
ORDER BY created_at DESC LIMIT 1
|
||
""", (tenant_id,))
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
return self._row_to_subscription(row)
|
||
return None
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def update_subscription(self, subscription_id: str, **kwargs) -> Subscription | None:
|
||
"""更新订阅"""
|
||
conn = self._get_connection()
|
||
try:
|
||
subscription = self.get_subscription(subscription_id)
|
||
if not subscription:
|
||
return None
|
||
|
||
updates = []
|
||
params = []
|
||
|
||
allowed_fields = ['status', 'current_period_start', 'current_period_end',
|
||
'cancel_at_period_end', 'canceled_at', 'trial_end',
|
||
'payment_provider', 'provider_subscription_id']
|
||
|
||
for key, value in kwargs.items():
|
||
if key in allowed_fields:
|
||
updates.append(f"{key} = ?")
|
||
if key == 'cancel_at_period_end':
|
||
params.append(int(value))
|
||
else:
|
||
params.append(value)
|
||
|
||
if not updates:
|
||
return subscription
|
||
|
||
updates.append("updated_at = ?")
|
||
params.append(datetime.now())
|
||
params.append(subscription_id)
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute(f"""
|
||
UPDATE subscriptions SET {', '.join(updates)}
|
||
WHERE id = ?
|
||
""", params)
|
||
|
||
conn.commit()
|
||
return self.get_subscription(subscription_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def cancel_subscription(self, subscription_id: str,
|
||
at_period_end: bool = True) -> Subscription | None:
|
||
"""取消订阅"""
|
||
conn = self._get_connection()
|
||
try:
|
||
subscription = self.get_subscription(subscription_id)
|
||
if not subscription:
|
||
return None
|
||
|
||
now = datetime.now()
|
||
|
||
if at_period_end:
|
||
# 在周期结束时取消
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE subscriptions
|
||
SET cancel_at_period_end = 1, canceled_at = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""", (now, now, subscription_id))
|
||
else:
|
||
# 立即取消
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE subscriptions
|
||
SET status = 'cancelled', canceled_at = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""", (now, now, subscription_id))
|
||
|
||
# 记录账单历史
|
||
self._add_billing_history_internal(
|
||
conn, subscription.tenant_id, "subscription", 0, "CNY",
|
||
f"Subscription cancelled{' (at period end)' if at_period_end else ''}",
|
||
subscription_id, 0
|
||
)
|
||
|
||
conn.commit()
|
||
logger.info(f"Subscription cancelled: {subscription_id}")
|
||
return self.get_subscription(subscription_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def change_plan(self, subscription_id: str, new_plan_id: str,
|
||
prorate: bool = True) -> Subscription | None:
|
||
"""更改订阅计划"""
|
||
conn = self._get_connection()
|
||
try:
|
||
subscription = self.get_subscription(subscription_id)
|
||
if not subscription:
|
||
return None
|
||
|
||
old_plan = self.get_plan(subscription.plan_id)
|
||
new_plan = self.get_plan(new_plan_id)
|
||
|
||
if not new_plan:
|
||
raise ValueError(f"Plan {new_plan_id} not found")
|
||
|
||
now = datetime.now()
|
||
|
||
# 按比例计算差价(简化实现)
|
||
if prorate and old_plan:
|
||
# 这里应该实现实际的按比例计算逻辑
|
||
pass
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE subscriptions
|
||
SET plan_id = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""", (new_plan_id, now, subscription_id))
|
||
|
||
# 记录账单历史
|
||
self._add_billing_history_internal(
|
||
conn, subscription.tenant_id, "subscription", 0, new_plan.currency,
|
||
f"Plan changed from {old_plan.name if old_plan else 'unknown'} to {new_plan.name}",
|
||
subscription_id, 0
|
||
)
|
||
|
||
conn.commit()
|
||
logger.info(f"Subscription plan changed: {subscription_id} -> {new_plan_id}")
|
||
return self.get_subscription(subscription_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
# ==================== 用量计费 ====================
|
||
|
||
def record_usage(self, tenant_id: str, resource_type: str,
|
||
quantity: float, unit: str,
|
||
description: str | None = None,
|
||
metadata: dict | None = None) -> UsageRecord:
|
||
"""记录用量"""
|
||
conn = self._get_connection()
|
||
try:
|
||
# 计算费用
|
||
cost = self._calculate_usage_cost(resource_type, quantity)
|
||
|
||
record_id = str(uuid.uuid4())
|
||
record = UsageRecord(
|
||
id=record_id,
|
||
tenant_id=tenant_id,
|
||
resource_type=resource_type,
|
||
quantity=quantity,
|
||
unit=unit,
|
||
recorded_at=datetime.now(),
|
||
cost=cost,
|
||
description=description,
|
||
metadata=metadata or {}
|
||
)
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
INSERT INTO usage_records
|
||
(id, tenant_id, resource_type, quantity, unit, recorded_at, cost, description, metadata)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
record.id, record.tenant_id, record.resource_type,
|
||
record.quantity, record.unit, record.recorded_at,
|
||
record.cost, record.description, json.dumps(record.metadata)
|
||
))
|
||
|
||
conn.commit()
|
||
return record
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_usage_summary(self, tenant_id: str,
|
||
start_date: datetime | None = None,
|
||
end_date: datetime | None = None) -> dict[str, Any]:
|
||
"""获取用量汇总"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
|
||
query = """
|
||
SELECT
|
||
resource_type,
|
||
SUM(quantity) as total_quantity,
|
||
SUM(cost) as total_cost,
|
||
COUNT(*) as record_count
|
||
FROM usage_records
|
||
WHERE tenant_id = ?
|
||
"""
|
||
params = [tenant_id]
|
||
|
||
if start_date:
|
||
query += " AND recorded_at >= ?"
|
||
params.append(start_date)
|
||
if end_date:
|
||
query += " AND recorded_at <= ?"
|
||
params.append(end_date)
|
||
|
||
query += " GROUP BY resource_type"
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
summary = {}
|
||
total_cost = 0
|
||
|
||
for row in rows:
|
||
summary[row['resource_type']] = {
|
||
"quantity": row['total_quantity'],
|
||
"cost": row['total_cost'],
|
||
"records": row['record_count']
|
||
}
|
||
total_cost += row['total_cost']
|
||
|
||
return {
|
||
"tenant_id": tenant_id,
|
||
"period": {
|
||
"start": start_date.isoformat() if start_date else None,
|
||
"end": end_date.isoformat() if end_date else None
|
||
},
|
||
"breakdown": summary,
|
||
"total_cost": total_cost
|
||
}
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def _calculate_usage_cost(self, resource_type: str, quantity: float) -> float:
|
||
"""计算用量费用"""
|
||
pricing = self.USAGE_PRICING.get(resource_type)
|
||
if not pricing:
|
||
return 0.0
|
||
|
||
# 扣除免费额度
|
||
chargeable = max(0, quantity - pricing.get("free_quota", 0))
|
||
|
||
# 计算费用
|
||
if pricing["unit"] == "1000_calls":
|
||
return (chargeable / 1000) * pricing["price"]
|
||
else:
|
||
return chargeable * pricing["price"]
|
||
|
||
# ==================== 支付管理 ====================
|
||
|
||
def create_payment(self, tenant_id: str, amount: float, currency: str,
|
||
provider: str, subscription_id: str | None = None,
|
||
invoice_id: str | None = None,
|
||
payment_method: str | None = None,
|
||
payment_details: dict | None = None) -> Payment:
|
||
"""创建支付记录"""
|
||
conn = self._get_connection()
|
||
try:
|
||
payment_id = str(uuid.uuid4())
|
||
now = datetime.now()
|
||
|
||
payment = Payment(
|
||
id=payment_id,
|
||
tenant_id=tenant_id,
|
||
subscription_id=subscription_id,
|
||
invoice_id=invoice_id,
|
||
amount=amount,
|
||
currency=currency,
|
||
provider=provider,
|
||
provider_payment_id=None,
|
||
status=PaymentStatus.PENDING.value,
|
||
payment_method=payment_method,
|
||
payment_details=payment_details or {},
|
||
paid_at=None,
|
||
failed_at=None,
|
||
failure_reason=None,
|
||
created_at=now,
|
||
updated_at=now
|
||
)
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
INSERT INTO payments
|
||
(id, tenant_id, subscription_id, invoice_id, amount, currency,
|
||
provider, provider_payment_id, status, payment_method, payment_details,
|
||
paid_at, failed_at, failure_reason, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
payment.id, payment.tenant_id, payment.subscription_id,
|
||
payment.invoice_id, payment.amount, payment.currency,
|
||
payment.provider, payment.provider_payment_id, payment.status,
|
||
payment.payment_method, json.dumps(payment.payment_details),
|
||
payment.paid_at, payment.failed_at, payment.failure_reason,
|
||
payment.created_at, payment.updated_at
|
||
))
|
||
|
||
conn.commit()
|
||
return payment
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def confirm_payment(self, payment_id: str,
|
||
provider_payment_id: str | None = None) -> Payment | None:
|
||
"""确认支付完成"""
|
||
conn = self._get_connection()
|
||
try:
|
||
payment = self._get_payment_internal(conn, payment_id)
|
||
if not payment:
|
||
return None
|
||
|
||
now = datetime.now()
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE payments
|
||
SET status = 'completed', provider_payment_id = ?, paid_at = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""", (provider_payment_id, now, now, payment_id))
|
||
|
||
# 如果有关联发票,更新发票状态
|
||
if payment.invoice_id:
|
||
cursor.execute("""
|
||
UPDATE invoices
|
||
SET status = 'paid', amount_paid = amount_due, paid_at = ?
|
||
WHERE id = ?
|
||
""", (now, payment.invoice_id))
|
||
|
||
# 如果有关联订阅,激活订阅
|
||
if payment.subscription_id:
|
||
cursor.execute("""
|
||
UPDATE subscriptions
|
||
SET status = 'active', updated_at = ?
|
||
WHERE id = ? AND status = 'pending'
|
||
""", (now, payment.subscription_id))
|
||
|
||
# 记录账单历史
|
||
self._add_billing_history_internal(
|
||
conn, payment.tenant_id, "payment", payment.amount,
|
||
payment.currency, f"Payment completed via {payment.provider}",
|
||
payment_id, 0 # 余额更新应该在账户管理中处理
|
||
)
|
||
|
||
conn.commit()
|
||
logger.info(f"Payment confirmed: {payment_id}")
|
||
return self._get_payment_internal(conn, payment_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def fail_payment(self, payment_id: str, reason: str) -> Payment | None:
|
||
"""标记支付失败"""
|
||
conn = self._get_connection()
|
||
try:
|
||
now = datetime.now()
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE payments
|
||
SET status = 'failed', failure_reason = ?, failed_at = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""", (reason, now, now, payment_id))
|
||
|
||
conn.commit()
|
||
return self._get_payment_internal(conn, payment_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_payment(self, payment_id: str) -> Payment | None:
|
||
"""获取支付记录"""
|
||
conn = self._get_connection()
|
||
try:
|
||
return self._get_payment_internal(conn, payment_id)
|
||
finally:
|
||
conn.close()
|
||
|
||
def list_payments(self, tenant_id: str, status: str | None = None,
|
||
limit: int = 100, offset: int = 0) -> list[Payment]:
|
||
"""列出支付记录"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
|
||
query = "SELECT * FROM payments WHERE tenant_id = ?"
|
||
params = [tenant_id]
|
||
|
||
if status:
|
||
query += " AND status = ?"
|
||
params.append(status)
|
||
|
||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||
params.extend([limit, offset])
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
return [self._row_to_payment(row) for row in rows]
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def _get_payment_internal(self, conn: sqlite3.Connection, payment_id: str) -> Payment | None:
|
||
"""内部方法:获取支付记录"""
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT * FROM payments WHERE id = ?", (payment_id,))
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
return self._row_to_payment(row)
|
||
return None
|
||
|
||
# ==================== 发票管理 ====================
|
||
|
||
def _create_invoice_internal(self, conn: sqlite3.Connection, tenant_id: str,
|
||
subscription_id: str | None, amount: float,
|
||
currency: str, period_start: datetime,
|
||
period_end: datetime, description: str,
|
||
line_items: list[dict] | None = None) -> Invoice:
|
||
"""内部方法:创建发票"""
|
||
invoice_id = str(uuid.uuid4())
|
||
invoice_number = self._generate_invoice_number()
|
||
now = datetime.now()
|
||
due_date = now + timedelta(days=7) # 7天付款期限
|
||
|
||
invoice = Invoice(
|
||
id=invoice_id,
|
||
tenant_id=tenant_id,
|
||
subscription_id=subscription_id,
|
||
invoice_number=invoice_number,
|
||
status=InvoiceStatus.DRAFT.value,
|
||
amount_due=amount,
|
||
amount_paid=0,
|
||
currency=currency,
|
||
period_start=period_start,
|
||
period_end=period_end,
|
||
description=description,
|
||
line_items=line_items or [{"description": description, "amount": amount}],
|
||
due_date=due_date,
|
||
paid_at=None,
|
||
voided_at=None,
|
||
void_reason=None,
|
||
created_at=now,
|
||
updated_at=now
|
||
)
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
INSERT INTO invoices
|
||
(id, tenant_id, subscription_id, invoice_number, status, amount_due, amount_paid,
|
||
currency, period_start, period_end, description, line_items, due_date,
|
||
paid_at, voided_at, void_reason, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
invoice.id, invoice.tenant_id, invoice.subscription_id,
|
||
invoice.invoice_number, invoice.status, invoice.amount_due,
|
||
invoice.amount_paid, invoice.currency, invoice.period_start,
|
||
invoice.period_end, invoice.description,
|
||
json.dumps(invoice.line_items), invoice.due_date,
|
||
invoice.paid_at, invoice.voided_at, invoice.void_reason,
|
||
invoice.created_at, invoice.updated_at
|
||
))
|
||
|
||
return invoice
|
||
|
||
def get_invoice(self, invoice_id: str) -> Invoice | None:
|
||
"""获取发票"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT * FROM invoices WHERE id = ?", (invoice_id,))
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
return self._row_to_invoice(row)
|
||
return None
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_invoice_by_number(self, invoice_number: str) -> Invoice | None:
|
||
"""通过发票号获取发票"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT * FROM invoices WHERE invoice_number = ?", (invoice_number,))
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
return self._row_to_invoice(row)
|
||
return None
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def list_invoices(self, tenant_id: str, status: str | None = None,
|
||
limit: int = 100, offset: int = 0) -> list[Invoice]:
|
||
"""列出发票"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
|
||
query = "SELECT * FROM invoices WHERE tenant_id = ?"
|
||
params = [tenant_id]
|
||
|
||
if status:
|
||
query += " AND status = ?"
|
||
params.append(status)
|
||
|
||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||
params.extend([limit, offset])
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
return [self._row_to_invoice(row) for row in rows]
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def void_invoice(self, invoice_id: str, reason: str) -> Invoice | None:
|
||
"""作废发票"""
|
||
conn = self._get_connection()
|
||
try:
|
||
invoice = self.get_invoice(invoice_id)
|
||
if not invoice:
|
||
return None
|
||
|
||
if invoice.status == InvoiceStatus.PAID.value:
|
||
raise ValueError("Cannot void a paid invoice")
|
||
|
||
now = datetime.now()
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE invoices
|
||
SET status = 'void', voided_at = ?, void_reason = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""", (now, reason, now, invoice_id))
|
||
|
||
conn.commit()
|
||
return self.get_invoice(invoice_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def _generate_invoice_number(self) -> str:
|
||
"""生成发票号"""
|
||
now = datetime.now()
|
||
prefix = f"INV-{now.strftime('%Y%m')}"
|
||
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
SELECT COUNT(*) as count FROM invoices
|
||
WHERE invoice_number LIKE ?
|
||
""", (f"{prefix}%",))
|
||
row = cursor.fetchone()
|
||
count = row['count'] + 1
|
||
|
||
return f"{prefix}-{count:06d}"
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
# ==================== 退款管理 ====================
|
||
|
||
def request_refund(self, tenant_id: str, payment_id: str, amount: float,
|
||
reason: str, requested_by: str) -> Refund:
|
||
"""申请退款"""
|
||
conn = self._get_connection()
|
||
try:
|
||
# 验证支付记录
|
||
payment = self._get_payment_internal(conn, payment_id)
|
||
if not payment:
|
||
raise ValueError(f"Payment {payment_id} not found")
|
||
|
||
if payment.tenant_id != tenant_id:
|
||
raise ValueError("Payment does not belong to this tenant")
|
||
|
||
if payment.status != PaymentStatus.COMPLETED.value:
|
||
raise ValueError("Can only refund completed payments")
|
||
|
||
if amount > payment.amount:
|
||
raise ValueError("Refund amount cannot exceed payment amount")
|
||
|
||
refund_id = str(uuid.uuid4())
|
||
now = datetime.now()
|
||
|
||
refund = Refund(
|
||
id=refund_id,
|
||
tenant_id=tenant_id,
|
||
payment_id=payment_id,
|
||
invoice_id=payment.invoice_id,
|
||
amount=amount,
|
||
currency=payment.currency,
|
||
reason=reason,
|
||
status=RefundStatus.PENDING.value,
|
||
requested_by=requested_by,
|
||
requested_at=now,
|
||
approved_by=None,
|
||
approved_at=None,
|
||
completed_at=None,
|
||
provider_refund_id=None,
|
||
metadata={},
|
||
created_at=now,
|
||
updated_at=now
|
||
)
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
INSERT INTO refunds
|
||
(id, tenant_id, payment_id, invoice_id, amount, currency, reason, status,
|
||
requested_by, requested_at, approved_by, approved_at, completed_at,
|
||
provider_refund_id, metadata, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
refund.id, refund.tenant_id, refund.payment_id, refund.invoice_id,
|
||
refund.amount, refund.currency, refund.reason, refund.status,
|
||
refund.requested_by, refund.requested_at, refund.approved_by,
|
||
refund.approved_at, refund.completed_at, refund.provider_refund_id,
|
||
json.dumps(refund.metadata), refund.created_at, refund.updated_at
|
||
))
|
||
|
||
conn.commit()
|
||
logger.info(f"Refund requested: {refund_id} for payment {payment_id}")
|
||
return refund
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def approve_refund(self, refund_id: str, approved_by: str) -> Refund | None:
|
||
"""批准退款"""
|
||
conn = self._get_connection()
|
||
try:
|
||
refund = self._get_refund_internal(conn, refund_id)
|
||
if not refund:
|
||
return None
|
||
|
||
if refund.status != RefundStatus.PENDING.value:
|
||
raise ValueError("Can only approve pending refunds")
|
||
|
||
now = datetime.now()
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE refunds
|
||
SET status = 'approved', approved_by = ?, approved_at = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""", (approved_by, now, now, refund_id))
|
||
|
||
conn.commit()
|
||
return self._get_refund_internal(conn, refund_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def complete_refund(self, refund_id: str,
|
||
provider_refund_id: str | None = None) -> Refund | None:
|
||
"""完成退款"""
|
||
conn = self._get_connection()
|
||
try:
|
||
refund = self._get_refund_internal(conn, refund_id)
|
||
if not refund:
|
||
return None
|
||
|
||
now = datetime.now()
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE refunds
|
||
SET status = 'completed', provider_refund_id = ?, completed_at = ?, updated_at = ?
|
||
WHERE id = ?
|
||
""", (provider_refund_id, now, now, refund_id))
|
||
|
||
# 更新原支付记录状态
|
||
cursor.execute("""
|
||
UPDATE payments
|
||
SET status = 'refunded', updated_at = ?
|
||
WHERE id = ?
|
||
""", (now, refund.payment_id))
|
||
|
||
# 记录账单历史
|
||
self._add_billing_history_internal(
|
||
conn, refund.tenant_id, "refund", -refund.amount,
|
||
refund.currency, f"Refund processed: {refund.reason}",
|
||
refund_id, 0
|
||
)
|
||
|
||
conn.commit()
|
||
logger.info(f"Refund completed: {refund_id}")
|
||
return self._get_refund_internal(conn, refund_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def reject_refund(self, refund_id: str, reason: str) -> Refund | None:
|
||
"""拒绝退款"""
|
||
conn = self._get_connection()
|
||
try:
|
||
refund = self._get_refund_internal(conn, refund_id)
|
||
if not refund:
|
||
return None
|
||
|
||
now = datetime.now()
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
UPDATE refunds
|
||
SET status = 'rejected', metadata = json_set(metadata, '$.rejection_reason', ?), updated_at = ?
|
||
WHERE id = ?
|
||
""", (reason, now, refund_id))
|
||
|
||
conn.commit()
|
||
return self._get_refund_internal(conn, refund_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def get_refund(self, refund_id: str) -> Refund | None:
|
||
"""获取退款记录"""
|
||
conn = self._get_connection()
|
||
try:
|
||
return self._get_refund_internal(conn, refund_id)
|
||
finally:
|
||
conn.close()
|
||
|
||
def list_refunds(self, tenant_id: str, status: str | None = None,
|
||
limit: int = 100, offset: int = 0) -> list[Refund]:
|
||
"""列出退款记录"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
|
||
query = "SELECT * FROM refunds WHERE tenant_id = ?"
|
||
params = [tenant_id]
|
||
|
||
if status:
|
||
query += " AND status = ?"
|
||
params.append(status)
|
||
|
||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||
params.extend([limit, offset])
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
return [self._row_to_refund(row) for row in rows]
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
def _get_refund_internal(self, conn: sqlite3.Connection, refund_id: str) -> Refund | None:
|
||
"""内部方法:获取退款记录"""
|
||
cursor = conn.cursor()
|
||
cursor.execute("SELECT * FROM refunds WHERE id = ?", (refund_id,))
|
||
row = cursor.fetchone()
|
||
|
||
if row:
|
||
return self._row_to_refund(row)
|
||
return None
|
||
|
||
# ==================== 账单历史 ====================
|
||
|
||
def _add_billing_history_internal(self, conn: sqlite3.Connection,
|
||
tenant_id: str, type: str, amount: float,
|
||
currency: str, description: str,
|
||
reference_id: str, balance_after: float):
|
||
"""内部方法:添加账单历史"""
|
||
history_id = str(uuid.uuid4())
|
||
|
||
cursor = conn.cursor()
|
||
cursor.execute("""
|
||
INSERT INTO billing_history
|
||
(id, tenant_id, type, amount, currency, description, reference_id, balance_after, created_at, metadata)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
history_id, tenant_id, type, amount, currency,
|
||
description, reference_id, balance_after, datetime.now(), json.dumps({})
|
||
))
|
||
|
||
def get_billing_history(self, tenant_id: str,
|
||
start_date: datetime | None = None,
|
||
end_date: datetime | None = None,
|
||
limit: int = 100, offset: int = 0) -> list[BillingHistory]:
|
||
"""获取账单历史"""
|
||
conn = self._get_connection()
|
||
try:
|
||
cursor = conn.cursor()
|
||
|
||
query = "SELECT * FROM billing_history WHERE tenant_id = ?"
|
||
params = [tenant_id]
|
||
|
||
if start_date:
|
||
query += " AND created_at >= ?"
|
||
params.append(start_date)
|
||
if end_date:
|
||
query += " AND created_at <= ?"
|
||
params.append(end_date)
|
||
|
||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||
params.extend([limit, offset])
|
||
|
||
cursor.execute(query, params)
|
||
rows = cursor.fetchall()
|
||
|
||
return [self._row_to_billing_history(row) for row in rows]
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
# ==================== 支付提供商集成 ====================
|
||
|
||
def create_stripe_checkout_session(self, tenant_id: str, plan_id: str,
|
||
success_url: str, cancel_url: str,
|
||
billing_cycle: str = "monthly") -> dict[str, Any]:
|
||
"""创建 Stripe Checkout 会话(占位实现)"""
|
||
# 这里应该集成 Stripe SDK
|
||
# 简化实现,返回模拟数据
|
||
return {
|
||
"session_id": f"cs_{uuid.uuid4().hex[:24]}",
|
||
"url": f"https://checkout.stripe.com/mock/{uuid.uuid4().hex[:24]}",
|
||
"status": "created",
|
||
"provider": "stripe"
|
||
}
|
||
|
||
def create_alipay_order(self, tenant_id: str, plan_id: str,
|
||
billing_cycle: str = "monthly") -> dict[str, Any]:
|
||
"""创建支付宝订单(占位实现)"""
|
||
# 这里应该集成支付宝 SDK
|
||
plan = self.get_plan(plan_id)
|
||
amount = plan.price_yearly if billing_cycle == "yearly" else plan.price_monthly
|
||
|
||
return {
|
||
"order_id": f"ALI{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:8].upper()}",
|
||
"amount": amount,
|
||
"currency": plan.currency,
|
||
"qr_code_url": f"https://qr.alipay.com/mock/{uuid.uuid4().hex[:16]}",
|
||
"status": "pending",
|
||
"provider": "alipay"
|
||
}
|
||
|
||
def create_wechat_order(self, tenant_id: str, plan_id: str,
|
||
billing_cycle: str = "monthly") -> dict[str, Any]:
|
||
"""创建微信支付订单(占位实现)"""
|
||
# 这里应该集成微信支付 SDK
|
||
plan = self.get_plan(plan_id)
|
||
amount = plan.price_yearly if billing_cycle == "yearly" else plan.price_monthly
|
||
|
||
return {
|
||
"order_id": f"WX{datetime.now().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:8].upper()}",
|
||
"amount": amount,
|
||
"currency": plan.currency,
|
||
"prepay_id": f"wx{uuid.uuid4().hex[:32]}",
|
||
"status": "pending",
|
||
"provider": "wechat"
|
||
}
|
||
|
||
def handle_webhook(self, provider: str, payload: dict[str, Any]) -> bool:
|
||
"""处理支付提供商的 Webhook(占位实现)"""
|
||
# 这里应该实现实际的 Webhook 处理逻辑
|
||
logger.info(f"Received webhook from {provider}: {payload.get('event_type', 'unknown')}")
|
||
|
||
event_type = payload.get("event_type", "")
|
||
|
||
if provider == "stripe":
|
||
if event_type == "checkout.session.completed":
|
||
# 处理支付完成
|
||
pass
|
||
elif event_type == "invoice.payment_failed":
|
||
# 处理支付失败
|
||
pass
|
||
|
||
elif provider in ["alipay", "wechat"]:
|
||
if payload.get("trade_status") == "TRADE_SUCCESS":
|
||
# 处理支付完成
|
||
pass
|
||
|
||
return True
|
||
|
||
# ==================== 辅助方法 ====================
|
||
|
||
def _row_to_plan(self, row: sqlite3.Row) -> SubscriptionPlan:
|
||
"""数据库行转换为 SubscriptionPlan 对象"""
|
||
return SubscriptionPlan(
|
||
id=row['id'],
|
||
name=row['name'],
|
||
tier=row['tier'],
|
||
description=row['description'] or "",
|
||
price_monthly=row['price_monthly'],
|
||
price_yearly=row['price_yearly'],
|
||
currency=row['currency'],
|
||
features=json.loads(
|
||
row['features'] or '[]'),
|
||
limits=json.loads(
|
||
row['limits'] or '{}'),
|
||
is_active=bool(
|
||
row['is_active']),
|
||
created_at=datetime.fromisoformat(
|
||
row['created_at']) if isinstance(
|
||
row['created_at'],
|
||
str) else row['created_at'],
|
||
updated_at=datetime.fromisoformat(
|
||
row['updated_at']) if isinstance(
|
||
row['updated_at'],
|
||
str) else row['updated_at'],
|
||
metadata=json.loads(
|
||
row['metadata'] or '{}'))
|
||
|
||
def _row_to_subscription(self, row: sqlite3.Row) -> Subscription:
|
||
"""数据库行转换为 Subscription 对象"""
|
||
return Subscription(
|
||
id=row['id'],
|
||
tenant_id=row['tenant_id'],
|
||
plan_id=row['plan_id'],
|
||
status=row['status'],
|
||
current_period_start=datetime.fromisoformat(
|
||
row['current_period_start']) if row['current_period_start'] and isinstance(
|
||
row['current_period_start'],
|
||
str) else row['current_period_start'],
|
||
current_period_end=datetime.fromisoformat(
|
||
row['current_period_end']) if row['current_period_end'] and isinstance(
|
||
row['current_period_end'],
|
||
str) else row['current_period_end'],
|
||
cancel_at_period_end=bool(
|
||
row['cancel_at_period_end']),
|
||
canceled_at=datetime.fromisoformat(
|
||
row['canceled_at']) if row['canceled_at'] and isinstance(
|
||
row['canceled_at'],
|
||
str) else row['canceled_at'],
|
||
trial_start=datetime.fromisoformat(
|
||
row['trial_start']) if row['trial_start'] and isinstance(
|
||
row['trial_start'],
|
||
str) else row['trial_start'],
|
||
trial_end=datetime.fromisoformat(
|
||
row['trial_end']) if row['trial_end'] and isinstance(
|
||
row['trial_end'],
|
||
str) else row['trial_end'],
|
||
payment_provider=row['payment_provider'],
|
||
provider_subscription_id=row['provider_subscription_id'],
|
||
created_at=datetime.fromisoformat(
|
||
row['created_at']) if isinstance(
|
||
row['created_at'],
|
||
str) else row['created_at'],
|
||
updated_at=datetime.fromisoformat(
|
||
row['updated_at']) if isinstance(
|
||
row['updated_at'],
|
||
str) else row['updated_at'],
|
||
metadata=json.loads(
|
||
row['metadata'] or '{}'))
|
||
|
||
def _row_to_usage(self, row: sqlite3.Row) -> UsageRecord:
|
||
"""数据库行转换为 UsageRecord 对象"""
|
||
return UsageRecord(
|
||
id=row['id'],
|
||
tenant_id=row['tenant_id'],
|
||
resource_type=row['resource_type'],
|
||
quantity=row['quantity'],
|
||
unit=row['unit'],
|
||
recorded_at=datetime.fromisoformat(
|
||
row['recorded_at']) if isinstance(
|
||
row['recorded_at'],
|
||
str) else row['recorded_at'],
|
||
cost=row['cost'],
|
||
description=row['description'],
|
||
metadata=json.loads(
|
||
row['metadata'] or '{}'))
|
||
|
||
def _row_to_payment(self, row: sqlite3.Row) -> Payment:
|
||
"""数据库行转换为 Payment 对象"""
|
||
return Payment(
|
||
id=row['id'],
|
||
tenant_id=row['tenant_id'],
|
||
subscription_id=row['subscription_id'],
|
||
invoice_id=row['invoice_id'],
|
||
amount=row['amount'],
|
||
currency=row['currency'],
|
||
provider=row['provider'],
|
||
provider_payment_id=row['provider_payment_id'],
|
||
status=row['status'],
|
||
payment_method=row['payment_method'],
|
||
payment_details=json.loads(
|
||
row['payment_details'] or '{}'),
|
||
paid_at=datetime.fromisoformat(
|
||
row['paid_at']) if row['paid_at'] and isinstance(
|
||
row['paid_at'],
|
||
str) else row['paid_at'],
|
||
failed_at=datetime.fromisoformat(
|
||
row['failed_at']) if row['failed_at'] and isinstance(
|
||
row['failed_at'],
|
||
str) else row['failed_at'],
|
||
failure_reason=row['failure_reason'],
|
||
created_at=datetime.fromisoformat(
|
||
row['created_at']) if isinstance(
|
||
row['created_at'],
|
||
str) else row['created_at'],
|
||
updated_at=datetime.fromisoformat(
|
||
row['updated_at']) if isinstance(
|
||
row['updated_at'],
|
||
str) else row['updated_at'])
|
||
|
||
def _row_to_invoice(self, row: sqlite3.Row) -> Invoice:
|
||
"""数据库行转换为 Invoice 对象"""
|
||
return Invoice(
|
||
id=row['id'],
|
||
tenant_id=row['tenant_id'],
|
||
subscription_id=row['subscription_id'],
|
||
invoice_number=row['invoice_number'],
|
||
status=row['status'],
|
||
amount_due=row['amount_due'],
|
||
amount_paid=row['amount_paid'],
|
||
currency=row['currency'],
|
||
period_start=datetime.fromisoformat(
|
||
row['period_start']) if row['period_start'] and isinstance(
|
||
row['period_start'],
|
||
str) else row['period_start'],
|
||
period_end=datetime.fromisoformat(
|
||
row['period_end']) if row['period_end'] and isinstance(
|
||
row['period_end'],
|
||
str) else row['period_end'],
|
||
description=row['description'],
|
||
line_items=json.loads(
|
||
row['line_items'] or '[]'),
|
||
due_date=datetime.fromisoformat(
|
||
row['due_date']) if row['due_date'] and isinstance(
|
||
row['due_date'],
|
||
str) else row['due_date'],
|
||
paid_at=datetime.fromisoformat(
|
||
row['paid_at']) if row['paid_at'] and isinstance(
|
||
row['paid_at'],
|
||
str) else row['paid_at'],
|
||
voided_at=datetime.fromisoformat(
|
||
row['voided_at']) if row['voided_at'] and isinstance(
|
||
row['voided_at'],
|
||
str) else row['voided_at'],
|
||
void_reason=row['void_reason'],
|
||
created_at=datetime.fromisoformat(
|
||
row['created_at']) if isinstance(
|
||
row['created_at'],
|
||
str) else row['created_at'],
|
||
updated_at=datetime.fromisoformat(
|
||
row['updated_at']) if isinstance(
|
||
row['updated_at'],
|
||
str) else row['updated_at'])
|
||
|
||
def _row_to_refund(self, row: sqlite3.Row) -> Refund:
|
||
"""数据库行转换为 Refund 对象"""
|
||
return Refund(
|
||
id=row['id'],
|
||
tenant_id=row['tenant_id'],
|
||
payment_id=row['payment_id'],
|
||
invoice_id=row['invoice_id'],
|
||
amount=row['amount'],
|
||
currency=row['currency'],
|
||
reason=row['reason'],
|
||
status=row['status'],
|
||
requested_by=row['requested_by'],
|
||
requested_at=datetime.fromisoformat(
|
||
row['requested_at']) if isinstance(
|
||
row['requested_at'],
|
||
str) else row['requested_at'],
|
||
approved_by=row['approved_by'],
|
||
approved_at=datetime.fromisoformat(
|
||
row['approved_at']) if row['approved_at'] and isinstance(
|
||
row['approved_at'],
|
||
str) else row['approved_at'],
|
||
completed_at=datetime.fromisoformat(
|
||
row['completed_at']) if row['completed_at'] and isinstance(
|
||
row['completed_at'],
|
||
str) else row['completed_at'],
|
||
provider_refund_id=row['provider_refund_id'],
|
||
metadata=json.loads(
|
||
row['metadata'] or '{}'),
|
||
created_at=datetime.fromisoformat(
|
||
row['created_at']) if isinstance(
|
||
row['created_at'],
|
||
str) else row['created_at'],
|
||
updated_at=datetime.fromisoformat(
|
||
row['updated_at']) if isinstance(
|
||
row['updated_at'],
|
||
str) else row['updated_at'])
|
||
|
||
def _row_to_billing_history(self, row: sqlite3.Row) -> BillingHistory:
|
||
"""数据库行转换为 BillingHistory 对象"""
|
||
return BillingHistory(
|
||
id=row['id'],
|
||
tenant_id=row['tenant_id'],
|
||
type=row['type'],
|
||
amount=row['amount'],
|
||
currency=row['currency'],
|
||
description=row['description'],
|
||
reference_id=row['reference_id'],
|
||
balance_after=row['balance_after'],
|
||
created_at=datetime.fromisoformat(
|
||
row['created_at']) if isinstance(
|
||
row['created_at'],
|
||
str) else row['created_at'],
|
||
metadata=json.loads(
|
||
row['metadata'] or '{}'))
|
||
|
||
|
||
# 全局订阅管理器实例
|
||
subscription_manager = None
|
||
|
||
|
||
def get_subscription_manager(db_path: str = "insightflow.db") -> SubscriptionManager:
|
||
"""获取订阅管理器实例(单例模式)"""
|
||
global subscription_manager
|
||
if subscription_manager is None:
|
||
subscription_manager = SubscriptionManager(db_path)
|
||
return subscription_manager
|