"""套餐与订阅模型(批次 C1)。 设计要点: - `Plan.limits` 是**唯一事实来源**:官网价格表、配额校验、升级引导弹窗都读它, 一处定义两处使用(计划要求)。 - `Subscription` 每租户一条:free/basic/pro,含试用期与账期。 - 试用到期由 Dramatiq cron 每日扫描 → 降级 free(见 tasks.py)。 """ from __future__ import annotations from datetime import timedelta from django.db import models from django.utils import timezone from apps.core.base_models import TenantScopedModel # 预置套餐(定价依据:卡在金蝶 698 与用友 1500 之间偏上,凭"带总账 + AI 风控"打差异) DEFAULT_PLANS = [ { "code": "free", "name": "免费版", "price_monthly": 0, "sort_order": 0, "description": "30 天全功能试用,适合先跑通流程", "limits": { "users": 1, "products": 100, "bills_monthly": 50, "ai_parse_order": 10, "ai_ask": 20, "batch_managed": True, "finance_ledger": True, "print_templates": 2, "storefront": False, }, }, { "code": "basic", "name": "基础版", "price_monthly": 998, "sort_order": 1, "description": "单店经销商:进销存 + 应收应付 + 打印", "limits": { "users": 5, "products": 3000, "bills_monthly": 2000, "ai_parse_order": 200, "ai_ask": 500, "batch_managed": True, "finance_ledger": True, "print_templates": 20, "storefront": False, }, }, { "code": "pro", "name": "专业版", "price_monthly": 4800, "sort_order": 2, "description": "多仓多业务员:总账 + 批次效期 + AI 风控 + 订货商城", "limits": { "users": 50, "products": 100000, "bills_monthly": 0, # 0 = 不限 "ai_parse_order": 2000, "ai_ask": 5000, "batch_managed": True, "finance_ledger": True, "print_templates": 0, # 0 = 不限 "storefront": True, }, }, ] class Plan(models.Model): """套餐定义(全局,非租户级)。""" code = models.CharField(max_length=32, unique=True) name = models.CharField(max_length=64) price_monthly = models.DecimalField(max_digits=10, decimal_places=2, default=0, help_text="月费(元);0 = 免费") limits = models.JSONField(default=dict, help_text="配额与功能开关(唯一事实来源)") description = models.CharField(max_length=255, blank=True, default="") sort_order = models.IntegerField(default=0) is_active = models.BooleanField(default=True) class Meta: db_table = "billing_plan" verbose_name_plural = "套餐" ordering = ["sort_order", "code"] def __str__(self): return f"{self.name}(¥{self.price_monthly}/月)" class Subscription(TenantScopedModel): """租户订阅(每租户一条)。""" STATUS_TRIAL = "trial" STATUS_ACTIVE = "active" STATUS_EXPIRED = "expired" STATUS_CHOICES = [ (STATUS_TRIAL, "试用中"), (STATUS_ACTIVE, "已订阅"), (STATUS_EXPIRED, "已过期"), ] plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name="subscriptions") status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_TRIAL) trial_ends_at = models.DateTimeField(null=True, blank=True) period_start = models.DateField(null=True, blank=True) period_end = models.DateField(null=True, blank=True) remark = models.TextField(blank=True, default="") class Meta: db_table = "billing_subscription" verbose_name_plural = "订阅" ordering = ["-created_at"] def __str__(self): return f"{self.tenant_id}:{self.plan.code}/{self.status}" @property def is_trial_expired(self) -> bool: return bool( self.status == self.STATUS_TRIAL and self.trial_ends_at and timezone.now() > self.trial_ends_at ) def effective_limits(self) -> dict: """当前生效配额:试用过期或已过期 → 回落 free 套餐限制。""" if self.status == self.STATUS_EXPIRED or self.is_trial_expired: free = Plan.objects.filter(code="free", is_active=True).first() return dict(free.limits) if free else {} return dict(self.plan.limits or {}) def seed_plans() -> int: """幂等写入预置套餐。返回新建数量。""" created = 0 for spec in DEFAULT_PLANS: _, was_created = Plan.objects.update_or_create( code=spec["code"], defaults={ "name": spec["name"], "price_monthly": spec["price_monthly"], "limits": spec["limits"], "description": spec["description"], "sort_order": spec["sort_order"], "is_active": True, }, ) created += 1 if was_created else 0 return created def subscribe( tenant, *, plan_code: str = "free", status: str = Subscription.STATUS_TRIAL, trial_days: int = 30, ) -> Subscription: """为新租户开通订阅(幂等:已存在则原样返回)。""" existing = Subscription.objects.filter(tenant=tenant).first() if existing is not None: return existing plan = Plan.objects.filter(code=plan_code, is_active=True).first() if plan is None: seed_plans() plan = Plan.objects.filter(code=plan_code, is_active=True).first() now = timezone.now() return Subscription.objects.create( tenant=tenant, plan=plan, status=status, trial_ends_at=(now + timedelta(days=trial_days)) if status == Subscription.STATUS_TRIAL else None, period_start=now.date(), period_end=(now + timedelta(days=trial_days)).date(), ) def get_subscription(tenant) -> Subscription: """取订阅,没有则自动开通 free 试用(保证任何租户都有配额可查)。""" sub = Subscription.objects.filter(tenant=tenant).select_related("plan").first() if sub is None: sub = subscribe(tenant) return sub def upgrade(tenant, plan_code: str, *, months: int = 1) -> Subscription: """升级/续订套餐(真实支付接入后,由支付回调调用)。""" sub = get_subscription(tenant) plan = Plan.objects.filter(code=plan_code, is_active=True).first() if plan is None: raise ValueError(f"plan {plan_code} not found") today = timezone.now().date() base = sub.period_end if (sub.period_end and sub.period_end > today) else today sub.plan = plan sub.status = Subscription.STATUS_ACTIVE sub.period_start = today sub.period_end = base + timedelta(days=30 * months) sub.trial_ends_at = None sub.save(update_fields=["plan", "status", "period_start", "period_end", "trial_ends_at", "updated_at"]) return sub