From 34412b26c0d49796235b975a5fc79f31b56e8b0a Mon Sep 17 00:00:00 2001 From: yuqei Date: Mon, 18 May 2026 22:27:42 +0800 Subject: [PATCH] feat: add commercial order activation flow --- backend/app/api/tenant_settings.py | 151 +++++++ backend/app/models/__init__.py | 1 + backend/app/models/commercial_order.py | 44 ++ backend/app/services/tenant_service.py | 311 +++++++++++++ .../20260518_0025_create_commercial_orders.py | 74 ++++ backend/tests/test_tenant_settings_api.py | 272 ++++++++++++ frontend/src/app/billing/page.tsx | 417 ++++++++++++++++-- frontend/src/services/tenant-settings.ts | 81 +++- frontend/src/types/api.ts | 70 +++ ...µ·草与审查助手-当前工作进度保存-2026-05-11.md | 68 +++ 10 files changed, 1460 insertions(+), 29 deletions(-) create mode 100644 backend/app/models/commercial_order.py create mode 100644 backend/migrations/versions/20260518_0025_create_commercial_orders.py diff --git a/backend/app/api/tenant_settings.py b/backend/app/api/tenant_settings.py index e0aabe72..f191d53d 100644 --- a/backend/app/api/tenant_settings.py +++ b/backend/app/api/tenant_settings.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, Form, Header @@ -15,6 +16,10 @@ ROUTE_PERMISSION_ACTIONS = { "get": "tenant_settings:get", "update": "tenant_settings:update", "billing": "tenant_settings:billing", + "billing_orders": "tenant_settings:billing_orders", + "create_billing_order": "tenant_settings:create_billing_order", + "activate_billing_order": "tenant_settings:activate_billing_order", + "update_billing_plan": "tenant_settings:update_billing_plan", } @@ -49,6 +54,152 @@ def get_current_tenant_billing( return success_response(tenant_service.get_current_billing(current_user)) +@router.get("/billing/orders") +def list_current_tenant_commercial_orders( + limit: int = 50, + tenant_service: TenantService = Depends(get_tenant_service), + current_user: dict = Depends(require_current_user), +): + return success_response(tenant_service.list_commercial_orders(current_user, limit=limit)) + + +@router.post("/billing/orders") +def create_current_tenant_commercial_order( + order_type: str = Form(default="subscription"), + plan_code: str = Form(...), + plan_name: Optional[str] = Form(default=None), + billing_cycle: str = Form(default="monthly"), + amount_cents: int = Form(default=0), + currency: str = Form(default="CNY"), + payment_provider: str = Form(default="offline"), + invoice_required: bool = Form(default=False), + contact_name: Optional[str] = Form(default=None), + contact_phone: Optional[str] = Form(default=None), + contact_email: Optional[str] = Form(default=None), + note: Optional[str] = Form(default=None), + review_quota_monthly: Optional[int] = Form(default=None), + generation_quota_monthly: Optional[int] = Form(default=None), + knowledge_base_quota: Optional[int] = Form(default=None), + storage_quota_mb: Optional[int] = Form(default=None), + max_users: Optional[int] = Form(default=None), + max_storage_mb: Optional[int] = Form(default=None), + tenant_service: TenantService = Depends(get_tenant_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = tenant_service.create_commercial_order( + current_user=current_user, + order_type=order_type, + plan_code=plan_code, + plan_name=plan_name, + billing_cycle=billing_cycle, + amount_cents=amount_cents, + currency=currency, + payment_provider=payment_provider, + invoice_required=invoice_required, + contact_name=contact_name, + contact_phone=contact_phone, + contact_email=contact_email, + note=note, + review_quota_monthly=review_quota_monthly, + generation_quota_monthly=generation_quota_monthly, + knowledge_base_quota=knowledge_base_quota, + storage_quota_mb=storage_quota_mb, + max_users=max_users, + max_storage_mb=max_storage_mb, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="create_commercial_order", + resource_type="commercial_order", + resource_id=result["id"], + detail_json={ + "order_no": result["order_no"], + "order_type": result["order_type"], + "plan_code": result["plan_code"], + "amount_cents": result["amount_cents"], + "invoice_status": result["invoice_status"], + }, + ) + return success_response(result) + + +@router.post("/billing/orders/{order_id}/activate") +def activate_current_tenant_commercial_order( + order_id: str, + payment_provider: str = Form(default="offline"), + tenant_service: TenantService = Depends(get_tenant_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = tenant_service.activate_commercial_order(current_user, order_id, payment_provider=payment_provider) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="activate_commercial_order", + resource_type="commercial_order", + resource_id=order_id, + detail_json={ + "order_no": result["order"]["order_no"], + "plan_code": result["tenant"]["plan_code"], + "billing_status": result["tenant"]["billing_status"], + "payment_provider": result["order"]["payment_provider"], + }, + ) + return success_response(result) + + +@router.put("/billing/plan") +def update_current_tenant_billing_plan( + plan_code: str = Form(...), + plan_name: str = Form(...), + billing_status: str = Form(default="active"), + trial_ends_at: Optional[datetime] = Form(default=None), + review_quota_monthly: Optional[int] = Form(default=None), + generation_quota_monthly: Optional[int] = Form(default=None), + knowledge_base_quota: Optional[int] = Form(default=None), + storage_quota_mb: Optional[int] = Form(default=None), + max_users: Optional[int] = Form(default=None), + max_storage_mb: Optional[int] = Form(default=None), + tenant_service: TenantService = Depends(get_tenant_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = tenant_service.update_current_billing_plan( + current_user=current_user, + plan_code=plan_code, + plan_name=plan_name, + billing_status=billing_status, + trial_ends_at=trial_ends_at, + review_quota_monthly=review_quota_monthly, + generation_quota_monthly=generation_quota_monthly, + knowledge_base_quota=knowledge_base_quota, + storage_quota_mb=storage_quota_mb, + max_users=max_users, + max_storage_mb=max_storage_mb, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="update_commercial_billing_plan", + resource_type="tenant", + resource_id=result["id"], + detail_json={ + "plan_code": result["plan_code"], + "plan_name": result["plan_name"], + "billing_status": result["billing_status"], + "review_quota_monthly": result["review_quota_monthly"], + "generation_quota_monthly": result["generation_quota_monthly"], + "knowledge_base_quota": result["knowledge_base_quota"], + "storage_quota_mb": result["storage_quota_mb"], + "max_users": result["max_users"], + "max_storage_mb": result["max_storage_mb"], + }, + ) + return success_response(result) + + @router.put("/me") def update_current_tenant_settings( name: str = Form(...), diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 83b34922..aeded7bd 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,4 +1,5 @@ from app.models.audit_log import AuditLog +from app.models.commercial_order import CommercialOrder from app.models.conversation import Conversation from app.models.contract_template import ContractTemplate from app.models.contract_template_version import ContractTemplateVersion diff --git a/backend/app/models/commercial_order.py b/backend/app/models/commercial_order.py new file mode 100644 index 00000000..3e33a207 --- /dev/null +++ b/backend/app/models/commercial_order.py @@ -0,0 +1,44 @@ +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class CommercialOrder(Base): + __tablename__ = "commercial_orders" + + id: Mapped[str] = mapped_column(String(64), primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + requested_by: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True) + order_no: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + order_type: Mapped[str] = mapped_column(String(32), nullable=False, default="subscription") + status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending_payment", index=True) + plan_code: Mapped[str] = mapped_column(String(64), nullable=False) + plan_name: Mapped[str] = mapped_column(String(64), nullable=False) + billing_cycle: Mapped[str] = mapped_column(String(32), nullable=False, default="monthly") + amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + currency: Mapped[str] = mapped_column(String(16), nullable=False, default="CNY") + payment_provider: Mapped[str] = mapped_column(String(32), nullable=False, default="offline") + invoice_status: Mapped[str] = mapped_column(String(32), nullable=False, default="not_requested") + contact_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + contact_phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) + contact_email: Mapped[Optional[str]] = mapped_column(String(120), nullable=True) + note: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + review_quota_monthly: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + generation_quota_monthly: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + knowledge_base_quota: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + storage_quota_mb: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + max_users: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + max_storage_mb: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + paid_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + activated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + canceled_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/app/services/tenant_service.py b/backend/app/services/tenant_service.py index 98736ce8..9e5486cd 100644 --- a/backend/app/services/tenant_service.py +++ b/backend/app/services/tenant_service.py @@ -1,12 +1,14 @@ from datetime import datetime, timedelta, timezone from math import ceil from typing import Any, Dict, Optional +from uuid import uuid4 from fastapi import HTTPException, status from sqlalchemy import func from sqlalchemy.orm import Session from app.models.audit_log import AuditLog +from app.models.commercial_order import CommercialOrder from app.models.file import File from app.models.generated_contract import GeneratedContract from app.models.knowledge_base import KnowledgeBase @@ -64,6 +66,58 @@ BILLABLE_ACTIONS = { }, } +VALID_BILLING_STATUSES = {"trialing", "active", "past_due", "suspended", "expired", "canceled"} +VALID_ORDER_TYPES = {"subscription", "overage", "private_deployment", "implementation_service"} +VALID_BILLING_CYCLES = {"monthly", "yearly", "one_time", "trial"} +VALID_PAYMENT_PROVIDERS = {"offline", "wechat_pay", "alipay", "bank_transfer", "manual"} +COMMERCIAL_PLAN_PRESETS = { + "trial": { + "plan_name": "试用版", + "review_quota_monthly": DEFAULT_REVIEW_QUOTA_MONTHLY, + "generation_quota_monthly": DEFAULT_GENERATION_QUOTA_MONTHLY, + "knowledge_base_quota": DEFAULT_KNOWLEDGE_BASE_QUOTA, + "storage_quota_mb": DEFAULT_STORAGE_QUOTA_MB, + "max_users": 3, + "max_storage_mb": DEFAULT_STORAGE_QUOTA_MB, + }, + "starter": { + "plan_name": "基础版", + "review_quota_monthly": 100, + "generation_quota_monthly": 80, + "knowledge_base_quota": 1, + "storage_quota_mb": 2048, + "max_users": 5, + "max_storage_mb": 2048, + }, + "team": { + "plan_name": "团队版", + "review_quota_monthly": 800, + "generation_quota_monthly": 500, + "knowledge_base_quota": 5, + "storage_quota_mb": 10240, + "max_users": 20, + "max_storage_mb": 10240, + }, + "professional": { + "plan_name": "专业版", + "review_quota_monthly": 4000, + "generation_quota_monthly": 2000, + "knowledge_base_quota": 20, + "storage_quota_mb": 51200, + "max_users": 80, + "max_storage_mb": 51200, + }, + "enterprise": { + "plan_name": "私有部署版", + "review_quota_monthly": 0, + "generation_quota_monthly": 0, + "knowledge_base_quota": 0, + "storage_quota_mb": 0, + "max_users": 0, + "max_storage_mb": 0, + }, +} + class TenantService: def __init__(self, db: Session): @@ -192,6 +246,136 @@ class TenantService: f"{action_config['metric_label']}额度已用完:当前已使用 {used}/{quota}{action_config['unit']},请升级套餐或购买超额包后继续使用。" ) + def update_current_billing_plan( + self, + current_user: Dict[str, Any], + plan_code: str, + plan_name: str, + billing_status: str, + trial_ends_at: Optional[datetime] = None, + review_quota_monthly: Optional[int] = None, + generation_quota_monthly: Optional[int] = None, + knowledge_base_quota: Optional[int] = None, + storage_quota_mb: Optional[int] = None, + max_users: Optional[int] = None, + max_storage_mb: Optional[int] = None, + ) -> dict: + self._require_admin(current_user) + tenant = self._get_or_create_current_tenant(current_user["tenant_id"]) + tenant.plan_code = self._normalize_plan_code(plan_code) + tenant.plan_name = self._normalize_required_text(plan_name, "plan_name") + tenant.billing_status = self._normalize_billing_status(billing_status) + if trial_ends_at is not None: + tenant.trial_ends_at = self._normalize_datetime(trial_ends_at) + self._apply_quota_values( + tenant, + review_quota_monthly=review_quota_monthly, + generation_quota_monthly=generation_quota_monthly, + knowledge_base_quota=knowledge_base_quota, + storage_quota_mb=storage_quota_mb, + max_users=max_users, + max_storage_mb=max_storage_mb, + ) + updated = self.repository.update(tenant) + return self._to_item(updated) + + def list_commercial_orders(self, current_user: Dict[str, Any], limit: int = 50) -> list[dict]: + self._require_admin(current_user) + tenant = self._get_or_create_current_tenant(current_user["tenant_id"]) + normalized_limit = min(max(int(limit or 50), 1), 100) + orders = ( + self.db.query(CommercialOrder) + .filter(CommercialOrder.tenant_id == tenant.id) + .order_by(CommercialOrder.created_at.desc()) + .limit(normalized_limit) + .all() + ) + return [self._to_order_item(order) for order in orders] + + def create_commercial_order( + self, + current_user: Dict[str, Any], + order_type: str, + plan_code: str, + plan_name: Optional[str] = None, + billing_cycle: str = "monthly", + amount_cents: int = 0, + currency: str = "CNY", + payment_provider: str = "offline", + invoice_required: bool = False, + contact_name: Optional[str] = None, + contact_phone: Optional[str] = None, + contact_email: Optional[str] = None, + note: Optional[str] = None, + review_quota_monthly: Optional[int] = None, + generation_quota_monthly: Optional[int] = None, + knowledge_base_quota: Optional[int] = None, + storage_quota_mb: Optional[int] = None, + max_users: Optional[int] = None, + max_storage_mb: Optional[int] = None, + ) -> dict: + self._require_admin(current_user) + tenant = self._get_or_create_current_tenant(current_user["tenant_id"]) + normalized_plan_code = self._normalize_plan_code(plan_code) + preset = COMMERCIAL_PLAN_PRESETS.get(normalized_plan_code, {}) + order = CommercialOrder( + id=f"commercial-order-{uuid4().hex}", + tenant_id=tenant.id, + requested_by=current_user.get("id"), + order_no=self._generate_order_no(), + order_type=self._normalize_order_type(order_type), + status="pending_payment", + plan_code=normalized_plan_code, + plan_name=self._normalize_text(plan_name) or str(preset.get("plan_name") or normalized_plan_code), + billing_cycle=self._normalize_billing_cycle(billing_cycle), + amount_cents=self._normalize_non_negative_int(amount_cents, "amount_cents"), + currency=(currency or "CNY").strip().upper()[:16] or "CNY", + payment_provider=self._normalize_payment_provider(payment_provider), + invoice_status="requested" if invoice_required else "not_requested", + contact_name=self._normalize_text(contact_name), + contact_phone=self._normalize_text(contact_phone), + contact_email=self._normalize_text(contact_email), + note=self._normalize_text(note), + review_quota_monthly=self._resolve_order_quota(preset, "review_quota_monthly", review_quota_monthly), + generation_quota_monthly=self._resolve_order_quota(preset, "generation_quota_monthly", generation_quota_monthly), + knowledge_base_quota=self._resolve_order_quota(preset, "knowledge_base_quota", knowledge_base_quota), + storage_quota_mb=self._resolve_order_quota(preset, "storage_quota_mb", storage_quota_mb), + max_users=self._resolve_order_quota(preset, "max_users", max_users), + max_storage_mb=self._resolve_order_quota(preset, "max_storage_mb", max_storage_mb), + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + ) + self.db.add(order) + self.db.commit() + self.db.refresh(order) + return self._to_order_item(order) + + def activate_commercial_order(self, current_user: Dict[str, Any], order_id: str, payment_provider: str = "offline") -> dict: + self._require_admin(current_user) + tenant = self._get_or_create_current_tenant(current_user["tenant_id"]) + order = ( + self.db.query(CommercialOrder) + .filter(CommercialOrder.id == order_id, CommercialOrder.tenant_id == tenant.id) + .first() + ) + if order is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="commercial order not found") + if order.status == "canceled": + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="commercial order is canceled") + now = datetime.now(timezone.utc) + order.status = "activated" + order.payment_provider = self._normalize_payment_provider(payment_provider) + order.paid_at = order.paid_at or now + order.activated_at = order.activated_at or now + order.updated_at = now + self._apply_commercial_order_to_tenant(tenant, order) + self.db.add(order) + self.db.add(tenant) + self.db.commit() + self.db.refresh(order) + self.db.refresh(tenant) + return {"order": self._to_order_item(order), "tenant": self._to_item(tenant)} + def update_current_settings(self, current_user: Dict[str, Any], name: str, code: str, status_value: str) -> dict: self._require_admin(current_user) tenant = self._get_or_create_current_tenant(current_user["tenant_id"]) @@ -405,6 +589,133 @@ class TenantService: return value.replace(tzinfo=timezone.utc) return value + def _normalize_text(self, value: Optional[str]) -> Optional[str]: + if value is None: + return None + normalized = value.strip() + return normalized or None + + def _normalize_required_text(self, value: str, field_name: str) -> str: + normalized = self._normalize_text(value) + if normalized is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is required") + return normalized + + def _normalize_plan_code(self, value: str) -> str: + normalized = self._normalize_required_text(value, "plan_code").lower() + if len(normalized) > 64: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="plan_code is too long") + return normalized + + def _normalize_billing_status(self, value: str) -> str: + normalized = self._normalize_required_text(value, "billing_status").lower() + if normalized not in VALID_BILLING_STATUSES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid billing_status") + return normalized + + def _normalize_order_type(self, value: str) -> str: + normalized = self._normalize_required_text(value, "order_type").lower() + if normalized not in VALID_ORDER_TYPES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid order_type") + return normalized + + def _normalize_billing_cycle(self, value: str) -> str: + normalized = self._normalize_required_text(value, "billing_cycle").lower() + if normalized not in VALID_BILLING_CYCLES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid billing_cycle") + return normalized + + def _normalize_payment_provider(self, value: str) -> str: + normalized = self._normalize_required_text(value or "offline", "payment_provider").lower() + if normalized not in VALID_PAYMENT_PROVIDERS: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid payment_provider") + return normalized + + def _normalize_non_negative_int(self, value: Optional[int], field_name: str) -> Optional[int]: + if value is None: + return None + normalized = int(value) + if normalized < 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} cannot be negative") + return normalized + + def _resolve_order_quota(self, preset: dict, field_name: str, value: Optional[int]) -> Optional[int]: + if value is not None: + return self._normalize_non_negative_int(value, field_name) + preset_value = preset.get(field_name) + return int(preset_value) if preset_value is not None else None + + def _apply_quota_values( + self, + tenant: Tenant, + review_quota_monthly: Optional[int] = None, + generation_quota_monthly: Optional[int] = None, + knowledge_base_quota: Optional[int] = None, + storage_quota_mb: Optional[int] = None, + max_users: Optional[int] = None, + max_storage_mb: Optional[int] = None, + ) -> None: + quota_updates = { + "review_quota_monthly": review_quota_monthly, + "generation_quota_monthly": generation_quota_monthly, + "knowledge_base_quota": knowledge_base_quota, + "storage_quota_mb": storage_quota_mb, + "max_users": max_users, + "max_storage_mb": max_storage_mb, + } + for field_name, value in quota_updates.items(): + if value is not None: + setattr(tenant, field_name, self._normalize_non_negative_int(value, field_name)) + + def _apply_commercial_order_to_tenant(self, tenant: Tenant, order: CommercialOrder) -> None: + tenant.plan_code = order.plan_code + tenant.plan_name = order.plan_name + tenant.billing_status = "active" + self._apply_quota_values( + tenant, + review_quota_monthly=order.review_quota_monthly, + generation_quota_monthly=order.generation_quota_monthly, + knowledge_base_quota=order.knowledge_base_quota, + storage_quota_mb=order.storage_quota_mb, + max_users=order.max_users, + max_storage_mb=order.max_storage_mb or order.storage_quota_mb, + ) + + def _generate_order_no(self) -> str: + return f"ORD-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}-{uuid4().hex[:8].upper()}" + + def _to_order_item(self, order: CommercialOrder) -> dict: + return { + "id": order.id, + "tenant_id": order.tenant_id, + "requested_by": order.requested_by, + "order_no": order.order_no, + "order_type": order.order_type, + "status": order.status, + "plan_code": order.plan_code, + "plan_name": order.plan_name, + "billing_cycle": order.billing_cycle, + "amount_cents": order.amount_cents, + "currency": order.currency, + "payment_provider": order.payment_provider, + "invoice_status": order.invoice_status, + "contact_name": order.contact_name, + "contact_phone": order.contact_phone, + "contact_email": order.contact_email, + "note": order.note, + "review_quota_monthly": order.review_quota_monthly, + "generation_quota_monthly": order.generation_quota_monthly, + "knowledge_base_quota": order.knowledge_base_quota, + "storage_quota_mb": order.storage_quota_mb, + "max_users": order.max_users, + "max_storage_mb": order.max_storage_mb, + "paid_at": order.paid_at.isoformat() if order.paid_at else None, + "activated_at": order.activated_at.isoformat() if order.activated_at else None, + "canceled_at": order.canceled_at.isoformat() if order.canceled_at else None, + "created_at": order.created_at.isoformat(), + "updated_at": order.updated_at.isoformat(), + } + def _raise_billing_block(self, message: str): raise HTTPException(status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=message) diff --git a/backend/migrations/versions/20260518_0025_create_commercial_orders.py b/backend/migrations/versions/20260518_0025_create_commercial_orders.py new file mode 100644 index 00000000..6e5d5108 --- /dev/null +++ b/backend/migrations/versions/20260518_0025_create_commercial_orders.py @@ -0,0 +1,74 @@ +"""create commercial orders""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +revision = "20260518_0025" +down_revision = "20260518_0024" +branch_labels = None +depends_on = None + + +def _table_names(inspector: sa.Inspector) -> set[str]: + return set(inspector.get_table_names()) + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "commercial_orders" in _table_names(inspector): + return + + op.create_table( + "commercial_orders", + sa.Column("id", sa.String(length=64), primary_key=True), + sa.Column("tenant_id", sa.String(length=64), nullable=False), + sa.Column("requested_by", sa.String(length=64), nullable=True), + sa.Column("order_no", sa.String(length=64), nullable=False), + sa.Column("order_type", sa.String(length=32), nullable=False, server_default="subscription"), + sa.Column("status", sa.String(length=32), nullable=False, server_default="pending_payment"), + sa.Column("plan_code", sa.String(length=64), nullable=False), + sa.Column("plan_name", sa.String(length=64), nullable=False), + sa.Column("billing_cycle", sa.String(length=32), nullable=False, server_default="monthly"), + sa.Column("amount_cents", sa.Integer(), nullable=False, server_default="0"), + sa.Column("currency", sa.String(length=16), nullable=False, server_default="CNY"), + sa.Column("payment_provider", sa.String(length=32), nullable=False, server_default="offline"), + sa.Column("invoice_status", sa.String(length=32), nullable=False, server_default="not_requested"), + sa.Column("contact_name", sa.String(length=100), nullable=True), + sa.Column("contact_phone", sa.String(length=64), nullable=True), + sa.Column("contact_email", sa.String(length=120), nullable=True), + sa.Column("note", sa.Text(), nullable=True), + sa.Column("review_quota_monthly", sa.Integer(), nullable=True), + sa.Column("generation_quota_monthly", sa.Integer(), nullable=True), + sa.Column("knowledge_base_quota", sa.Integer(), nullable=True), + sa.Column("storage_quota_mb", sa.Integer(), nullable=True), + sa.Column("max_users", sa.Integer(), nullable=True), + sa.Column("max_storage_mb", sa.Integer(), nullable=True), + sa.Column("paid_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("canceled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_commercial_orders_tenant_id", "commercial_orders", ["tenant_id"]) + op.create_index("ix_commercial_orders_requested_by", "commercial_orders", ["requested_by"]) + op.create_index("ix_commercial_orders_order_no", "commercial_orders", ["order_no"], unique=True) + op.create_index("ix_commercial_orders_status", "commercial_orders", ["status"]) + op.create_index("ix_commercial_orders_created_at", "commercial_orders", ["created_at"]) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if "commercial_orders" not in _table_names(inspector): + return + + op.drop_index("ix_commercial_orders_created_at", table_name="commercial_orders") + op.drop_index("ix_commercial_orders_status", table_name="commercial_orders") + op.drop_index("ix_commercial_orders_order_no", table_name="commercial_orders") + op.drop_index("ix_commercial_orders_requested_by", table_name="commercial_orders") + op.drop_index("ix_commercial_orders_tenant_id", table_name="commercial_orders") + op.drop_table("commercial_orders") diff --git a/backend/tests/test_tenant_settings_api.py b/backend/tests/test_tenant_settings_api.py index dd4dd63c..e8035d16 100644 --- a/backend/tests/test_tenant_settings_api.py +++ b/backend/tests/test_tenant_settings_api.py @@ -10,6 +10,7 @@ from app.api.tenant_settings import get_audit_log_service, get_tenant_service, r from app.db import Base from app.main import app from app.models.audit_log import AuditLog +from app.models.commercial_order import CommercialOrder from app.models.file import File from app.models.generated_contract import GeneratedContract from app.models.knowledge_base import KnowledgeBase @@ -91,6 +92,154 @@ class StubTenantService: "upgrade_action": {"href": "/pricing"}, } + def list_commercial_orders(self, current_user, limit=50): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return [ + { + "id": "commercial-order-1", + "tenant_id": current_user["tenant_id"], + "requested_by": current_user["id"], + "order_no": "ORD-202605180001", + "order_type": "subscription", + "status": "pending_payment", + "plan_code": "team", + "plan_name": "团队版", + "billing_cycle": "monthly", + "amount_cents": 199900, + "currency": "CNY", + "payment_provider": "offline", + "invoice_status": "requested", + "contact_name": "demo", + "contact_phone": "13800000000", + "contact_email": "demo@example.com", + "note": "upgrade", + "review_quota_monthly": 800, + "generation_quota_monthly": 500, + "knowledge_base_quota": 5, + "storage_quota_mb": 10240, + "max_users": 20, + "max_storage_mb": 10240, + "paid_at": None, + "activated_at": None, + "canceled_at": None, + "created_at": "2026-05-18T00:00:00+00:00", + "updated_at": "2026-05-18T00:00:00+00:00", + } + ][:limit] + + def create_commercial_order(self, current_user, **kwargs): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "id": "commercial-order-created", + "tenant_id": current_user["tenant_id"], + "requested_by": current_user["id"], + "order_no": "ORD-202605180002", + "order_type": kwargs["order_type"], + "status": "pending_payment", + "plan_code": kwargs["plan_code"], + "plan_name": kwargs.get("plan_name") or "团队版", + "billing_cycle": kwargs["billing_cycle"], + "amount_cents": kwargs["amount_cents"], + "currency": kwargs["currency"], + "payment_provider": kwargs["payment_provider"], + "invoice_status": "requested" if kwargs["invoice_required"] else "not_requested", + "contact_name": kwargs.get("contact_name"), + "contact_phone": kwargs.get("contact_phone"), + "contact_email": kwargs.get("contact_email"), + "note": kwargs.get("note"), + "review_quota_monthly": kwargs.get("review_quota_monthly"), + "generation_quota_monthly": kwargs.get("generation_quota_monthly"), + "knowledge_base_quota": kwargs.get("knowledge_base_quota"), + "storage_quota_mb": kwargs.get("storage_quota_mb"), + "max_users": kwargs.get("max_users"), + "max_storage_mb": kwargs.get("max_storage_mb"), + "paid_at": None, + "activated_at": None, + "canceled_at": None, + "created_at": "2026-05-18T00:00:00+00:00", + "updated_at": "2026-05-18T00:00:00+00:00", + } + + def activate_commercial_order(self, current_user, order_id, payment_provider="offline"): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "order": { + "id": order_id, + "tenant_id": current_user["tenant_id"], + "requested_by": current_user["id"], + "order_no": "ORD-202605180002", + "order_type": "subscription", + "status": "activated", + "plan_code": "team", + "plan_name": "团队版", + "billing_cycle": "monthly", + "amount_cents": 199900, + "currency": "CNY", + "payment_provider": payment_provider, + "invoice_status": "requested", + "contact_name": "demo", + "contact_phone": "13800000000", + "contact_email": "demo@example.com", + "note": "paid", + "review_quota_monthly": 800, + "generation_quota_monthly": 500, + "knowledge_base_quota": 5, + "storage_quota_mb": 10240, + "max_users": 20, + "max_storage_mb": 10240, + "paid_at": "2026-05-18T00:00:00+00:00", + "activated_at": "2026-05-18T00:00:00+00:00", + "canceled_at": None, + "created_at": "2026-05-18T00:00:00+00:00", + "updated_at": "2026-05-18T00:00:00+00:00", + }, + "tenant": { + "id": current_user["tenant_id"], + "name": "demo", + "code": "demo", + "status": "active", + "plan_name": "团队版", + "plan_code": "team", + "billing_status": "active", + "trial_started_at": None, + "trial_ends_at": None, + "review_quota_monthly": 800, + "generation_quota_monthly": 500, + "knowledge_base_quota": 5, + "storage_quota_mb": 10240, + "max_users": 20, + "max_storage_mb": 10240, + "created_at": "2026-05-18T00:00:00+00:00", + "updated_at": "2026-05-18T00:00:00+00:00", + }, + } + + def update_current_billing_plan(self, current_user, **kwargs): + if current_user["role"] != "admin": + raise HTTPException(status_code=403, detail="forbidden") + return { + "id": current_user["tenant_id"], + "name": "demo", + "code": "demo", + "status": "active", + "plan_name": kwargs["plan_name"], + "plan_code": kwargs["plan_code"], + "billing_status": kwargs["billing_status"], + "trial_started_at": None, + "trial_ends_at": kwargs["trial_ends_at"].isoformat() if kwargs.get("trial_ends_at") else None, + "review_quota_monthly": kwargs.get("review_quota_monthly"), + "generation_quota_monthly": kwargs.get("generation_quota_monthly"), + "knowledge_base_quota": kwargs.get("knowledge_base_quota"), + "storage_quota_mb": kwargs.get("storage_quota_mb"), + "max_users": kwargs.get("max_users"), + "max_storage_mb": kwargs.get("max_storage_mb"), + "created_at": "2026-05-18T00:00:00+00:00", + "updated_at": "2026-05-18T00:00:00+00:00", + } + class TenantSettingsApiTestCase(unittest.TestCase): def setUp(self): @@ -128,6 +277,96 @@ class TenantSettingsApiTestCase(unittest.TestCase): self.assertEqual(body["plan"]["code"], "trial") self.assertEqual(body["metrics"][0]["code"], "reviews") + def test_admin_can_list_commercial_orders(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.get("/api/v1/tenant-settings/billing/orders") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["data"][0]["order_no"], "ORD-202605180001") + + def test_admin_can_create_commercial_order(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.post( + "/api/v1/tenant-settings/billing/orders", + data={ + "order_type": "subscription", + "plan_code": "team", + "plan_name": "团队版", + "billing_cycle": "monthly", + "amount_cents": "199900", + "payment_provider": "offline", + "invoice_required": "true", + "review_quota_monthly": "800", + "generation_quota_monthly": "500", + "knowledge_base_quota": "5", + "storage_quota_mb": "10240", + "max_users": "20", + "max_storage_mb": "10240", + }, + ) + + self.assertEqual(response.status_code, 200) + body = response.json()["data"] + self.assertEqual(body["status"], "pending_payment") + self.assertEqual(body["invoice_status"], "requested") + self.assertEqual(self.audit_log_service.events[0]["action"], "create_commercial_order") + + def test_admin_can_activate_commercial_order(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.post( + "/api/v1/tenant-settings/billing/orders/commercial-order-created/activate", + data={"payment_provider": "bank_transfer"}, + ) + + self.assertEqual(response.status_code, 200) + body = response.json()["data"] + self.assertEqual(body["order"]["status"], "activated") + self.assertEqual(body["tenant"]["billing_status"], "active") + self.assertEqual(self.audit_log_service.events[0]["action"], "activate_commercial_order") + + def test_admin_can_update_billing_plan(self): + app.dependency_overrides[require_current_user] = lambda: { + "id": "user-admin", + "tenant_id": "tenant-demo-001", + "role": "admin", + } + + response = self.client.put( + "/api/v1/tenant-settings/billing/plan", + data={ + "plan_code": "professional", + "plan_name": "专业版", + "billing_status": "active", + "review_quota_monthly": "4000", + "generation_quota_monthly": "2000", + "knowledge_base_quota": "20", + "storage_quota_mb": "51200", + "max_users": "80", + "max_storage_mb": "51200", + }, + ) + + self.assertEqual(response.status_code, 200) + body = response.json()["data"] + self.assertEqual(body["plan_code"], "professional") + self.assertEqual(body["billing_status"], "active") + self.assertEqual(self.audit_log_service.events[0]["action"], "update_commercial_billing_plan") + def test_admin_can_update_tenant_settings(self): app.dependency_overrides[require_current_user] = lambda: { "id": "user-admin", @@ -381,6 +620,39 @@ class TenantSettingsApiTestCase(unittest.TestCase): session.close() engine.dispose() + def test_commercial_order_activation_updates_tenant_plan_and_quota(self): + engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine, future=True)() + try: + service = TenantService(session) + service.ensure_default_tenant() + current_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"} + + order = service.create_commercial_order( + current_user=current_user, + order_type="subscription", + plan_code="team", + billing_cycle="monthly", + amount_cents=199900, + payment_provider="offline", + invoice_required=True, + ) + result = service.activate_commercial_order(current_user, order["id"], payment_provider="bank_transfer") + + self.assertEqual(result["order"]["status"], "activated") + self.assertEqual(result["order"]["payment_provider"], "bank_transfer") + self.assertEqual(result["tenant"]["plan_code"], "team") + self.assertEqual(result["tenant"]["billing_status"], "active") + self.assertEqual(result["tenant"]["review_quota_monthly"], 800) + self.assertEqual(result["tenant"]["generation_quota_monthly"], 500) + self.assertEqual(result["tenant"]["knowledge_base_quota"], 5) + self.assertEqual(result["tenant"]["storage_quota_mb"], 10240) + self.assertEqual(session.query(CommercialOrder).count(), 1) + finally: + session.close() + engine.dispose() + if __name__ == "__main__": unittest.main() diff --git a/frontend/src/app/billing/page.tsx b/frontend/src/app/billing/page.tsx index 48222bce..01f4dff0 100644 --- a/frontend/src/app/billing/page.tsx +++ b/frontend/src/app/billing/page.tsx @@ -1,16 +1,49 @@ "use client"; -import { Alert, Button, Card, Progress, Space, Statistic, Table, Tag, Typography, message } from "antd"; +import { Alert, Button, Card, Form, Input, InputNumber, Modal, Progress, Select, Space, Statistic, Table, Tag, Typography, message } from "antd"; import type { ColumnsType } from "antd/es/table"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; -import { getTenantBillingSummary } from "@/services/tenant-settings"; +import { commercialPlans } from "@/lib/commercial-plans"; +import { + activateCommercialOrder, + createCommercialOrder, + getCommercialOrders, + getTenantBillingSummary, + updateTenantBillingPlan, +} from "@/services/tenant-settings"; import { useAuthStore } from "@/stores/auth.store"; -import type { TenantBillingMetric, TenantBillingSummary } from "@/types/api"; +import type { CommercialOrderItem, TenantBillingMetric, TenantBillingSummary } from "@/types/api"; const { Title, Text } = Typography; +type OrderFormValues = { + order_type: string; + plan_code: string; + plan_name?: string; + billing_cycle: string; + amount_yuan?: number; + invoice_required?: boolean; + contact_name?: string; + contact_phone?: string; + contact_email?: string; + note?: string; +}; + +type BillingPlanFormValues = { + plan_code: string; + plan_name: string; + billing_status: string; + trial_ends_at?: string; + review_quota_monthly?: number; + generation_quota_monthly?: number; + knowledge_base_quota?: number; + storage_quota_mb?: number; + max_users?: number; + max_storage_mb?: number; +}; + const statusMeta: Record = { ok: { color: "green", label: "正常" }, warning: { color: "orange", label: "接近上限" }, @@ -18,6 +51,20 @@ const statusMeta: Record = { + pending_payment: { color: "orange", label: "待收款" }, + activated: { color: "green", label: "已生效" }, + canceled: { color: "default", label: "已取消" }, +}; + +const invoiceStatusMeta: Record = { + not_requested: { color: "default", label: "不开发票" }, + requested: { color: "blue", label: "待开票" }, + issued: { color: "green", label: "已开票" }, +}; + +const planOptions = commercialPlans.map((plan) => ({ value: plan.key, label: plan.name })); + function formatDateTime(value?: string | null) { if (!value) { return "-"; @@ -33,6 +80,10 @@ function formatUsage(metric: TenantBillingMetric) { return `${used} / ${metric.quota} ${metric.unit}`; } +function formatMoney(amountCents: number, currency: string) { + return `${currency} ${(amountCents / 100).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + function resolveProgressStatus(status: TenantBillingMetric["status"]): "normal" | "active" | "exception" { if (status === "exceeded") { return "exception"; @@ -43,36 +94,74 @@ function resolveProgressStatus(status: TenantBillingMetric["status"]): "normal" return "normal"; } +function getErrorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +function getPlanName(planCode: string) { + return commercialPlans.find((plan) => plan.key === planCode)?.name ?? planCode; +} + export default function BillingPage() { const [messageApi, contextHolder] = message.useMessage(); const currentUser = useAuthStore((state) => state.currentUser); + const [orderForm] = Form.useForm(); + const [planForm] = Form.useForm(); const [loading, setLoading] = useState(true); const [summary, setSummary] = useState(null); + const [orders, setOrders] = useState([]); + const [submittingOrder, setSubmittingOrder] = useState(false); + const [submittingPlan, setSubmittingPlan] = useState(false); + const [activatingOrderId, setActivatingOrderId] = useState(null); - useEffect(() => { - const loadSummary = async () => { - if (currentUser?.role !== "admin") { - setLoading(false); + const loadBillingData = useCallback(async () => { + if (currentUser?.role !== "admin") { + setLoading(false); + return; + } + + setLoading(true); + try { + const [summaryResult, ordersResult] = await Promise.all([getTenantBillingSummary(), getCommercialOrders()]); + if (summaryResult.code !== 0) { + messageApi.error(summaryResult.message || "用量统计加载失败"); return; } - - try { - const result = await getTenantBillingSummary(); - if (result.code !== 0) { - messageApi.error(result.message || "用量统计加载失败"); - return; - } - setSummary(result.data); - } catch { - messageApi.error("用量统计加载失败,请稍后重试"); - } finally { - setLoading(false); + if (ordersResult.code !== 0) { + messageApi.error(ordersResult.message || "商业订单加载失败"); + return; } - }; - - void loadSummary(); + setSummary(summaryResult.data); + setOrders(ordersResult.data); + } catch (error: unknown) { + messageApi.error(getErrorMessage(error, "商业化数据加载失败,请稍后重试")); + } finally { + setLoading(false); + } }, [currentUser?.role, messageApi]); + useEffect(() => { + void loadBillingData(); + }, [loadBillingData]); + + useEffect(() => { + if (!summary) { + return; + } + planForm.setFieldsValue({ + plan_code: summary.plan.code, + plan_name: summary.plan.name, + billing_status: summary.plan.billing_status, + trial_ends_at: summary.plan.trial_ends_at ?? undefined, + review_quota_monthly: summary.tenant.review_quota_monthly ?? undefined, + generation_quota_monthly: summary.tenant.generation_quota_monthly ?? undefined, + knowledge_base_quota: summary.tenant.knowledge_base_quota ?? undefined, + storage_quota_mb: summary.tenant.storage_quota_mb ?? undefined, + max_users: summary.tenant.max_users, + max_storage_mb: summary.tenant.max_storage_mb, + }); + }, [planForm, summary]); + const columns = useMemo>( () => [ { @@ -107,6 +196,151 @@ export default function BillingPage() { [], ); + const handleActivateOrder = useCallback( + async (order: CommercialOrderItem) => { + Modal.confirm({ + title: "确认收款并同步套餐?", + content: `订单 ${order.order_no} 将被标记为已收款,并把租户套餐切换为 ${order.plan_name}。`, + okText: "确认生效", + cancelText: "取消", + onOk: async () => { + setActivatingOrderId(order.id); + try { + const result = await activateCommercialOrder(order.id, "bank_transfer"); + if (result.code !== 0) { + messageApi.error(result.message || "订单生效失败"); + return; + } + messageApi.success("订单已生效,套餐额度已同步"); + await loadBillingData(); + } catch (error: unknown) { + messageApi.error(getErrorMessage(error, "订单生效失败,请稍后重试")); + } finally { + setActivatingOrderId(null); + } + }, + }); + }, + [loadBillingData, messageApi], + ); + + const orderColumns = useMemo>( + () => [ + { + title: "订单号", + dataIndex: "order_no", + key: "order_no", + width: 180, + }, + { + title: "套餐", + key: "plan", + render: (_, order) => ( + + {order.plan_name} + {order.plan_code} + + ), + }, + { + title: "金额", + key: "amount", + render: (_, order) => formatMoney(order.amount_cents, order.currency), + }, + { + title: "状态", + key: "status", + render: (_, order) => { + const meta = orderStatusMeta[order.status] ?? { color: "default", label: order.status }; + return {meta.label}; + }, + }, + { + title: "发票", + key: "invoice", + render: (_, order) => { + const meta = invoiceStatusMeta[order.invoice_status] ?? { color: "default", label: order.invoice_status }; + return {meta.label}; + }, + }, + { + title: "创建时间", + key: "created_at", + render: (_, order) => formatDateTime(order.created_at), + }, + { + title: "操作", + key: "actions", + fixed: "right", + render: (_, order) => + order.status === "pending_payment" ? ( + + ) : ( + 已处理 + ), + }, + ], + [activatingOrderId, handleActivateOrder], + ); + + const handleOrderPlanChange = (planCode: string) => { + orderForm.setFieldsValue({ plan_name: getPlanName(planCode) }); + }; + + const handlePlanCodeChange = (planCode: string) => { + planForm.setFieldsValue({ plan_name: getPlanName(planCode) }); + }; + + const handleCreateOrder = async (values: OrderFormValues) => { + setSubmittingOrder(true); + try { + const result = await createCommercialOrder({ + order_type: values.order_type, + plan_code: values.plan_code, + plan_name: values.plan_name || getPlanName(values.plan_code), + billing_cycle: values.billing_cycle, + amount_cents: Math.round((values.amount_yuan ?? 0) * 100), + currency: "CNY", + payment_provider: "offline", + invoice_required: Boolean(values.invoice_required), + contact_name: values.contact_name, + contact_phone: values.contact_phone, + contact_email: values.contact_email, + note: values.note, + }); + if (result.code !== 0) { + messageApi.error(result.message || "订单创建失败"); + return; + } + messageApi.success("购买意向/订单已创建"); + orderForm.resetFields(); + await loadBillingData(); + } catch (error: unknown) { + messageApi.error(getErrorMessage(error, "订单创建失败,请稍后重试")); + } finally { + setSubmittingOrder(false); + } + }; + + const handleUpdatePlan = async (values: BillingPlanFormValues) => { + setSubmittingPlan(true); + try { + const result = await updateTenantBillingPlan(values); + if (result.code !== 0) { + messageApi.error(result.message || "套餐额度调整失败"); + return; + } + messageApi.success("套餐额度已更新"); + await loadBillingData(); + } catch (error: unknown) { + messageApi.error(getErrorMessage(error, "套餐额度调整失败,请稍后重试")); + } finally { + setSubmittingPlan(false); + } + }; + if (currentUser?.role !== "admin") { return (
@@ -131,12 +365,12 @@ export default function BillingPage() { return (
{contextHolder} - +
Usage & Billing - 用量统计、试用到期与超额提示 - 管理员可以核对本月真实使用量、试用剩余时间和当前套餐额度。 + 用量统计、试用到期、订单和套餐生效 + 先把购买意向、线下收款确认、发票状态和套餐额度同步到系统,后续真实支付回调沿用同一条订单链路。
Billing Gate @@ -147,7 +381,9 @@ export default function BillingPage() {
  • 套餐:{summary.plan.name}
  • 计费状态:{summary.plan.billing_status}
  • -
  • 统计周期:{formatDateTime(summary.period.start_at)} 至 {formatDateTime(summary.period.end_at)}
  • +
  • + 统计周期:{formatDateTime(summary.period.start_at)} 至 {formatDateTime(summary.period.end_at)} +
) : null} {summary ? ( @@ -183,10 +419,135 @@ export default function BillingPage() {
- + order.status === "pending_payment").length} suffix="笔" /> +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + + + +