215 lines
7.1 KiB
Python
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
|