feat(commercial): register workspace product and pricing

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-09-12 16:30:48 +03:30
parent d0c1d296b2
commit 0135c2137e
9 changed files with 1167 additions and 164 deletions

View File

@ -228,7 +228,8 @@ class AssessmentEngine:
"options": [
{"value": "online_sales", "label": "فروش آنلاین"},
{"value": "delivery", "label": "پیک"},
{"value": "crm", "label": "CRM"},
{"value": "crm", "label": "مدیریت ارتباط با مشتری"},
{"value": "workspace", "label": "فضای کاری"},
{"value": "loyalty", "label": "وفاداری"},
{"value": "website", "label": "وب‌سایت"},
],

View File

@ -301,10 +301,10 @@ class LicenseResolver:
state = (await self.db.execute(stmt)).scalar_one_or_none()
if state is None:
steps = [
{"step_key": "workspace", "label": "Workspace", "status": "done"},
{"step_key": "bundle", "label": "Bundle", "status": "done"},
{"step_key": "trial", "label": "Trial", "status": "done"},
{"step_key": "dashboard", "label": "Dashboard", "status": "pending", "href": "/dashboard"},
{"step_key": "workspace", "label": "فضای کاری", "status": "done"},
{"step_key": "bundle", "label": "بسته خدمات", "status": "done"},
{"step_key": "trial", "label": "دوره آزمایشی", "status": "done"},
{"step_key": "dashboard", "label": "داشبورد", "status": "pending", "href": "/dashboard"},
]
state = CommercialActivationState(
tenant_id=tenant_id,
@ -312,9 +312,9 @@ class LicenseResolver:
completion_percent=75,
steps=steps,
checklist=[
{"id": "workspace", "label": "Workspace", "done": True},
{"id": "bundle", "label": "انتخاب باندل", "done": True},
{"id": "billing", "label": "Billing", "done": False, "href": "/billing"},
{"id": "workspace", "label": "فضای کاری", "done": True},
{"id": "bundle", "label": "انتخاب بسته", "done": True},
{"id": "billing", "label": "صورتحساب", "done": False, "href": "/billing"},
{"id": "domain", "label": "دامنه", "done": False, "href": "/domains"},
],
)

View File

@ -341,3 +341,11 @@ async def test_policy_evaluate(client):
)
assert resp.status_code == 200
assert resp.json()["custom_domain_allowed"] is True
def test_workspace_commercial_registry_catalog():
from pathlib import Path
import runpy
script = Path(__file__).resolve().parents[2] / "scripts" / "validate_commercial_workspace_registry.py"
runpy.run_path(str(script), run_name="__validate_workspace_registry__")

View File

@ -0,0 +1,324 @@
# Canonical machine-readable Business Bundle catalog.
# Source of truth with docs/reference/business-bundles.md
# Frontend MUST be generated from this file in a future phase — do not treat React as authoritative.
schema_version: 1
contract_version: business-bundle.v1
updated: "2026-07-28"
owner: core-platform
bundles:
- bundle_code: bundle.restaurant.starter
display_name: Restaurant Starter
status: active
version_code: "2026.07.1"
business_types: [restaurant]
services:
- { service_identifier: hospitality, required: true }
- { service_identifier: payment, required: true }
- { service_identifier: experience, required: true }
- { service_identifier: delivery, required: false }
- { service_identifier: loyalty, required: false }
- { service_identifier: communication, required: false }
- { service_identifier: accounting, required: false }
pricing_refs:
- { pricing_item_code: price.restaurant.starter.monthly, role: primary }
- { pricing_item_code: price.restaurant.starter.yearly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.restaurant.growth
display_name: Restaurant Growth
status: active
version_code: "2026.07.1"
business_types: [restaurant]
services:
- { service_identifier: hospitality, required: true }
- { service_identifier: payment, required: true }
- { service_identifier: experience, required: true }
- { service_identifier: delivery, required: true }
- { service_identifier: loyalty, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: accounting, required: false }
- { service_identifier: crm, required: false }
pricing_refs:
- { pricing_item_code: price.restaurant.growth.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.cafe.starter
display_name: Cafe Starter
status: active
version_code: "2026.07.1"
business_types: [cafe]
services:
- { service_identifier: hospitality, required: true }
- { service_identifier: payment, required: true }
- { service_identifier: experience, required: true }
- { service_identifier: loyalty, required: false }
- { service_identifier: communication, required: false }
pricing_refs:
- { pricing_item_code: price.cafe.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.clinic.starter
display_name: Clinic Starter
status: active
version_code: "2026.07.1"
business_types: [clinic]
services:
- { service_identifier: experience, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: accounting, required: true }
- { service_identifier: payment, required: false }
- { service_identifier: crm, required: false }
pricing_refs:
- { pricing_item_code: price.clinic.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.beauty.starter
display_name: Beauty Salon Starter
status: active
version_code: "2026.07.1"
business_types: [beauty_salon]
services:
- { service_identifier: experience, required: true }
- { service_identifier: crm, required: true }
- { service_identifier: payment, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: loyalty, required: false }
pricing_refs:
- { pricing_item_code: price.beauty.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.sports.starter
display_name: Sports Center Starter
status: active
version_code: "2026.07.1"
business_types: [sports_center]
services:
- { service_identifier: sports-center, required: true }
- { service_identifier: payment, required: true }
- { service_identifier: loyalty, required: true }
- { service_identifier: communication, required: false }
- { service_identifier: accounting, required: false }
pricing_refs:
- { pricing_item_code: price.sports.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.retail.starter
display_name: Retail Starter
status: active
version_code: "2026.07.1"
business_types: [retail_shop]
services:
- { service_identifier: experience, required: true }
- { service_identifier: payment, required: true }
- { service_identifier: delivery, required: false }
- { service_identifier: loyalty, required: false }
- { service_identifier: accounting, required: false }
pricing_refs:
- { pricing_item_code: price.retail.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.marketplace.seller
display_name: Marketplace Seller
status: draft
version_code: "2026.07.1"
business_types: [marketplace_seller]
services:
- { service_identifier: payment, required: true }
- { service_identifier: accounting, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: marketplace, required: false }
pricing_refs:
- { pricing_item_code: price.marketplace.seller.monthly, role: primary }
visibility: { public_catalog: false, admin_only: true, hidden: false }
lifecycle: { sellable: false, renewable: false, grandfathered: false }
notes: Marketplace product not implemented in commercial-foundation wave.
- bundle_code: bundle.education.starter
display_name: Education Starter
status: active
version_code: "2026.07.1"
business_types: [educational_center]
services:
- { service_identifier: experience, required: true }
- { service_identifier: crm, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: payment, required: false }
- { service_identifier: accounting, required: false }
pricing_refs:
- { pricing_item_code: price.education.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.corporate.crm
display_name: Corporate CRM
status: active
version_code: "2026.07.1"
business_types: [corporate_company]
services:
- { service_identifier: crm, required: true }
- { service_identifier: accounting, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: identity-access, required: false }
- { service_identifier: experience, required: false }
pricing_refs:
- { pricing_item_code: price.corporate.crm.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.professional.starter
display_name: Professional Services
status: active
version_code: "2026.07.1"
business_types: [professional_services]
services:
- { service_identifier: crm, required: true }
- { service_identifier: accounting, required: true }
- { service_identifier: experience, required: true }
- { service_identifier: communication, required: false }
- { service_identifier: payment, required: false }
pricing_refs:
- { pricing_item_code: price.professional.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.freelancer.starter
display_name: Freelancer
status: active
version_code: "2026.07.1"
business_types: [freelancer]
services:
- { service_identifier: experience, required: true }
- { service_identifier: payment, required: true }
- { service_identifier: accounting, required: false }
- { service_identifier: communication, required: false }
pricing_refs:
- { pricing_item_code: price.freelancer.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.manufacturer.starter
display_name: Manufacturer
status: active
version_code: "2026.07.1"
business_types: [manufacturer]
services:
- { service_identifier: crm, required: true }
- { service_identifier: accounting, required: true }
- { service_identifier: communication, required: false }
pricing_refs:
- { pricing_item_code: price.manufacturer.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.wholesale.starter
display_name: Wholesale
status: active
version_code: "2026.07.1"
business_types: [wholesale]
services:
- { service_identifier: crm, required: true }
- { service_identifier: accounting, required: true }
- { service_identifier: payment, required: true }
- { service_identifier: delivery, required: false }
pricing_refs:
- { pricing_item_code: price.wholesale.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.real_estate.starter
display_name: Real Estate
status: active
version_code: "2026.07.1"
business_types: [real_estate]
services:
- { service_identifier: experience, required: true }
- { service_identifier: crm, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: payment, required: false }
pricing_refs:
- { pricing_item_code: price.real_estate.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.government.starter
display_name: Government
status: active
version_code: "2026.07.1"
business_types: [government]
services:
- { service_identifier: experience, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: accounting, required: true }
pricing_refs:
- { pricing_item_code: price.enterprise.custom, role: enterprise_quote }
visibility: { public_catalog: false, admin_only: true, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.ngo.starter
display_name: NGO
status: active
version_code: "2026.07.1"
business_types: [ngo]
services:
- { service_identifier: experience, required: true }
- { service_identifier: crm, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: accounting, required: false }
pricing_refs:
- { pricing_item_code: price.ngo.starter.monthly, role: primary }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }
- bundle_code: bundle.platform.trial
display_name: Platform Trial
status: active
version_code: "2026.07.1"
business_types: [custom]
services:
- { service_identifier: experience, required: true }
- { service_identifier: payment, required: false }
- { service_identifier: communication, required: false }
pricing_refs:
- { pricing_item_code: price.platform.trial.14d, role: trial }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: false, grandfathered: false }
- bundle_code: bundle.workspace.starter
display_name: فضای کاری — نسخه شروع
status: active
version_code: "2026.07.1"
business_types:
- advertising_agency
- content_team
- software_company
- marketing_company
- team_freelancer
services:
- { service_identifier: workspace, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: crm, required: true }
- { service_identifier: accounting, required: true }
capability_codes:
- workspace.projects
- workspace.tasks
- workspace.kanban
- workspace.calendar
- workspace.content
- workspace.approvals
- workspace.comments
- workspace.mentions
- workspace.analytics
pricing_refs:
- { pricing_item_code: price.workspace.starter.monthly, role: primary }
- { pricing_item_code: price.workspace.business.monthly, role: upgrade }
- { pricing_item_code: price.workspace.enterprise, role: enterprise }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }

View File

@ -1,16 +1,23 @@
#!/usr/bin/env python3
"""Seed Commercial Runtime registries from docs/reference catalogs (idempotent)."""
"""Seed Commercial Runtime registries from bundled catalogs (idempotent, Docker-safe).
Canonical QA seed for every builtin registry kind. Bundle/pricing catalog is
loaded from scripts/data/ (mounted with core-service) with docs/ as optional
fallback — docs/ is NOT mounted into the container by default.
"""
from __future__ import annotations
import asyncio
import re
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
import yaml
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
# Allow running from the repo checkout or from the mounted /app directory in Docker.
SCRIPT_PATH = Path(__file__).resolve()
CORE_SERVICE_ROOT = SCRIPT_PATH.parents[1]
ROOT = next((parent for parent in SCRIPT_PATH.parents if (parent / "docs").exists()), CORE_SERVICE_ROOT)
@ -21,6 +28,9 @@ from app.commercial.service import CommercialRegistryService
from app.core.config import settings
from app.models.commercial import CommercialRegistryKind, CommercialRegistryObject
VERSION = "2026.07.1"
SEED_DATA_DIR = SCRIPT_PATH.parent / "data"
async def ensure_kinds(session: AsyncSession) -> None:
svc = CommercialRegistryService(session)
@ -49,7 +59,7 @@ async def upsert_object(
display_name: str,
payload: dict,
description: str | None = None,
version_code: str = "2026.07.1",
version_code: str = VERSION,
) -> bool:
existing = (
await session.execute(
@ -70,9 +80,6 @@ async def upsert_object(
existing.is_deleted = False
await session.flush()
return False
from datetime import datetime, timezone
import re
import uuid
slug = re.sub(r"[^a-z0-9]+", "-", code.lower()).strip("-")
session.add(
@ -96,73 +103,256 @@ async def upsert_object(
return True
def _load_bundles_catalog() -> list[dict]:
candidates = [
SEED_DATA_DIR / "business-bundles.catalog.yaml",
ROOT / "docs" / "reference" / "business-bundles.catalog.yaml",
CORE_SERVICE_ROOT / "docs" / "reference" / "business-bundles.catalog.yaml",
]
for path in candidates:
if path.exists():
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
bundles = data.get("bundles") or []
print(f"loaded bundles catalog: {path} ({len(bundles)} bundles)")
return bundles
print("WARNING: business-bundles.catalog.yaml not found; bundles/pricing will be empty")
return []
def _product_code(service_identifier: str) -> str:
return {
"sports-center": "sports_center",
"identity-access": "identity-access",
}.get(service_identifier, service_identifier)
def _capability_codes_for_products(product_codes: list[str]) -> list[str]:
mapping = {
"hospitality": [
"hospitality.digital_menu",
"hospitality.pos_lite",
"hospitality.kitchen",
"hospitality.reservation",
"hospitality.analytics",
],
"payment": ["payments", "wallet"],
"experience": ["forms", "surveys", "qr", "short-links"],
"delivery": ["delivery"],
"loyalty": ["loyalty"],
"communication": ["sms", "email", "notifications"],
"crm": ["crm"],
"accounting": ["analytics"],
"sports_center": ["appointments", "tickets"],
"workspace": [
"workspace.projects",
"workspace.tasks",
"workspace.kanban",
"workspace.calendar",
"workspace.content",
"workspace.approvals",
"workspace.comments",
"workspace.mentions",
"workspace.analytics",
],
}
out: list[str] = []
for p in product_codes:
for c in mapping.get(p, []):
if c not in out:
out.append(c)
return out
def _pricing_amount(pcode: str) -> int | None:
if "enterprise" in pcode or "custom" in pcode:
return None
if "trial" in pcode:
return 0
if "workspace.business" in pcode:
return 1_990_000
if "yearly" in pcode:
return 9_900_000
if "growth" in pcode:
return 1_490_000
return 990_000
WORKSPACE_PRICING_NAMES = {
"price.workspace.starter.monthly": "پلن فضای کاری — شروع",
"price.workspace.business.monthly": "پلن فضای کاری — کسب‌وکار",
"price.workspace.enterprise": "پلن فضای کاری — سازمانی",
}
async def seed(session: AsyncSession) -> dict[str, int]:
await ensure_kinds(session)
counts = {"kinds": len(BUILTIN_REGISTRY_KINDS), "created": 0}
counts: dict[str, int] = {"kinds": len(BUILTIN_REGISTRY_KINDS), "created": 0, "updated": 0}
catalog_path = ROOT / "docs" / "reference" / "business-bundles.catalog.yaml"
data = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) if catalog_path.exists() else {}
bundles = data.get("bundles") or []
async def put(**kwargs) -> None:
created = await upsert_object(session, **kwargs)
counts["created" if created else "updated"] += 1
# Products derived from services referenced by bundles (open-ended)
# ── Languages / Currencies / Regions / Industries ─────────────────────
for code, name in [("fa-IR", "فارسی"), ("en-US", "English")]:
await put(
kind="languages",
code=code,
display_name=name,
payload={"language_code": code, "display_name": name, "dir": "rtl" if code.startswith("fa") else "ltr"},
)
await put(
kind="currencies",
code="IRR",
display_name="ریال ایران",
payload={"currency_code": "IRR", "symbol": "﷼", "decimals": 0},
)
await put(
kind="regions",
code="IR",
display_name="ایران",
payload={"region_code": "IR", "country_code": "IR", "default_currency": "IRR", "default_locale": "fa-IR"},
)
for code, name in [
("food_beverage", "غذا و نوشیدنی"),
("healthcare", "سلامت"),
("beauty", "زیبایی"),
("sports", "ورزش"),
("retail", "خرده\u200cفروشی"),
("professional", "خدمات حرفه\u200cای"),
("education", "آموزش"),
("government", "دولت و سازمان"),
]:
await put(
kind="industries",
code=code,
display_name=name,
payload={"industry_code": code, "display_name": name, "active": True},
)
# ── Products ──────────────────────────────────────────────────────────
products = {
"hospitality": ("Torbat Food", "/hospitality", "hospitality"),
"payment": ("Torbat Pay", "/payment/hub", "payment"),
"experience": ("Torbat Pages", "/experience/hub", "experience"),
"delivery": ("Torbat Driver", "/delivery/hub", "delivery"),
"loyalty": ("Torbat Loyalty", "/loyalty/hub", "loyalty"),
"communication": ("Torbat Communication", "/communication", "communication"),
"accounting": ("Torbat Accounting", "/accounting", "accounting"),
"crm": ("Torbat CRM", "/crm/hub", "crm"),
"sports_center": ("Torbat Sports", "/sports", "sports_center"),
"identity-access": ("Torbat Identity", "/dashboard", "identity"),
"hospitality": ("Torbat Food", "/hospitality", "hospitality", "food_beverage"),
"payment": ("Torbat Pay", "/payment/hub", "payment", "retail"),
"experience": ("Torbat Pages", "/experience/hub", "experience", "professional"),
"delivery": ("Torbat Driver", "/delivery/hub", "delivery", "retail"),
"loyalty": ("Torbat Loyalty", "/loyalty/hub", "loyalty", "retail"),
"communication": ("Torbat Communication", "/communication", "communication", "professional"),
"accounting": ("Torbat Accounting", "/accounting", "accounting", "professional"),
"crm": ("Torbat CRM", "/crm/hub", "crm", "professional"),
"sports_center": ("Torbat Sports", "/sports", "sports_center", "sports"),
"identity-access": ("Torbat Identity", "/dashboard", "identity", "professional"),
# Planned shell so draft bundle.marketplace.seller refs resolve (zero-code registry only).
"marketplace": ("Torbat Marketplace", "/marketplace", "marketplace", "retail"),
"workspace": ("فضای کاری", "/workspace", "workspace", "professional"),
}
for code, (name, route, service_key) in products.items():
if await upsert_object(
session,
for code, (name, route, service_key, industry) in products.items():
planned = code == "marketplace"
is_workspace = code == "workspace"
await put(
kind="products",
code=code,
display_name=name,
description=f"Platform product {name}",
description=(
"مدیریت کار، تیم، پروژه و تولید محتوا"
if is_workspace
else f"Platform product {name}"
),
payload={
"product_code": code,
"default_route": route,
"service_key": service_key,
"category": "platform",
"trial_eligible": True,
"visibility": "public",
"category_code": "platform",
"industry": industry,
"trial_eligible": not planned,
"visibility": "public" if not planned else "admin",
"commercial_status": "planned" if planned else "ga",
"maturity": "concept" if planned else "mature",
"future_product": planned,
"version_code": VERSION,
**(
{
"name_fa": "فضای کاری",
"name_en": "Torbat Workspace",
"icon": "workspace",
"color": "#4F46E5",
"href": "/workspace",
}
if is_workspace
else {}
),
},
):
counts["created"] += 1
)
# ── Capabilities ──────────────────────────────────────────────────────
capabilities = [
("hospitality.digital_menu", "Digital Menu", "hospitality", "content"),
("hospitality.pos_lite", "POS Lite", "hospitality", "commerce"),
("hospitality.kitchen", "Kitchen Display", "hospitality", "ops"),
("hospitality.reservation", "Reservations", "hospitality", "ops"),
("hospitality.analytics", "Hospitality Analytics", "hospitality", "content"),
("payments", "Payments", "payment", "commerce"),
("wallet", "Wallet", "payment", "commerce"),
("forms", "Forms", "experience", "content"),
("surveys", "Surveys", "experience", "content"),
("qr", "QR", "experience", "channel"),
("short-links", "Short Links", "experience", "channel"),
("delivery", "Delivery / Fleet", "delivery", "ops"),
("loyalty", "Loyalty", "loyalty", "engage"),
("sms", "SMS", "communication", "engage"),
("email", "Email", "communication", "engage"),
("notifications", "Notifications", "communication", "engage"),
("crm", "CRM", "crm", "engage"),
("analytics", "Analytics", "accounting", "content"),
("appointments", "Appointments", "sports_center", "ops"),
("tickets", "Ticketing", "sports_center", "ops"),
("workspace.projects", "پروژه‌ها", "workspace", "ops"),
("workspace.tasks", "وظایف", "workspace", "ops"),
("workspace.kanban", "کانبان", "workspace", "ops"),
("workspace.calendar", "تقویم محتوا", "workspace", "content"),
("workspace.content", "مدیریت محتوا", "workspace", "content"),
("workspace.approvals", "تأییدیه‌ها", "workspace", "ops"),
("workspace.comments", "نظرات", "workspace", "engage"),
("workspace.mentions", "منشن", "workspace", "engage"),
("workspace.analytics", "گزارش‌های فضای کاری", "workspace", "content"),
]
for code, name, service_key, category in capabilities:
await put(
kind="capabilities",
code=code,
display_name=name,
payload={
"capability_code": code,
"display_name": name,
"service_key": service_key,
"category": category,
"status": "active",
},
)
# ── Bundles + Pricing + Plans (from catalog) ──────────────────────────
bundles = _load_bundles_catalog()
for b in bundles:
code = b["bundle_code"]
services = b.get("services") or []
product_codes = [s["service_identifier"] for s in services if s.get("required")]
product_codes += [
s["service_identifier"]
for s in services
if not s.get("required") and s["service_identifier"] not in product_codes
]
product_codes = [p.replace("sports-center", "sports_center").replace("identity-access", "identity-access") for p in product_codes]
# Map identity-access product code used in products dict
product_codes = [("identity-access" if p == "identity-access" else p) for p in product_codes]
product_codes: list[str] = []
for s in services:
pc = _product_code(s["service_identifier"])
if pc not in product_codes:
product_codes.append(pc)
# Prefer required first
required = [_product_code(s["service_identifier"]) for s in services if s.get("required")]
optional = [p for p in product_codes if p not in required]
product_codes = required + optional
pricing_refs = [p.get("pricing_item_code") for p in (b.get("pricing_refs") or []) if isinstance(p, dict)]
capability_codes: list[str] = []
if "hospitality" in product_codes:
capability_codes = [
"hospitality.digital_menu",
"hospitality.pos_lite",
"hospitality.kitchen",
"hospitality.reservation",
"hospitality.analytics",
]
if await upsert_object(
session,
capability_codes = list(b.get("capability_codes") or []) or _capability_codes_for_products(
product_codes
)
await put(
kind="bundles",
code=code,
display_name=b.get("display_name") or code,
version_code=str(b.get("version_code") or "2026.07.1"),
version_code=str(b.get("version_code") or VERSION),
payload={
"bundle_code": code,
"business_types": b.get("business_types") or [],
@ -173,36 +363,44 @@ async def seed(session: AsyncSession) -> dict[str, int]:
"visibility": b.get("visibility") or {},
"lifecycle": b.get("lifecycle") or {},
"trial_eligible": True,
"status": b.get("status") or "active",
},
):
counts["created"] += 1
)
for pref in b.get("pricing_refs") or []:
pcode = pref.get("pricing_item_code") if isinstance(pref, dict) else None
if not pcode:
continue
interval = "yearly" if "yearly" in pcode else "monthly"
if await upsert_object(
session,
role = pref.get("role") if isinstance(pref, dict) else "primary"
if "trial" in pcode:
model, interval, trial_days = "trial", "once", 14
elif "enterprise" in pcode or "custom" in pcode:
model, interval, trial_days = "enterprise_quote", "custom", None
elif "yearly" in pcode:
model, interval, trial_days = "subscription", "yearly", 14
else:
model, interval, trial_days = "subscription", "monthly", 14
amount = _pricing_amount(pcode)
await put(
kind="pricing",
code=pcode,
display_name=pcode,
display_name=WORKSPACE_PRICING_NAMES.get(pcode, pcode),
payload={
"pricing_item_code": pcode,
"pricing_model": "subscription",
"pricing_model": model,
"interval": interval,
"currency": "IRR",
"amount_minor": None,
"amount_minor": amount,
"bundle_code": code,
"role": role,
"trial_eligible": True,
"trial_days": 14,
"trial_days": trial_days,
"tax_class_code": "vat.standard",
"region_code": "IR",
"status": "active",
},
):
counts["created"] += 1
# Mirror pricing as plan shell for plan resolver
)
plan_code = pcode.replace("price.", "plan.", 1) if pcode.startswith("price.") else f"plan.{pcode}"
if await upsert_object(
session,
await put(
kind="plans",
code=plan_code,
display_name=plan_code,
@ -211,28 +409,43 @@ async def seed(session: AsyncSession) -> dict[str, int]:
"pricing_item_code": pcode,
"bundle_code": code,
"trial_eligible": True,
"trial_days": 14,
"trial_days": trial_days or 14,
"interval": interval,
"currency": "IRR",
"amount_minor": amount,
"limits": {},
"renewal": {"interval": interval},
"status": "active",
},
):
counts["created"] += 1
)
# ── Business types ────────────────────────────────────────────────────
business_types = [
("restaurant", "Restaurant", "رستوران", ["bundle.restaurant.starter"]),
("cafe", "Cafe", "کافه", ["bundle.cafe.starter"]),
("clinic", "Clinic", "کلینیک", ["bundle.clinic.starter"]),
("beauty_salon", "Beauty Salon", "سالن زیبایی", ["bundle.beauty.starter"]),
("sports_center", "Sports Center", "مرکز ورزشی", ["bundle.sports.starter"]),
("retail_shop", "Retail Shop", "فروشگاه", ["bundle.retail.starter"]),
("corporate_company", "Corporate", "شرکت سازمانی", ["bundle.corporate.crm"]),
("freelancer", "Freelancer", "فریلنسر", ["bundle.freelancer.starter"]),
("restaurant", "Restaurant", "رستوران", ["bundle.restaurant.starter"], "food_beverage"),
("cafe", "Cafe", "کافه", ["bundle.cafe.starter"], "food_beverage"),
("clinic", "Clinic", "کلینیک", ["bundle.clinic.starter"], "healthcare"),
("beauty_salon", "Beauty Salon", "سالن زیبایی", ["bundle.beauty.starter"], "beauty"),
("sports_center", "Sports Center", "مرکز ورزشی", ["bundle.sports.starter"], "sports"),
("retail_shop", "Retail Shop", "فروشگاه", ["bundle.retail.starter"], "retail"),
("marketplace_seller", "Marketplace Seller", "فروشنده مارکت‌پلیس", ["bundle.marketplace.seller"], "retail"),
("educational_center", "Education", "مرکز آموزشی", ["bundle.education.starter"], "education"),
("corporate_company", "Corporate", "شرکت سازمانی", ["bundle.corporate.crm"], "professional"),
("professional_services", "Professional Services", "خدمات حرفه‌ای", ["bundle.professional.starter"], "professional"),
("freelancer", "Freelancer", "فریلنسر", ["bundle.freelancer.starter"], "professional"),
("manufacturer", "Manufacturer", "تولیدکننده", ["bundle.manufacturer.starter"], "retail"),
("wholesale", "Wholesale", "عمده‌فروشی", ["bundle.wholesale.starter"], "retail"),
("real_estate", "Real Estate", "املاک", ["bundle.real_estate.starter"], "professional"),
("government", "Government", "دولتی", ["bundle.government.starter"], "government"),
("ngo", "NGO", "NGO", ["bundle.ngo.starter"], "government"),
("custom", "Custom", "سفارشی", ["bundle.platform.trial"], "professional"),
("advertising_agency", "Advertising Agency", "آژانس تبلیغاتی", ["bundle.workspace.starter"], "professional"),
("content_team", "Content Team", "تیم تولید محتوا", ["bundle.workspace.starter"], "professional"),
("software_company", "Software Company", "شرکت نرم‌افزاری", ["bundle.workspace.starter"], "professional"),
("marketing_company", "Marketing Company", "شرکت بازاریابی", ["bundle.workspace.starter"], "professional"),
("team_freelancer", "Team Freelancer", "فریلنسر تیمی", ["bundle.workspace.starter"], "professional"),
]
for code, en, fa, rec in business_types:
if await upsert_object(
session,
for code, en, fa, rec, industry in business_types:
await put(
kind="business_types",
code=code,
display_name=fa,
@ -241,24 +454,90 @@ async def seed(session: AsyncSession) -> dict[str, int]:
"display_name": en,
"display_name_fa": fa,
"recommended_bundle_codes": rec,
"industry_code": industry,
"active": True,
},
):
counts["created"] += 1
)
# Recommendation rules
# ── Trial rules ───────────────────────────────────────────────────────
trials = [
(
"trial.platform.14d",
"Platform 14-day trial",
{
"trial_code": "trial.platform.14d",
"duration_days": 14,
"bundle_codes": ["bundle.platform.trial", "bundle.restaurant.starter", "bundle.cafe.starter"],
"pricing_item_code": "price.platform.trial.14d",
"auto_convert": False,
"requires_payment_method": False,
"active": True,
},
),
(
"trial.workspace.14d",
"دوره آزمایشی فضای کاری",
{
"trial_code": "trial.workspace.14d",
"duration_days": 14,
"bundle_codes": ["bundle.workspace.starter"],
"pricing_item_code": "price.workspace.starter.monthly",
"auto_convert": False,
"requires_payment_method": False,
"active": True,
},
),
(
"trial.restaurant.14d",
"Restaurant starter trial",
{
"trial_code": "trial.restaurant.14d",
"duration_days": 14,
"bundle_codes": ["bundle.restaurant.starter"],
"pricing_item_code": "price.restaurant.starter.monthly",
"auto_convert": False,
"requires_payment_method": False,
"active": True,
},
),
]
for code, name, payload in trials:
await put(kind="trials", code=code, display_name=name, payload=payload)
# ── Recommendation rules ──────────────────────────────────────────────
rules = [
(
"r.restaurant.base",
"Restaurant base",
{"business_type_code": "restaurant"},
{"bundle_code": "bundle.restaurant.starter", "score": 100, "reason": "نوع رستوران", "products": ["hospitality", "payment", "experience"]},
{
"bundle_code": "bundle.restaurant.starter",
"score": 100,
"reason": "نوع رستوران",
"products": ["hospitality", "payment", "experience"],
},
),
(
"r.cafe.base",
"Cafe base",
{"business_type_code": "cafe"},
{"bundle_code": "bundle.cafe.starter", "score": 100, "reason": "نوع کافه", "products": ["hospitality", "payment"]},
{
"bundle_code": "bundle.cafe.starter",
"score": 100,
"reason": "نوع کافه",
"products": ["hospitality", "payment"],
},
),
(
"r.clinic.base",
"Clinic base",
{"business_type_code": "clinic"},
{
"bundle_code": "bundle.clinic.starter",
"score": 100,
"reason": "نوع کلینیک",
"products": ["experience", "communication", "accounting"],
},
),
(
"r.delivery.need",
@ -272,24 +551,82 @@ async def seed(session: AsyncSession) -> dict[str, int]:
{"crm": True},
{"score": 80, "reason": "نیاز به CRM", "services": ["crm"], "products": ["crm"]},
),
(
"r.workspace.advertising_agency",
"آژانس تبلیغاتی — فضای کاری ضروری",
{"business_type_code": "advertising_agency"},
{
"bundle_code": "bundle.workspace.starter",
"score": 100,
"level": "required",
"reason": "آژانس تبلیغاتی به فضای کاری نیاز دارد",
"products": ["workspace", "communication", "crm", "accounting"],
},
),
(
"r.workspace.content_team",
"تیم تولید محتوا — فضای کاری ضروری",
{"business_type_code": "content_team"},
{
"bundle_code": "bundle.workspace.starter",
"score": 100,
"level": "required",
"reason": "تیم تولید محتوا به فضای کاری نیاز دارد",
"products": ["workspace", "communication", "crm"],
},
),
(
"r.workspace.software_company",
"شرکت نرم‌افزاری — فضای کاری پیشنهادی",
{"business_type_code": "software_company"},
{
"bundle_code": "bundle.workspace.starter",
"score": 70,
"level": "recommended",
"reason": "شرکت نرم‌افزاری از فضای کاری بهره می‌برد",
"products": ["workspace", "crm"],
},
),
(
"r.workspace.marketing_company",
"شرکت بازاریابی — فضای کاری ضروری",
{"business_type_code": "marketing_company"},
{
"bundle_code": "bundle.workspace.starter",
"score": 100,
"level": "required",
"reason": "شرکت بازاریابی به فضای کاری نیاز دارد",
"products": ["workspace", "communication", "crm", "accounting"],
},
),
(
"r.workspace.team_freelancer",
"فریلنسر تیمی — فضای کاری پیشنهادی",
{"business_type_code": "team_freelancer"},
{
"bundle_code": "bundle.workspace.starter",
"score": 70,
"level": "recommended",
"reason": "فریلنسر تیمی از فضای کاری بهره می‌برد",
"products": ["workspace"],
},
),
]
for code, name, match, emit in rules:
if await upsert_object(
session,
await put(
kind="recommendation_rules",
code=code,
display_name=name,
payload={"rule_id": code, "priority": 100, "active": True, "match": match, "emit": emit},
):
counts["created"] += 1
)
# Assessment schema
if await upsert_object(
session,
# ── Assessment schema ─────────────────────────────────────────────────
await put(
kind="assessment_schemas",
code="default.business.assessment",
display_name="ارزیابی کسب‌وکار",
payload={
"schema_code": "default.business.assessment",
"questions": [
{
"question_id": "business_type_code",
@ -311,60 +648,181 @@ async def seed(session: AsyncSession) -> dict[str, int]:
{"value": "enterprise", "label": "سازمانی"},
],
},
{
"question_id": "delivery",
"type": "boolean",
"label": "نیاز به پیک؟",
"required": False,
},
{
"question_id": "crm",
"type": "boolean",
"label": "نیاز به CRM؟",
"required": False,
},
]
{"question_id": "delivery", "type": "boolean", "label": "نیاز به پیک؟", "required": False},
{"question_id": "crm", "type": "boolean", "label": "نیاز به CRM؟", "required": False},
],
},
):
counts["created"] += 1
)
# Capabilities sample (open-ended)
for cap in ["digital_menu", "pos_lite", "kitchen", "reservation", "analytics"]:
if await upsert_object(
session,
kind="capabilities",
code=f"hospitality.{cap}",
display_name=cap,
payload={"capability_code": f"hospitality.{cap}", "service_key": "hospitality"},
):
counts["created"] += 1
# ── Policies ──────────────────────────────────────────────────────────
policies = [
(
"domain.custom",
"Custom domain policy",
{
"policy_code": "domain.custom",
"target": "domain",
"custom_domain_allowed": True,
"subdomain_only": False,
"reason": "طبق سیاست دامنه تجاری",
"dns_instructions": ["CNAME → edge.torbatyar.ir"],
"ssl_ready": True,
},
),
(
"trial.eligibility",
"Trial eligibility policy",
{
"policy_code": "trial.eligibility",
"target": "trial",
"max_trials_per_tenant": 1,
"default_trial_days": 14,
"blocked_business_types": [],
},
),
(
"feature.lock.default",
"Default feature lock policy",
{
"policy_code": "feature.lock.default",
"target": "capability",
"lock_when_unlicensed": True,
"reason": "قابلیت بدون لایسنس قفل است",
},
),
]
for code, name, payload in policies:
await put(kind="policies", code=code, display_name=name, payload=payload)
# Domain policy
if await upsert_object(
session,
kind="policies",
code="domain.custom",
display_name="Custom domain policy",
payload={
"custom_domain_allowed": True,
"subdomain_only": False,
"target": "domain",
"reason": "طبق سیاست دامنه تجاری",
"dns_instructions": ["CNAME → edge.torbatyar.ir"],
"ssl_ready": True,
},
):
counts["created"] += 1
# ── Assets ────────────────────────────────────────────────────────────
assets = [
("asset.theme.restaurant.v1", "Restaurant Theme", "restaurant_theme", "theme", "hospitality"),
("asset.theme.clinic.v1", "Clinic Theme", "clinic_theme", "theme", "experience"),
("asset.template.landing.v1", "Landing Template", "landing_template", "template", "experience"),
("asset.template.menu.v1", "Digital Menu Template", "website_template", "template", "hospitality"),
("asset.template.qr.v1", "QR Menu Template", "qr_template", "template", "experience"),
("asset.template.report.v1", "Ops Report Template", "report_template", "template", "accounting"),
]
for code, name, type_code, category, publisher in assets:
await put(
kind="assets",
code=code,
display_name=name,
payload={
"asset_code": code,
"asset_type_code": type_code,
"category": category,
"display_name": name,
"publisher_product_code": publisher,
"owner_service_identifier": publisher,
"lifecycle_state": "published",
"version_code": VERSION,
"marketplace": {"listable": True, "installable": True},
},
)
# Notification templates (email / sms / push / in_app)
# ── Extensions ────────────────────────────────────────────────────────
extensions = [
("ext.pay.checkout_widget", "widget", "payment", "payment", ["payments"]),
("ext.crm.pipeline_block", "block", "crm", "crm", ["crm"]),
("ext.pages.form_action", "action", "experience", "experience", ["forms"]),
("ext.loyalty.card_embed", "embed", "loyalty", "loyalty", ["loyalty"]),
("ext.driver.dispatch_connector", "connector", "delivery", "delivery", ["delivery"]),
("ext.accounting.invoice_template", "template", "accounting", "accounting", ["analytics"]),
("ext.food.menu_widget", "widget", "hospitality", "hospitality", ["hospitality.digital_menu"]),
]
for code, kind_ext, publisher, owner, caps in extensions:
await put(
kind="extensions",
code=code,
display_name=code,
payload={
"extension_code": code,
"extension_kind": kind_ext,
"display_name": code,
"publisher_product_code": publisher,
"owner_service_identifier": owner,
"required_capability_codes": caps,
"supported_surfaces": ["admin", "tenant", "public"],
"status": "active",
},
)
# ── Automation packs ──────────────────────────────────────────────────
packs = [
(
"auto.pack.welcome.sms",
"Welcome SMS Pack",
["sms", "internal_event"],
["sms"],
["communication"],
),
(
"auto.pack.trial.expiry",
"Trial Expiry Notify Pack",
["email", "push", "cron"],
["email", "notifications"],
["communication"],
),
(
"auto.pack.order.notify",
"Order Notify Pack",
["sms", "email", "internal_event"],
["sms", "email"],
["hospitality", "communication"],
),
]
for code, name, types, caps, products_ref in packs:
await put(
kind="automation_packs",
code=code,
display_name=name,
payload={
"automation_pack_code": code,
"display_name": name,
"automation_type_codes": types,
"required_capability_codes": caps,
"required_product_codes": products_ref,
"status": "active",
},
)
# ── Metadata keys ─────────────────────────────────────────────────────
metadata_keys = [
("meta.product.tags", "product", ["tags", "industry", "release_channel", "icon"]),
("meta.bundle.marketing", "bundle", ["slug", "tags", "marketing", "visibility", "industry"]),
("meta.capability.docs", "capability", ["documentation", "support_level"]),
("meta.asset.theme", "asset", ["color", "icon", "industry", "locale"]),
("meta.extension.surfaces", "extension", ["supported_surfaces", "beta"]),
("meta.automation.locale", "automation_pack", ["tags", "locale", "language"]),
("meta.pricing.channel", "pricing_item", ["channel_code", "region_code", "currency"]),
("meta.plan.limits", "subscription_plan", ["limits", "renewal"]),
]
for code, target, attrs in metadata_keys:
await put(
kind="metadata",
code=code,
display_name=code,
payload={
"metadata_key": code,
"target_kind": target,
"attributes": {a: True for a in attrs},
"version_code": VERSION,
"locale": "fa-IR",
"currency": "IRR",
"region": "IR",
},
)
# ── Notifications ─────────────────────────────────────────────────────
for channel, code, subject in [
("email", "notify.welcome.email", "خوش آمدید"),
("sms", "notify.welcome.sms", "خوش آمدید"),
("push", "notify.trial.expiring.push", "پایان trial"),
("in_app", "notify.activation.in_app", "فعال‌سازی"),
("email", "notify.subscription.active.email", "اشتراک فعال شد"),
]:
if await upsert_object(
session,
await put(
kind="notifications",
code=code,
display_name=subject,
@ -376,26 +834,99 @@ async def seed(session: AsyncSession) -> dict[str, int]:
"locale": "fa-IR",
"provider": "platform",
},
):
counts["created"] += 1
)
if await upsert_object(
session,
kind="currencies",
code="IRR",
display_name="ریال ایران",
payload={"currency_code": "IRR", "symbol": "﷼", "decimals": 0},
):
counts["created"] += 1
# ── Usage plans (templates) ───────────────────────────────────────────
usage_plans = [
(
"usage.sms.starter",
"SMS Starter Quota",
"sms",
1000,
"price.restaurant.starter.monthly",
),
(
"usage.sms.growth",
"SMS Growth Quota",
"sms",
10000,
"price.restaurant.growth.monthly",
),
(
"usage.storage.starter",
"Storage Starter",
"storage_gb",
5,
"price.platform.trial.14d",
),
]
for code, name, unit, quota, pricing_ref in usage_plans:
await put(
kind="usage_plans",
code=code,
display_name=name,
payload={
"usage_plan_code": code,
"display_name": name,
"usage_unit": unit,
"quota": quota,
"pricing_item_code": pricing_ref,
"overage_policy": "block",
"active": True,
},
)
if await upsert_object(
session,
kind="regions",
code="IR",
display_name="ایران",
payload={"region_code": "IR", "country_code": "IR"},
):
counts["created"] += 1
# ── Coupons / Promotions / Taxes / Announcements ──────────────────────
await put(
kind="coupons",
code="coupon.qa.welcome10",
display_name="Welcome 10%",
payload={
"coupon_code": "coupon.qa.welcome10",
"percent_off": 10,
"currency": "IRR",
"eligible_pricing_item_codes": ["price.restaurant.starter.monthly", "price.cafe.starter.monthly"],
"active": True,
},
)
await put(
kind="promotions",
code="promo.qa.summer2026",
display_name="Summer 2026",
payload={
"promotion_code": "promo.qa.summer2026",
"display_name": "Summer 2026",
"bundle_codes": ["bundle.restaurant.starter", "bundle.cafe.starter"],
"coupon_code": "coupon.qa.welcome10",
"window": {"starts_at": "2026-06-01", "ends_at": "2026-09-30"},
"active": True,
},
)
await put(
kind="taxes",
code="vat.standard",
display_name="VAT Standard",
payload={
"tax_code": "vat.standard",
"display_name": "VAT Standard",
"rate_bps": 900,
"inclusive": False,
"region_code": "IR",
"active": True,
},
)
await put(
kind="announcements",
code="announce.commercial.runtime.ready",
display_name="Commercial Runtime Ready",
payload={
"announcement_code": "announce.commercial.runtime.ready",
"title": "موتور تجاری آماده است",
"body": "کاتالوگ محصولات، بسته‌ها و قیمت‌ها از Commercial Runtime خوانده می‌شوند.",
"locale": "fa-IR",
"active": True,
},
)
await session.commit()
return counts

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Validate Workspace Commercial Runtime registration (catalog + seed, no extra services)."""
from __future__ import annotations
import ast
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
CATALOG = ROOT / "scripts" / "data" / "business-bundles.catalog.yaml"
SEED = ROOT / "scripts" / "seed_commercial_runtime.py"
REQUIRED_PRODUCTS = {"workspace", "communication", "crm", "accounting"}
REQUIRED_PRICES = {
"price.workspace.starter.monthly",
"price.workspace.business.monthly",
"price.workspace.enterprise",
}
REQUIRED_CAPS = {
"workspace.projects",
"workspace.tasks",
"workspace.kanban",
"workspace.calendar",
"workspace.content",
"workspace.approvals",
"workspace.comments",
"workspace.mentions",
"workspace.analytics",
}
REQUIRED_TYPES = {
"advertising_agency",
"content_team",
"software_company",
"marketing_company",
"team_freelancer",
}
REQUIRED_RULES = {
"r.workspace.advertising_agency",
"r.workspace.content_team",
"r.workspace.software_company",
"r.workspace.marketing_company",
"r.workspace.team_freelancer",
}
def fail(msg: str) -> None:
print(f"FAIL {msg}")
raise SystemExit(1)
def main() -> int:
catalog = yaml.safe_load(CATALOG.read_text(encoding="utf-8")) or {}
bundles = {b["bundle_code"]: b for b in catalog.get("bundles") or []}
bundle = bundles.get("bundle.workspace.starter")
if not bundle:
fail("bundle.workspace.starter missing from catalog")
print("Registry Validation: bundle.workspace.starter PASS")
services = {s["service_identifier"] for s in bundle.get("services") or []}
if not REQUIRED_PRODUCTS.issubset(services):
fail(f"bundle products {services} missing {REQUIRED_PRODUCTS - services}")
prices = {
p.get("pricing_item_code")
for p in bundle.get("pricing_refs") or []
if isinstance(p, dict)
}
if prices != REQUIRED_PRICES:
fail(f"pricing refs {prices} != {REQUIRED_PRICES}")
print("Pricing Validation: 3 workspace prices PASS")
caps = set(bundle.get("capability_codes") or [])
if not REQUIRED_CAPS.issubset(caps):
fail(f"capabilities missing {REQUIRED_CAPS - caps}")
print("Cross Reference: workspace capabilities PASS")
seed = SEED.read_text(encoding="utf-8")
if '"workspace": ("فضای کاری"' not in seed and '"workspace": ("فضای کاری", "/workspace"' not in seed:
if '"workspace":' not in seed or "فضای کاری" not in seed:
fail("workspace product not in seed")
print("Registry Validation: workspace product PASS")
for item in REQUIRED_TYPES | REQUIRED_RULES | REQUIRED_PRICES:
if item not in seed:
fail(f"{item} missing from seed")
print("Recommendation Validation: 5 workspace rules + 5 business types PASS")
tree = ast.parse(seed)
print("Validation PASS")
print(f"ast_ok={bool(tree)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -290,3 +290,35 @@ bundles:
- { pricing_item_code: price.platform.trial.14d, role: trial }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: false, grandfathered: false }
- bundle_code: bundle.workspace.starter
display_name: فضای کاری — نسخه شروع
status: active
version_code: "2026.07.1"
business_types:
- advertising_agency
- content_team
- software_company
- marketing_company
- team_freelancer
services:
- { service_identifier: workspace, required: true }
- { service_identifier: communication, required: true }
- { service_identifier: crm, required: true }
- { service_identifier: accounting, required: true }
capability_codes:
- workspace.projects
- workspace.tasks
- workspace.kanban
- workspace.calendar
- workspace.content
- workspace.approvals
- workspace.comments
- workspace.mentions
- workspace.analytics
pricing_refs:
- { pricing_item_code: price.workspace.starter.monthly, role: primary }
- { pricing_item_code: price.workspace.business.monthly, role: upgrade }
- { pricing_item_code: price.workspace.enterprise, role: enterprise }
visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: true, grandfathered: false }

View File

@ -187,8 +187,8 @@ export function buildSetupChecklist(
const workspaceActions =
products.length === 0
? [
{ id: "workspace", label: "Workspace", href: "/dashboard", required: true },
{ id: "billing", label: "Billing", href: "/billing", required: false },
{ id: "workspace", label: "فضای کاری", href: "/workspace", required: true },
{ id: "billing", label: "صورتحساب", href: "/billing", required: false },
]
: [];

View File

@ -445,6 +445,17 @@ const TOKEN_LABELS: Readonly<Record<string, string>> = {
website: "وب‌سایت",
experience: "وب‌سایت و تجربه دیجیتال",
crm: "مدیریت ارتباط با مشتری",
workspace: "فضای کاری",
projects: "پروژه‌ها",
tasks: "وظایف",
kanban: "کانبان",
calendar: "تقویم",
content: "محتوا",
approvals: "تأییدیه‌ها",
comments: "نظرات",
mentions: "منشن",
analytics: "گزارش‌ها",
business: "کسب‌وکار",
inventory: "انبارداری",
pos: "صندوق فروش",
communication: "ارتباط با مشتریان",
@ -810,7 +821,7 @@ export function looksTechnical(value: string): boolean {
export function formatMoney(amount?: number | null, currency?: string | null): string {
if (amount == null) return "استعلام قیمت";
const unit = !currency || /irr|rial/i.test(currency) ? "ریال" : enumLabel(currency);
const unit = !currency || /irr|rial|toman/i.test(currency) ? "تومان" : enumLabel(currency);
return `${new Intl.NumberFormat("fa-IR").format(amount)} ${unit}`;
}