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": [ "options": [
{"value": "online_sales", "label": "فروش آنلاین"}, {"value": "online_sales", "label": "فروش آنلاین"},
{"value": "delivery", "label": "پیک"}, {"value": "delivery", "label": "پیک"},
{"value": "crm", "label": "CRM"}, {"value": "crm", "label": "مدیریت ارتباط با مشتری"},
{"value": "workspace", "label": "فضای کاری"},
{"value": "loyalty", "label": "وفاداری"}, {"value": "loyalty", "label": "وفاداری"},
{"value": "website", "label": "وب‌سایت"}, {"value": "website", "label": "وب‌سایت"},
], ],

View File

@ -301,10 +301,10 @@ class LicenseResolver:
state = (await self.db.execute(stmt)).scalar_one_or_none() state = (await self.db.execute(stmt)).scalar_one_or_none()
if state is None: if state is None:
steps = [ steps = [
{"step_key": "workspace", "label": "Workspace", "status": "done"}, {"step_key": "workspace", "label": "فضای کاری", "status": "done"},
{"step_key": "bundle", "label": "Bundle", "status": "done"}, {"step_key": "bundle", "label": "بسته خدمات", "status": "done"},
{"step_key": "trial", "label": "Trial", "status": "done"}, {"step_key": "trial", "label": "دوره آزمایشی", "status": "done"},
{"step_key": "dashboard", "label": "Dashboard", "status": "pending", "href": "/dashboard"}, {"step_key": "dashboard", "label": "داشبورد", "status": "pending", "href": "/dashboard"},
] ]
state = CommercialActivationState( state = CommercialActivationState(
tenant_id=tenant_id, tenant_id=tenant_id,
@ -312,9 +312,9 @@ class LicenseResolver:
completion_percent=75, completion_percent=75,
steps=steps, steps=steps,
checklist=[ checklist=[
{"id": "workspace", "label": "Workspace", "done": True}, {"id": "workspace", "label": "فضای کاری", "done": True},
{"id": "bundle", "label": "انتخاب باندل", "done": True}, {"id": "bundle", "label": "انتخاب بسته", "done": True},
{"id": "billing", "label": "Billing", "done": False, "href": "/billing"}, {"id": "billing", "label": "صورتحساب", "done": False, "href": "/billing"},
{"id": "domain", "label": "دامنه", "done": False, "href": "/domains"}, {"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.status_code == 200
assert resp.json()["custom_domain_allowed"] is True 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 #!/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 from __future__ import annotations
import asyncio import asyncio
import re
import sys import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import yaml import yaml
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine 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() SCRIPT_PATH = Path(__file__).resolve()
CORE_SERVICE_ROOT = SCRIPT_PATH.parents[1] CORE_SERVICE_ROOT = SCRIPT_PATH.parents[1]
ROOT = next((parent for parent in SCRIPT_PATH.parents if (parent / "docs").exists()), CORE_SERVICE_ROOT) 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.core.config import settings
from app.models.commercial import CommercialRegistryKind, CommercialRegistryObject 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: async def ensure_kinds(session: AsyncSession) -> None:
svc = CommercialRegistryService(session) svc = CommercialRegistryService(session)
@ -49,7 +59,7 @@ async def upsert_object(
display_name: str, display_name: str,
payload: dict, payload: dict,
description: str | None = None, description: str | None = None,
version_code: str = "2026.07.1", version_code: str = VERSION,
) -> bool: ) -> bool:
existing = ( existing = (
await session.execute( await session.execute(
@ -70,9 +80,6 @@ async def upsert_object(
existing.is_deleted = False existing.is_deleted = False
await session.flush() await session.flush()
return False return False
from datetime import datetime, timezone
import re
import uuid
slug = re.sub(r"[^a-z0-9]+", "-", code.lower()).strip("-") slug = re.sub(r"[^a-z0-9]+", "-", code.lower()).strip("-")
session.add( session.add(
@ -96,73 +103,256 @@ async def upsert_object(
return True 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]: async def seed(session: AsyncSession) -> dict[str, int]:
await ensure_kinds(session) 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" async def put(**kwargs) -> None:
data = yaml.safe_load(catalog_path.read_text(encoding="utf-8")) if catalog_path.exists() else {} created = await upsert_object(session, **kwargs)
bundles = data.get("bundles") or [] 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 = { products = {
"hospitality": ("Torbat Food", "/hospitality", "hospitality"), "hospitality": ("Torbat Food", "/hospitality", "hospitality", "food_beverage"),
"payment": ("Torbat Pay", "/payment/hub", "payment"), "payment": ("Torbat Pay", "/payment/hub", "payment", "retail"),
"experience": ("Torbat Pages", "/experience/hub", "experience"), "experience": ("Torbat Pages", "/experience/hub", "experience", "professional"),
"delivery": ("Torbat Driver", "/delivery/hub", "delivery"), "delivery": ("Torbat Driver", "/delivery/hub", "delivery", "retail"),
"loyalty": ("Torbat Loyalty", "/loyalty/hub", "loyalty"), "loyalty": ("Torbat Loyalty", "/loyalty/hub", "loyalty", "retail"),
"communication": ("Torbat Communication", "/communication", "communication"), "communication": ("Torbat Communication", "/communication", "communication", "professional"),
"accounting": ("Torbat Accounting", "/accounting", "accounting"), "accounting": ("Torbat Accounting", "/accounting", "accounting", "professional"),
"crm": ("Torbat CRM", "/crm/hub", "crm"), "crm": ("Torbat CRM", "/crm/hub", "crm", "professional"),
"sports_center": ("Torbat Sports", "/sports", "sports_center"), "sports_center": ("Torbat Sports", "/sports", "sports_center", "sports"),
"identity-access": ("Torbat Identity", "/dashboard", "identity"), "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(): for code, (name, route, service_key, industry) in products.items():
if await upsert_object( planned = code == "marketplace"
session, is_workspace = code == "workspace"
await put(
kind="products", kind="products",
code=code, code=code,
display_name=name, display_name=name,
description=f"Platform product {name}", description=(
"مدیریت کار، تیم، پروژه و تولید محتوا"
if is_workspace
else f"Platform product {name}"
),
payload={ payload={
"product_code": code, "product_code": code,
"default_route": route, "default_route": route,
"service_key": service_key, "service_key": service_key,
"category": "platform", "category": "platform",
"trial_eligible": True, "category_code": "platform",
"visibility": "public", "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: for b in bundles:
code = b["bundle_code"] code = b["bundle_code"]
services = b.get("services") or [] services = b.get("services") or []
product_codes = [s["service_identifier"] for s in services if s.get("required")] product_codes: list[str] = []
product_codes += [ for s in services:
s["service_identifier"] pc = _product_code(s["service_identifier"])
for s in services if pc not in product_codes:
if not s.get("required") and s["service_identifier"] not in product_codes product_codes.append(pc)
] # Prefer required first
product_codes = [p.replace("sports-center", "sports_center").replace("identity-access", "identity-access") for p in product_codes] required = [_product_code(s["service_identifier"]) for s in services if s.get("required")]
# Map identity-access product code used in products dict optional = [p for p in product_codes if p not in required]
product_codes = [("identity-access" if p == "identity-access" else p) for p in product_codes] product_codes = required + optional
pricing_refs = [p.get("pricing_item_code") for p in (b.get("pricing_refs") or []) if isinstance(p, dict)] pricing_refs = [p.get("pricing_item_code") for p in (b.get("pricing_refs") or []) if isinstance(p, dict)]
capability_codes: list[str] = [] capability_codes = list(b.get("capability_codes") or []) or _capability_codes_for_products(
if "hospitality" in product_codes: product_codes
capability_codes = [ )
"hospitality.digital_menu", await put(
"hospitality.pos_lite",
"hospitality.kitchen",
"hospitality.reservation",
"hospitality.analytics",
]
if await upsert_object(
session,
kind="bundles", kind="bundles",
code=code, code=code,
display_name=b.get("display_name") or 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={ payload={
"bundle_code": code, "bundle_code": code,
"business_types": b.get("business_types") or [], "business_types": b.get("business_types") or [],
@ -173,36 +363,44 @@ async def seed(session: AsyncSession) -> dict[str, int]:
"visibility": b.get("visibility") or {}, "visibility": b.get("visibility") or {},
"lifecycle": b.get("lifecycle") or {}, "lifecycle": b.get("lifecycle") or {},
"trial_eligible": True, "trial_eligible": True,
"status": b.get("status") or "active",
}, },
): )
counts["created"] += 1
for pref in b.get("pricing_refs") or []: for pref in b.get("pricing_refs") or []:
pcode = pref.get("pricing_item_code") if isinstance(pref, dict) else None pcode = pref.get("pricing_item_code") if isinstance(pref, dict) else None
if not pcode: if not pcode:
continue continue
interval = "yearly" if "yearly" in pcode else "monthly" role = pref.get("role") if isinstance(pref, dict) else "primary"
if await upsert_object( if "trial" in pcode:
session, 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", kind="pricing",
code=pcode, code=pcode,
display_name=pcode, display_name=WORKSPACE_PRICING_NAMES.get(pcode, pcode),
payload={ payload={
"pricing_item_code": pcode, "pricing_item_code": pcode,
"pricing_model": "subscription", "pricing_model": model,
"interval": interval, "interval": interval,
"currency": "IRR", "currency": "IRR",
"amount_minor": None, "amount_minor": amount,
"bundle_code": code, "bundle_code": code,
"role": role,
"trial_eligible": True, "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}" plan_code = pcode.replace("price.", "plan.", 1) if pcode.startswith("price.") else f"plan.{pcode}"
if await upsert_object( await put(
session,
kind="plans", kind="plans",
code=plan_code, code=plan_code,
display_name=plan_code, display_name=plan_code,
@ -211,28 +409,43 @@ async def seed(session: AsyncSession) -> dict[str, int]:
"pricing_item_code": pcode, "pricing_item_code": pcode,
"bundle_code": code, "bundle_code": code,
"trial_eligible": True, "trial_eligible": True,
"trial_days": 14, "trial_days": trial_days or 14,
"interval": interval, "interval": interval,
"currency": "IRR", "currency": "IRR",
"amount_minor": amount,
"limits": {}, "limits": {},
"renewal": {"interval": interval}, "renewal": {"interval": interval},
"status": "active",
}, },
): )
counts["created"] += 1
# ── Business types ────────────────────────────────────────────────────
business_types = [ business_types = [
("restaurant", "Restaurant", "رستوران", ["bundle.restaurant.starter"]), ("restaurant", "Restaurant", "رستوران", ["bundle.restaurant.starter"], "food_beverage"),
("cafe", "Cafe", "کافه", ["bundle.cafe.starter"]), ("cafe", "Cafe", "کافه", ["bundle.cafe.starter"], "food_beverage"),
("clinic", "Clinic", "کلینیک", ["bundle.clinic.starter"]), ("clinic", "Clinic", "کلینیک", ["bundle.clinic.starter"], "healthcare"),
("beauty_salon", "Beauty Salon", "سالن زیبایی", ["bundle.beauty.starter"]), ("beauty_salon", "Beauty Salon", "سالن زیبایی", ["bundle.beauty.starter"], "beauty"),
("sports_center", "Sports Center", "مرکز ورزشی", ["bundle.sports.starter"]), ("sports_center", "Sports Center", "مرکز ورزشی", ["bundle.sports.starter"], "sports"),
("retail_shop", "Retail Shop", "فروشگاه", ["bundle.retail.starter"]), ("retail_shop", "Retail Shop", "فروشگاه", ["bundle.retail.starter"], "retail"),
("corporate_company", "Corporate", "شرکت سازمانی", ["bundle.corporate.crm"]), ("marketplace_seller", "Marketplace Seller", "فروشنده مارکت‌پلیس", ["bundle.marketplace.seller"], "retail"),
("freelancer", "Freelancer", "فریلنسر", ["bundle.freelancer.starter"]), ("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: for code, en, fa, rec, industry in business_types:
if await upsert_object( await put(
session,
kind="business_types", kind="business_types",
code=code, code=code,
display_name=fa, display_name=fa,
@ -241,24 +454,90 @@ async def seed(session: AsyncSession) -> dict[str, int]:
"display_name": en, "display_name": en,
"display_name_fa": fa, "display_name_fa": fa,
"recommended_bundle_codes": rec, "recommended_bundle_codes": rec,
"industry_code": industry,
"active": True, "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 = [ rules = [
( (
"r.restaurant.base", "r.restaurant.base",
"Restaurant base", "Restaurant base",
{"business_type_code": "restaurant"}, {"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", "r.cafe.base",
"Cafe base", "Cafe base",
{"business_type_code": "cafe"}, {"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", "r.delivery.need",
@ -272,24 +551,82 @@ async def seed(session: AsyncSession) -> dict[str, int]:
{"crm": True}, {"crm": True},
{"score": 80, "reason": "نیاز به CRM", "services": ["crm"], "products": ["crm"]}, {"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: for code, name, match, emit in rules:
if await upsert_object( await put(
session,
kind="recommendation_rules", kind="recommendation_rules",
code=code, code=code,
display_name=name, display_name=name,
payload={"rule_id": code, "priority": 100, "active": True, "match": match, "emit": emit}, payload={"rule_id": code, "priority": 100, "active": True, "match": match, "emit": emit},
): )
counts["created"] += 1
# Assessment schema # ── Assessment schema ─────────────────────────────────────────────────
if await upsert_object( await put(
session,
kind="assessment_schemas", kind="assessment_schemas",
code="default.business.assessment", code="default.business.assessment",
display_name="ارزیابی کسب‌وکار", display_name="ارزیابی کسب‌وکار",
payload={ payload={
"schema_code": "default.business.assessment",
"questions": [ "questions": [
{ {
"question_id": "business_type_code", "question_id": "business_type_code",
@ -311,60 +648,181 @@ async def seed(session: AsyncSession) -> dict[str, int]:
{"value": "enterprise", "label": "سازمانی"}, {"value": "enterprise", "label": "سازمانی"},
], ],
}, },
{ {"question_id": "delivery", "type": "boolean", "label": "نیاز به پیک؟", "required": False},
"question_id": "delivery", {"question_id": "crm", "type": "boolean", "label": "نیاز به CRM؟", "required": False},
"type": "boolean", ],
"label": "نیاز به پیک؟",
"required": False,
},
{
"question_id": "crm",
"type": "boolean",
"label": "نیاز به CRM؟",
"required": False,
},
]
}, },
): )
counts["created"] += 1
# Capabilities sample (open-ended) # ── Policies ──────────────────────────────────────────────────────────
for cap in ["digital_menu", "pos_lite", "kitchen", "reservation", "analytics"]: policies = [
if await upsert_object( (
session, "domain.custom",
kind="capabilities", "Custom domain policy",
code=f"hospitality.{cap}", {
display_name=cap, "policy_code": "domain.custom",
payload={"capability_code": f"hospitality.{cap}", "service_key": "hospitality"}, "target": "domain",
): "custom_domain_allowed": True,
counts["created"] += 1 "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 # ── Assets ────────────────────────────────────────────────────────────
if await upsert_object( assets = [
session, ("asset.theme.restaurant.v1", "Restaurant Theme", "restaurant_theme", "theme", "hospitality"),
kind="policies", ("asset.theme.clinic.v1", "Clinic Theme", "clinic_theme", "theme", "experience"),
code="domain.custom", ("asset.template.landing.v1", "Landing Template", "landing_template", "template", "experience"),
display_name="Custom domain policy", ("asset.template.menu.v1", "Digital Menu Template", "website_template", "template", "hospitality"),
payload={ ("asset.template.qr.v1", "QR Menu Template", "qr_template", "template", "experience"),
"custom_domain_allowed": True, ("asset.template.report.v1", "Ops Report Template", "report_template", "template", "accounting"),
"subdomain_only": False, ]
"target": "domain", for code, name, type_code, category, publisher in assets:
"reason": "طبق سیاست دامنه تجاری", await put(
"dns_instructions": ["CNAME → edge.torbatyar.ir"], kind="assets",
"ssl_ready": True, code=code,
}, display_name=name,
): payload={
counts["created"] += 1 "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 [ for channel, code, subject in [
("email", "notify.welcome.email", "خوش آمدید"), ("email", "notify.welcome.email", "خوش آمدید"),
("sms", "notify.welcome.sms", "خوش آمدید"), ("sms", "notify.welcome.sms", "خوش آمدید"),
("push", "notify.trial.expiring.push", "پایان trial"), ("push", "notify.trial.expiring.push", "پایان trial"),
("in_app", "notify.activation.in_app", "فعال‌سازی"), ("in_app", "notify.activation.in_app", "فعال‌سازی"),
("email", "notify.subscription.active.email", "اشتراک فعال شد"),
]: ]:
if await upsert_object( await put(
session,
kind="notifications", kind="notifications",
code=code, code=code,
display_name=subject, display_name=subject,
@ -376,26 +834,99 @@ async def seed(session: AsyncSession) -> dict[str, int]:
"locale": "fa-IR", "locale": "fa-IR",
"provider": "platform", "provider": "platform",
}, },
): )
counts["created"] += 1
if await upsert_object( # ── Usage plans (templates) ───────────────────────────────────────────
session, usage_plans = [
kind="currencies", (
code="IRR", "usage.sms.starter",
display_name="ریال ایران", "SMS Starter Quota",
payload={"currency_code": "IRR", "symbol": "﷼", "decimals": 0}, "sms",
): 1000,
counts["created"] += 1 "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( # ── Coupons / Promotions / Taxes / Announcements ──────────────────────
session, await put(
kind="regions", kind="coupons",
code="IR", code="coupon.qa.welcome10",
display_name="ایران", display_name="Welcome 10%",
payload={"region_code": "IR", "country_code": "IR"}, payload={
): "coupon_code": "coupon.qa.welcome10",
counts["created"] += 1 "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() await session.commit()
return counts 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 } - { pricing_item_code: price.platform.trial.14d, role: trial }
visibility: { public_catalog: true, admin_only: false, hidden: false } visibility: { public_catalog: true, admin_only: false, hidden: false }
lifecycle: { sellable: true, renewable: false, grandfathered: 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 = const workspaceActions =
products.length === 0 products.length === 0
? [ ? [
{ id: "workspace", label: "Workspace", href: "/dashboard", required: true }, { id: "workspace", label: "فضای کاری", href: "/workspace", required: true },
{ id: "billing", label: "Billing", href: "/billing", required: false }, { id: "billing", label: "صورتحساب", href: "/billing", required: false },
] ]
: []; : [];

View File

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