Files

215 lines
7.1 KiB
Python

"""套餐与订阅模型(批次 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