Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/basalam_integration_service.py
2026-05-16 18:36:22 +03:30

2796 lines
108 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Basalam integration service (settings, webhook processing, order sync)."""
from __future__ import annotations
import hashlib
import hmac
import io
import json
from datetime import date
from datetime import datetime
from datetime import timedelta
from typing import Any, Dict, List, Optional, Tuple
from uuid import UUID
from sqlalchemy import or_
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
import structlog
from adapters.api.v1.schema_models.person import PersonCreateRequest, PersonType
from adapters.api.v1.schema_models.product import ProductCreateRequest
from adapters.db.models.business import Business
from adapters.db.models.file_storage import FileStorage
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.crm_chat import CrmChatConversation, CrmChatMessage, CrmChatWidget
from adapters.db.models.currency import BusinessCurrency, Currency
from adapters.db.models.person import Person
from adapters.db.models.product import Product
from adapters.db.models.marketplace import BusinessPlugin, MarketplacePlugin
from fastapi import HTTPException
from app.core.basalam_plugin_dependency import check_basalam_plugin_active
from app.core.cache import get_cache
from app.core.responses import ApiError
from app.services import crm_chat_service
from app.services.file_storage_service import FileStorageService
from app.services.basalam_observability import record_basalam_metric
from app.services.invoice_service import (
INVOICE_LINK_RECEIPT_PAYMENT_IDS,
_normalize_document_extra_info_for_storage,
calculate_invoice_remaining,
create_invoice,
)
from app.services.person_service import create_person
from app.services.product_service import create_product
from app.services.receipt_payment_service import create_receipt_payment
from app.services.storage_subscription_service import check_storage_limit
from app.services.workflow.workflow_trigger_service import trigger_workflows
logger = structlog.get_logger(__name__)
PLUGIN_CODE = "basalam_connector"
EXTRA_INFO_KEY = "basalam_connector"
DEDUPE_TTL_SECONDS = 60 * 60 * 48
SYNC_DEAD_LETTER_MAX = 200
_PAYMENT_DLQ_STATUSES = frozenset(
{
"invoice_not_found",
"manual_review_required",
"invalid_amount",
"invoice_person_not_found",
"missing_reference_id",
"invoice_currency_not_irr",
"payment_exceeds_invoice_remaining",
"payment_invoice_already_settled",
}
)
def _json_loads_safe(value: Optional[str]) -> Dict[str, Any]:
if not value:
return {}
try:
loaded = json.loads(value)
except Exception:
return {}
return loaded if isinstance(loaded, dict) else {}
def _json_dumps_safe(value: Dict[str, Any]) -> str:
return json.dumps(value, ensure_ascii=False)
def _find_plugin_row(db: Session) -> MarketplacePlugin:
plugin = db.query(MarketplacePlugin).filter(MarketplacePlugin.code == PLUGIN_CODE).first()
if not plugin:
raise ApiError(
"BASALAM_PLUGIN_NOT_REGISTERED",
"Basalam plugin is not registered in marketplace.",
http_status=404,
)
return plugin
def _find_business_plugin(db: Session, business_id: int) -> BusinessPlugin:
plugin = _find_plugin_row(db)
row = (
db.query(BusinessPlugin)
.filter(
BusinessPlugin.business_id == int(business_id),
BusinessPlugin.plugin_id == plugin.id,
)
.first()
)
if row:
return row
raise ApiError(
"BASALAM_LICENSE_NOT_FOUND",
"Basalam license row for this business was not found.",
http_status=404,
)
def _default_settings() -> Dict[str, Any]:
return {
"enabled": False,
"api_key": "",
"api_refresh_token": "",
"api_base_url": "https://api.basalam.com",
"default_basalam_vendor_id": None,
"default_basalam_category_id": None,
"default_basalam_stock": 1,
"last_product_pull_at": None,
"last_product_push_at": None,
"pending_product_publish_retries": [],
"pending_product_conflicts": [],
"product_conflict_price_strategy": "local_wins",
"product_conflict_stock_strategy": "local_wins",
"product_variant_strategy": "manual_review",
"webhook_secret": "",
"webhook_enabled": False,
"chat_enabled": True,
"order_sync_enabled": True,
"product_sync_enabled": True,
"auto_create_person_mode": "match_or_create",
"auto_create_product_mode": "match_or_create",
"default_order_tag": "basalam",
"payment_register_mode": "manual_review",
"payment_sync_enabled": True,
"payment_verify_remote": True,
"create_sales_invoice_on_sync": True,
"invoice_type_on_sync": "invoice_sales",
"default_bank_account_id": None,
"default_cash_register_id": None,
"mappings": {
"persons": {},
"products": {},
},
"recent_event_keys": [],
"updated_at": None,
"last_webhook_event_at": None,
"last_webhook_event_type": None,
"sync_dead_letter": [],
# واحد مبلغ در API/وب‌هوک باسلام: «rial» (ریال) یا «toman» (تومان؛ به ریال داخلی ×۱۰)
"basalam_monetary_unit": "rial",
# تطبیق سند پرداخت با ماندهٔ فاکتور (IRR)
"payment_reconcile_block_overpayment": True,
"payment_reconcile_tolerance_rial": 1.0,
}
def _normalize_settings(payload: Dict[str, Any], previous: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
base = dict(_default_settings())
if previous:
base.update(previous)
if payload:
base.update(payload)
base["enabled"] = bool(base.get("enabled", False))
base["webhook_enabled"] = bool(base.get("webhook_enabled", False))
base["chat_enabled"] = bool(base.get("chat_enabled", True))
base["order_sync_enabled"] = bool(base.get("order_sync_enabled", True))
base["product_sync_enabled"] = bool(base.get("product_sync_enabled", True))
base["api_key"] = str(base.get("api_key") or "").strip()
base["api_refresh_token"] = str(base.get("api_refresh_token") or "").strip()
base["api_base_url"] = str(base.get("api_base_url") or "https://api.basalam.com").strip()
base["default_basalam_vendor_id"] = (
int(base.get("default_basalam_vendor_id")) if str(base.get("default_basalam_vendor_id") or "").isdigit() else None
)
base["default_basalam_category_id"] = (
int(base.get("default_basalam_category_id"))
if str(base.get("default_basalam_category_id") or "").isdigit()
else None
)
stock_val = base.get("default_basalam_stock")
try:
base["default_basalam_stock"] = max(0, int(stock_val))
except Exception:
base["default_basalam_stock"] = 1
pending_pub = base.get("pending_product_publish_retries")
base["pending_product_publish_retries"] = (
[x for x in pending_pub if isinstance(x, dict)] if isinstance(pending_pub, list) else []
)
pending_conf = base.get("pending_product_conflicts")
base["pending_product_conflicts"] = (
[x for x in pending_conf if isinstance(x, dict)] if isinstance(pending_conf, list) else []
)
dlq = base.get("sync_dead_letter")
base["sync_dead_letter"] = [x for x in dlq if isinstance(x, dict)] if isinstance(dlq, list) else []
price_strategy = str(base.get("product_conflict_price_strategy") or "local_wins").strip()
stock_strategy = str(base.get("product_conflict_stock_strategy") or "local_wins").strip()
variant_strategy = str(base.get("product_variant_strategy") or "manual_review").strip()
allowed = {"local_wins", "remote_wins", "manual_review"}
base["product_conflict_price_strategy"] = price_strategy if price_strategy in allowed else "local_wins"
base["product_conflict_stock_strategy"] = stock_strategy if stock_strategy in allowed else "local_wins"
base["product_variant_strategy"] = variant_strategy if variant_strategy in allowed else "manual_review"
mon_unit = str(base.get("basalam_monetary_unit") or "rial").strip().lower()
base["basalam_monetary_unit"] = "toman" if mon_unit in ("toman", "tomman", "تومان") else "rial"
base["payment_reconcile_block_overpayment"] = bool(base.get("payment_reconcile_block_overpayment", True))
try:
tol = float(base.get("payment_reconcile_tolerance_rial"))
base["payment_reconcile_tolerance_rial"] = max(0.0, tol)
except (TypeError, ValueError):
base["payment_reconcile_tolerance_rial"] = 1.0
base["webhook_secret"] = str(base.get("webhook_secret") or "").strip()
base["auto_create_person_mode"] = str(base.get("auto_create_person_mode") or "match_or_create").strip()
base["auto_create_product_mode"] = str(base.get("auto_create_product_mode") or "match_or_create").strip()
base["default_order_tag"] = str(base.get("default_order_tag") or "basalam").strip() or "basalam"
base["payment_register_mode"] = str(base.get("payment_register_mode") or "manual_review").strip()
base["payment_sync_enabled"] = bool(base.get("payment_sync_enabled", True))
base["payment_verify_remote"] = bool(base.get("payment_verify_remote", True))
base["create_sales_invoice_on_sync"] = bool(base.get("create_sales_invoice_on_sync", True))
base["invoice_type_on_sync"] = str(base.get("invoice_type_on_sync") or "invoice_sales").strip()
mappings = base.get("mappings") if isinstance(base.get("mappings"), dict) else {}
base["mappings"] = {
"persons": mappings.get("persons") if isinstance(mappings.get("persons"), dict) else {},
"products": mappings.get("products") if isinstance(mappings.get("products"), dict) else {},
}
recent = base.get("recent_event_keys")
base["recent_event_keys"] = [str(x) for x in recent if isinstance(x, str)] if isinstance(recent, list) else []
base["updated_at"] = datetime.utcnow().isoformat()
return base
def get_settings(db: Session, business_id: int) -> Dict[str, Any]:
row = _find_business_plugin(db, business_id)
if not check_basalam_plugin_active(db, int(business_id)):
raise ApiError(
"BASALAM_PLUGIN_NOT_ACTIVE",
"Basalam plugin is not active.",
http_status=403,
details={"plugin_code": PLUGIN_CODE},
)
extra = _json_loads_safe(row.extra_info)
saved = extra.get(EXTRA_INFO_KEY)
if not isinstance(saved, dict):
saved = _default_settings()
return _normalize_settings(saved, saved)
def update_settings(db: Session, business_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
row = _find_business_plugin(db, business_id)
if not check_basalam_plugin_active(db, int(business_id)):
raise ApiError(
"BASALAM_PLUGIN_NOT_ACTIVE",
"Basalam plugin is not active.",
http_status=403,
details={"plugin_code": PLUGIN_CODE},
)
extra = _json_loads_safe(row.extra_info)
prev = extra.get(EXTRA_INFO_KEY) if isinstance(extra.get(EXTRA_INFO_KEY), dict) else _default_settings()
settings = _normalize_settings(payload or {}, prev)
extra[EXTRA_INFO_KEY] = settings
row.extra_info = _json_dumps_safe(extra)
row.updated_at = datetime.utcnow()
db.add(row)
db.commit()
db.refresh(row)
return settings
def _verify_webhook_signature(raw_body: bytes, supplied_signature: Optional[str], secret: str) -> bool:
if not secret:
return True
if not supplied_signature:
return False
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(digest, supplied_signature.strip())
def _event_type(payload: Dict[str, Any]) -> str:
for key in ("event_type", "event", "type", "topic"):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip().lower()
return "unknown"
def _event_id(payload: Dict[str, Any]) -> str:
for key in ("event_id", "id", "hash_id", "message_id"):
value = payload.get(key)
if value not in (None, ""):
return str(value)
data = payload.get("data")
if isinstance(data, dict):
for key in ("event_id", "id", "hash_id"):
value = data.get(key)
if value not in (None, ""):
return str(value)
return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()[:24]
def _workflow_event_key(event_type: str) -> str:
mapping = {
"order.created": "basalam.order.created",
"order.updated": "basalam.order.updated",
"order.paid": "basalam.order.paid",
"chat.message.received": "basalam.chat.message.received",
}
return mapping.get(event_type, "basalam.webhook.received")
def _digits_only(value: Optional[str]) -> str:
if not value:
return ""
mapped = str(value)
trans = str.maketrans("۰۱۲۳۴۵۶۷۸۹", "0123456789")
mapped = mapped.translate(trans)
return "".join(ch for ch in mapped if ch.isdigit())
def _normalize_mobile(value: Optional[str]) -> Optional[str]:
d = _digits_only(value)
if not d:
return None
if d.startswith("98") and len(d) >= 12:
d = "0" + d[2:]
if d.startswith("+98"):
d = "0" + d[3:]
return d
def _extract_order_candidates(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
candidates: List[Dict[str, Any]] = []
for key in ("orders", "customer_orders", "items"):
value = payload.get(key)
if isinstance(value, list):
candidates.extend(v for v in value if isinstance(v, dict))
for key in ("order", "customer_order"):
value = payload.get(key)
if isinstance(value, dict):
candidates.append(value)
data = payload.get("data")
if isinstance(data, dict):
for key in ("orders", "customer_orders"):
value = data.get(key)
if isinstance(value, list):
candidates.extend(v for v in value if isinstance(v, dict))
if isinstance(data.get("order"), dict):
candidates.append(data["order"])
return candidates
def _order_id(order: Dict[str, Any]) -> str:
for key in ("id", "order_id", "hash_id", "number"):
value = order.get(key)
if value not in (None, ""):
return str(value)
return hashlib.sha256(json.dumps(order, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest()[:16]
def _extract_customer(order: Dict[str, Any]) -> Dict[str, Any]:
for key in ("customer", "buyer", "person", "user"):
value = order.get(key)
if isinstance(value, dict):
return value
return {}
def _extract_order_lines(order: Dict[str, Any]) -> List[Dict[str, Any]]:
for key in ("items", "lines", "products", "order_items"):
value = order.get(key)
if isinstance(value, list):
return [v for v in value if isinstance(v, dict)]
return []
def _extract_product_basalam_id(line: Dict[str, Any]) -> Optional[str]:
for key in ("product_id", "variation_id", "id", "hash_id"):
value = line.get(key)
if value not in (None, ""):
return str(value)
product = line.get("product")
if isinstance(product, dict):
for key in ("id", "hash_id"):
value = product.get(key)
if value not in (None, ""):
return str(value)
return None
def _parse_decimal(value: Any, default: float = 0.0) -> float:
try:
if value in (None, ""):
return float(default)
return float(value)
except Exception:
return float(default)
def _basalam_monetary_unit(settings: Dict[str, Any]) -> str:
"""واحد مبلغ در دادهٔ باسلام: rial یا toman (تبدیل به ریال حساب‌یکس با ×۱۰)."""
u = str(settings.get("basalam_monetary_unit") or "rial").strip().lower()
return "toman" if u == "toman" else "rial"
def _incoming_basalam_amount_to_rial_amount(value: Any, settings: Dict[str, Any]) -> float:
"""مبلغ خام از باسلام → ریال داخلی (IRR) مطابق تنظیمات افزونه."""
v = _parse_decimal(value, 0)
if _basalam_monetary_unit(settings) == "toman":
return float(v) * 10.0
return float(v)
def _internal_rial_amount_to_basalam_wire(value: Any, settings: Dict[str, Any]) -> float:
"""ریال ذخیره‌شده در حساب‌یکس → عددی که به API باسلام برای قیمت می‌فرستیم."""
v = _parse_decimal(value, 0)
if _basalam_monetary_unit(settings) == "toman":
return float(v) / 10.0
return float(v)
def _basalam_currency_validation(db: Session, business_id: int) -> Dict[str, Any]:
"""اعتبارسنجی IRR-only؛ بدون پرتاب — برای UI و برای ساخت خطای اول در سینک."""
bid = int(business_id)
issues: List[Dict[str, Any]] = []
default_code: Optional[str] = None
invalid_secondary: List[str] = []
biz = db.query(Business).filter(Business.id == bid).first()
if not biz:
issues.append(
{
"code": "BUSINESS_NOT_FOUND",
"message": "کسب‌وکار یافت نشد.",
"http_status": 404,
}
)
return {
"issues": issues,
"default_currency_code": None,
"invalid_secondary_currency_codes": invalid_secondary,
}
if not biz.default_currency_id:
issues.append(
{
"code": "BASALAM_CURRENCY_NOT_SET",
"message": "برای سینک با باسلام ابتدا ارز پیش‌فرض کسب‌وکار را تعیین کنید.",
"http_status": 400,
}
)
return {
"issues": issues,
"default_currency_code": None,
"invalid_secondary_currency_codes": invalid_secondary,
}
dc = db.query(Currency).filter(Currency.id == int(biz.default_currency_id)).first()
default_code = str(dc.code if dc else "").strip().upper() or None
if default_code != "IRR":
issues.append(
{
"code": "BASALAM_REQUIRES_IRR_DEFAULT_CURRENCY",
"message": "برای سینک با باسلام، ارز پیش‌فرض کسب‌وکار باید ریال ایران با کد IRR باشد.",
"http_status": 409,
"details": {"current_currency_code": default_code},
}
)
secondary_codes = (
db.query(Currency.code)
.join(BusinessCurrency, BusinessCurrency.currency_id == Currency.id)
.filter(BusinessCurrency.business_id == bid)
.all()
)
invalid_secondary = sorted(
{str(c[0]).strip().upper() for c in secondary_codes if str(c[0]).strip().upper() != "IRR"}
)
if invalid_secondary:
issues.append(
{
"code": "BASALAM_REQUIRES_IRR_ONLY_CURRENCIES",
"message": "برای سینک با باسلام فقط ارز IRR باید در ارزهای فعال کسب‌وکار باشد؛ ارزهای جانبی غیرریالی را حذف کنید.",
"http_status": 409,
"details": {"invalid_currency_codes": invalid_secondary},
}
)
return {
"issues": issues,
"default_currency_code": default_code,
"invalid_secondary_currency_codes": invalid_secondary,
}
def get_basalam_currency_readiness(db: Session, business_id: int) -> Dict[str, Any]:
"""وضعیت ارزی برای هشدار در UI؛ هرگز پرتاب نمی‌کند."""
if not check_basalam_plugin_active(db, int(business_id)):
return {
"ready": False,
"issues": [
{
"code": "BASALAM_PLUGIN_NOT_ACTIVE",
"message": "افزونهٔ باسلام برای این کسب‌وکار فعال نیست یا اعتبار آن به پایان رسیده است.",
}
],
"default_currency_code": None,
"invalid_secondary_currency_codes": [],
}
v = _basalam_currency_validation(db, business_id)
issues = list(v.get("issues") or [])
return {
"ready": len(issues) == 0,
"issues": issues,
"default_currency_code": v.get("default_currency_code"),
"invalid_secondary_currency_codes": list(v.get("invalid_secondary_currency_codes") or []),
}
def _ensure_business_irr_only_for_basalam(db: Session, business_id: int) -> None:
"""
سینک باسلام فقط با ارز ریال ایران در حساب‌یکس: پیش‌فرض IRR و عدم وجود ارز جانبی غیر IRR.
"""
v = _basalam_currency_validation(db, business_id)
issues = list(v.get("issues") or [])
if not issues:
return
first = issues[0]
raise ApiError(
str(first.get("code") or "BASALAM_CURRENCY_INVALID"),
str(first.get("message") or "Currency validation failed."),
http_status=int(first.get("http_status") or 409),
details=first.get("details"),
)
def _event_dedupe_key(business_id: int, event_type: str, event_id: str, order_id: Optional[str] = None) -> str:
if order_id:
return f"basalam:dedupe:{business_id}:{event_type}:{event_id}:{order_id}"
return f"basalam:dedupe:{business_id}:{event_type}:{event_id}"
def _is_duplicate_event(business_id: int, dedupe_key: str) -> bool:
cache = get_cache()
if cache.enabled:
if cache.exists(dedupe_key):
return True
cache.set(dedupe_key, True, ttl=DEDUPE_TTL_SECONDS)
return False
return False
def _remember_event_in_settings(db: Session, business_id: int, settings: Dict[str, Any], dedupe_key: str) -> None:
recent = list(settings.get("recent_event_keys") or [])
if dedupe_key in recent:
return
recent.append(dedupe_key)
if len(recent) > 200:
recent = recent[-200:]
update_settings(db, business_id, {"recent_event_keys": recent})
def _resolve_actor_user_id(db: Session, business_id: int, requested_user_id: Optional[int]) -> int:
if requested_user_id and int(requested_user_id) > 0:
return int(requested_user_id)
biz = db.query(Business).filter(Business.id == int(business_id)).first()
if not biz or not biz.owner_id:
raise ApiError("BASALAM_BUSINESS_OWNER_NOT_FOUND", "Business owner user was not found.", http_status=404)
return int(biz.owner_id)
def _extract_person_name(customer: Dict[str, Any]) -> str:
for key in ("full_name", "name", "display_name", "username"):
value = customer.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
first_name = str(customer.get("first_name") or "").strip()
last_name = str(customer.get("last_name") or "").strip()
joined = f"{first_name} {last_name}".strip()
return joined or "Basalam Customer"
def _find_or_create_person(
db: Session,
business_id: int,
settings: Dict[str, Any],
customer: Dict[str, Any],
) -> Optional[int]:
mode = str(settings.get("auto_create_person_mode") or "match_or_create")
customer_id = str(customer.get("id") or customer.get("hash_id") or "").strip()
mappings = settings.get("mappings", {}).get("persons", {})
if customer_id and customer_id in mappings:
mapped_id = int(mappings[customer_id])
exists = db.query(Person).filter(Person.business_id == int(business_id), Person.id == mapped_id).first()
if exists:
return mapped_id
mobile = _normalize_mobile(customer.get("mobile") or customer.get("phone"))
if mobile:
hit = db.query(Person).filter(
Person.business_id == int(business_id),
or_(Person.mobile == mobile, Person.mobile_2 == mobile, Person.mobile_3 == mobile),
).first()
if hit:
return int(hit.id)
name = _extract_person_name(customer)
hit = db.query(Person).filter(Person.business_id == int(business_id), Person.alias_name == name).first()
if hit:
return int(hit.id)
if mode in ("match_only", "manual_review"):
return None
created = create_person(
db,
business_id=int(business_id),
person_data=PersonCreateRequest(
alias_name=name,
person_types=[PersonType.CUSTOMER],
mobile=mobile,
phone=mobile,
),
)
data = created.get("data") if isinstance(created, dict) else {}
person_id = data.get("id") if isinstance(data, dict) else None
if not person_id:
return None
return int(person_id)
def _find_or_create_product(
db: Session,
business_id: int,
settings: Dict[str, Any],
line: Dict[str, Any],
) -> Optional[int]:
mode = str(settings.get("auto_create_product_mode") or "match_or_create")
basalam_pid = _extract_product_basalam_id(line)
mappings = settings.get("mappings", {}).get("products", {})
if basalam_pid and basalam_pid in mappings:
mapped_id = int(mappings[basalam_pid])
exists = db.query(Product).filter(Product.business_id == int(business_id), Product.id == mapped_id).first()
if exists:
return mapped_id
sku = str(line.get("sku") or "").strip()
barcode = _digits_only(line.get("barcode") or "")
name = str(line.get("title") or line.get("name") or "Basalam Item").strip()
if sku:
hit = db.query(Product).filter(Product.business_id == int(business_id), Product.code == sku).first()
if hit:
return int(hit.id)
if barcode:
hit = (
db.query(Product)
.filter(Product.business_id == int(business_id), Product.general_barcodes.isnot(None))
.all()
)
for p in hit:
raw = str(p.general_barcodes or "")
tokens = [t.strip() for t in raw.split(",") if t.strip()]
if barcode in tokens:
return int(p.id)
hit = db.query(Product).filter(Product.business_id == int(business_id), Product.name == name).first()
if hit:
return int(hit.id)
if mode in ("match_only", "manual_review"):
return None
product_payload = ProductCreateRequest(
code=sku if sku else None,
name=name or "Basalam Item",
item_type="کالا",
base_sales_price=_incoming_basalam_amount_to_rial_amount(
line.get("unit_price") or line.get("price"),
settings,
),
barcode=barcode or None,
track_inventory=True,
)
created = create_product(db, business_id=int(business_id), payload=product_payload)
data = created.get("data") if isinstance(created, dict) else {}
product_id = data.get("id") if isinstance(data, dict) else None
if not product_id:
return None
return int(product_id)
def _build_invoice_lines(
db: Session,
business_id: int,
settings: Dict[str, Any],
order: Dict[str, Any],
) -> Tuple[List[Dict[str, Any]], Dict[str, int]]:
lines: List[Dict[str, Any]] = []
mapped_products: Dict[str, int] = {}
for item in _extract_order_lines(order):
pid = _find_or_create_product(db, business_id, settings, item)
if not pid:
continue
basalam_pid = _extract_product_basalam_id(item)
if basalam_pid:
mapped_products[basalam_pid] = pid
qty = _parse_decimal(item.get("quantity") or item.get("qty") or 1, 1)
unit_price = _incoming_basalam_amount_to_rial_amount(
item.get("unit_price") or item.get("price"),
settings,
)
lines.append(
{
"product_id": int(pid),
"quantity": qty if qty > 0 else 1,
"unit_price": unit_price if unit_price >= 0 else 0,
"discount_percent": 0,
"tax_percent": 0,
"description": str(item.get("title") or item.get("name") or "").strip() or None,
}
)
return lines, mapped_products
def _sync_order_to_invoice(
db: Session,
business_id: int,
actor_user_id: int,
settings: Dict[str, Any],
order: Dict[str, Any],
) -> Dict[str, Any]:
customer = _extract_customer(order)
person_id = _find_or_create_person(db, business_id, settings, customer)
if not person_id:
return {"status": "pending_manual_person", "order_id": _order_id(order)}
lines, mapped_products = _build_invoice_lines(db, business_id, settings, order)
if not lines:
return {"status": "pending_manual_products", "order_id": _order_id(order), "person_id": person_id}
currency_id = (
db.query(Business.default_currency_id)
.filter(Business.id == int(business_id))
.scalar()
)
if not currency_id:
raise ApiError("BASALAM_CURRENCY_NOT_SET", "Business default currency is not configured.", http_status=400)
invoice_type = str(settings.get("invoice_type_on_sync") or "invoice_sales").strip()
order_id = _order_id(order)
invoice_payload: Dict[str, Any] = {
"invoice_type": invoice_type,
"person_id": int(person_id),
"currency_id": int(currency_id),
"document_date": (date.today()).isoformat(),
"lines": lines,
"description": f"Basalam order {order_id}",
"extra_info": {
"source": "basalam",
"basalam_order_id": order_id,
"tags": [settings.get("default_order_tag", "basalam")],
"basalam_order_payload": order,
},
}
if not settings.get("create_sales_invoice_on_sync", True):
return {
"status": "prepared_only",
"order_id": order_id,
"person_id": int(person_id),
"lines_count": len(lines),
}
created = create_invoice(
db=db,
business_id=int(business_id),
user_id=int(actor_user_id),
data=invoice_payload,
)
data = created.get("data") if isinstance(created, dict) else {}
result = {
"status": "synced",
"order_id": order_id,
"person_id": int(person_id),
"invoice_id": data.get("id"),
"invoice_code": data.get("code"),
}
mappings = settings.get("mappings") if isinstance(settings.get("mappings"), dict) else {"persons": {}, "products": {}}
if customer:
customer_id = str(customer.get("id") or customer.get("hash_id") or "").strip()
if customer_id:
mappings.setdefault("persons", {})[customer_id] = int(person_id)
if mapped_products:
mappings.setdefault("products", {}).update({k: int(v) for k, v in mapped_products.items()})
update_settings(db, business_id, {"mappings": mappings})
return result
def _sync_orders(
db: Session,
business_id: int,
settings: Dict[str, Any],
orders: List[Dict[str, Any]],
source: str,
event_type: str,
event_id: str,
actor_user_id: int,
) -> Dict[str, Any]:
_ensure_business_irr_only_for_basalam(db, int(business_id))
results: List[Dict[str, Any]] = []
processed = 0
skipped_duplicates = 0
for order in orders:
oid = _order_id(order)
dedupe_key = _event_dedupe_key(business_id, event_type, event_id, oid)
if dedupe_key in settings.get("recent_event_keys", []):
skipped_duplicates += 1
continue
if _is_duplicate_event(business_id, dedupe_key):
skipped_duplicates += 1
continue
result = _sync_order_to_invoice(
db=db,
business_id=int(business_id),
actor_user_id=int(actor_user_id),
settings=settings,
order=order,
)
processed += 1
results.append(result)
if result.get("status") in ("pending_manual_person", "pending_manual_products"):
_append_sync_dead_letter(
db,
int(business_id),
{
"type": "order_sync",
"subtype": result.get("status"),
"order_id": oid,
"source": source,
"event_type": event_type,
"event_id": event_id,
"person_id": result.get("person_id"),
},
)
_remember_event_in_settings(db, business_id, settings, dedupe_key)
trigger_workflows(
db=db,
business_id=int(business_id),
trigger_type="basalam.order.created",
trigger_data={
"source": source,
"event_type": event_type,
"event_id": event_id,
"order": order,
"order_id": oid,
"sync_result": result,
"tag": settings.get("default_order_tag", "basalam"),
},
)
record_basalam_metric("sync_orders_batches", 1)
record_basalam_metric("sync_orders_processed", processed)
record_basalam_metric(
"sync_orders_invoices_created",
sum(1 for r in results if r.get("status") == "synced" and r.get("invoice_id")),
)
return {"processed_orders": processed, "skipped_duplicates": skipped_duplicates, "results": results}
def _sdk_to_dict(value: Any) -> Any:
if value is None:
return None
if isinstance(value, dict):
return {k: _sdk_to_dict(v) for k, v in value.items()}
if isinstance(value, list):
return [_sdk_to_dict(v) for v in value]
dump = getattr(value, "model_dump", None)
if callable(dump):
try:
return _sdk_to_dict(dump())
except Exception:
return str(value)
return value
def _get_basalam_sdk_client(settings: Dict[str, Any]) -> Any:
api_key = str(settings.get("api_key") or "").strip()
if not api_key:
raise ApiError("BASALAM_API_KEY_REQUIRED", "Basalam API key is not configured.", http_status=400)
try:
from basalam_sdk import BasalamClient, PersonalToken
except Exception as exc:
raise ApiError(
"BASALAM_SDK_NOT_INSTALLED",
"basalam-sdk is not installed. Install package basalam-sdk in API environment.",
http_status=500,
details={"reason": str(exc)},
)
refresh_token = str(settings.get("api_refresh_token") or "").strip()
auth = PersonalToken(token=api_key, refresh_token=refresh_token)
return BasalamClient(auth=auth)
def _basalam_request(
settings: Dict[str, Any],
method: str,
path: str,
query: Optional[Dict[str, Any]] = None,
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
client = _get_basalam_sdk_client(settings)
query = query or {}
payload = payload or {}
path_norm = str(path or "").strip()
path_lc = path_norm.lower()
method_norm = str(method or "").strip().upper()
if path_lc == "/v1/pay/transactions/unverified" and method_norm == "GET":
try:
res = client.wallet.request_sync(
method="GET",
path="/v1/pay/transactions/unverified",
params={"page": query.get("page", 1), "per_page": query.get("per_page", 50)},
require_auth=True,
)
except Exception as exc:
raise ApiError(
"BASALAM_SDK_CALL_ERROR",
"Basalam SDK request failed for unverified transactions.",
http_status=502,
details={"path": path_norm, "reason": str(exc)},
)
parsed = _sdk_to_dict(res)
return parsed if isinstance(parsed, dict) else {"data": parsed}
if path_lc.startswith("/v1/pay/transactions/") and path_lc.endswith("/verify") and method_norm == "POST":
hash_id = path_norm.split("/v1/pay/transactions/", 1)[1].rsplit("/verify", 1)[0].strip()
try:
res = client.wallet.request_sync(
method="POST",
path=f"/v1/pay/transactions/{hash_id}/verify",
require_auth=True,
)
except Exception as exc:
raise ApiError(
"BASALAM_SDK_CALL_ERROR",
"Basalam SDK request failed for transaction verify.",
http_status=502,
details={"path": path_norm, "reason": str(exc)},
)
parsed = _sdk_to_dict(res)
return parsed if isinstance(parsed, dict) else {"data": parsed}
if path_lc.startswith("/v1/chats/") and path_lc.endswith("/messages") and method_norm == "POST":
chat_id_raw = path_norm.split("/v1/chats/", 1)[1].rsplit("/messages", 1)[0].strip()
try:
chat_id: Any = int(chat_id_raw)
except Exception:
chat_id = chat_id_raw
message_type = str(payload.get("message_type") or "text").strip().lower()
if message_type != "text":
try:
res = client.chat.request_sync(
method="POST",
path=f"/v1/chats/{chat_id}/messages",
json_data=payload,
require_auth=True,
)
except Exception as exc:
raise ApiError(
"BASALAM_SDK_CALL_ERROR",
"Basalam SDK request failed for non-text chat message.",
http_status=502,
details={"path": path_norm, "reason": str(exc)},
)
parsed = _sdk_to_dict(res)
return parsed if isinstance(parsed, dict) else {"data": parsed}
content = payload.get("content") if isinstance(payload.get("content"), dict) else {}
text = str(content.get("text") or payload.get("text") or "").strip()
try:
from basalam_sdk.chat.models import MessageInput, MessageRequest, MessageTypeEnum
req = MessageRequest(
chat_id=chat_id,
content=MessageInput(text=text),
message_type=MessageTypeEnum.TEXT,
)
res = client.chat.create_message_sync(request=req)
except Exception as exc:
raise ApiError(
"BASALAM_SDK_CALL_ERROR",
"Basalam SDK request failed for create chat message.",
http_status=502,
details={"path": path_norm, "reason": str(exc)},
)
parsed = _sdk_to_dict(res)
return parsed if isinstance(parsed, dict) else {"data": parsed}
raise ApiError(
"BASALAM_SDK_UNSUPPORTED_PATH",
"Requested Basalam API path is not mapped to SDK adapter yet.",
http_status=400,
details={"method": method_norm, "path": path},
)
def _map_upload_file_type(mime_type: str) -> str:
mt = str(mime_type or "").lower()
if mt.startswith("image/"):
return "chat.photo"
if mt.startswith("video/"):
return "chat.video"
if mt.startswith("audio/"):
return "chat.voice"
return "chat.file"
async def _upload_crm_file_to_basalam(
db: Session,
business_id: int,
settings: Dict[str, Any],
file_storage_id: str,
) -> Dict[str, Any]:
fs = db.get(FileStorage, str(file_storage_id).strip())
if not fs:
raise ApiError("CRM_CHAT_FILE_NOT_FOUND", "File not found", http_status=404)
if fs.business_id is not None and int(fs.business_id) != int(business_id):
raise ApiError(
"CRM_CHAT_FILE_BUSINESS_MISMATCH",
"File does not belong to this business.",
http_status=403,
)
limit_info = check_storage_limit(db, int(business_id), None)
if float(limit_info.get("total_limit_gb") or 0) <= 0:
raise ApiError(
"NO_ACTIVE_STORAGE_PLAN",
"برای ارسال پیوست از CRM به باسلام، ابتدا یک پلن ذخیره‌سازی فعال برای کسب‌وکار انتخاب کنید.",
http_status=403,
details={
"total_limit_gb": limit_info.get("total_limit_gb"),
"current_usage_gb": limit_info.get("current_usage_gb"),
},
)
storage_svc = FileStorageService(db)
try:
dl = await storage_svc.download_file(UUID(str(fs.id)))
except HTTPException as he:
detail = he.detail
if isinstance(detail, dict):
msg = str(detail.get("message") or detail.get("detail") or detail)
else:
msg = str(detail)
if he.status_code == 404:
raise ApiError("CRM_CHAT_FILE_NOT_FOUND", msg, http_status=404)
raise ApiError(
"FILE_STORAGE_READ_FAILED",
msg or "Failed to read file from storage.",
http_status=502,
details={"file_id": str(fs.id)},
)
except NotImplementedError as exc:
raise ApiError(
"STORAGE_BACKEND_NOT_SUPPORTED",
"خواندن این نوع فضای ذخیره‌سازی در سرور هنوز پیاده‌سازی نشده؛ با مدیر سیستم هماهنگ کنید.",
http_status=422,
details={"storage_type": getattr(fs, "storage_type", None), "reason": str(exc)},
)
except FileNotFoundError as exc:
raise ApiError(
"CRM_CHAT_FILE_MISSING_ON_STORAGE",
"فایل در فضای ذخیره‌سازی یافت نشد.",
http_status=404,
details={"file_id": str(fs.id), "reason": str(exc)},
)
file_content = dl.get("content")
if not isinstance(file_content, (bytes, bytearray)) or len(file_content) == 0:
raise ApiError(
"FILE_STORAGE_EMPTY_CONTENT",
"محتوای فایل خالی یا نامعتبر است.",
http_status=422,
details={"file_id": str(fs.id)},
)
client = _get_basalam_sdk_client(settings)
file_type = _map_upload_file_type(fs.mime_type)
try:
from basalam_sdk.upload.models import UserUploadFileTypeEnum
with io.BytesIO(bytes(file_content)) as bio:
uploaded = client.upload.upload_file_sync(file=bio, file_type=UserUploadFileTypeEnum(file_type))
except Exception as exc:
raise ApiError(
"BASALAM_FILE_UPLOAD_FAILED",
"Failed to upload attachment to Basalam.",
http_status=502,
details={"file_id": str(fs.id), "reason": str(exc)},
)
parsed = _sdk_to_dict(uploaded)
if not isinstance(parsed, dict):
raise ApiError("BASALAM_FILE_UPLOAD_FAILED", "Invalid upload response from Basalam.", http_status=502)
return {
"id": int(parsed.get("id")),
"url": str(parsed.get("url") or (parsed.get("urls") or {}).get("original") or ""),
"name": fs.original_name,
"type": fs.mime_type,
"size": int(fs.file_size or 0),
}
async def relay_agent_message_from_crm(
db: Session,
business_id: int,
conversation_id: int,
user_id: int,
body: Optional[str],
file_storage_id: Optional[str] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
if not settings.get("enabled"):
return {"relayed": False, "reason": "basalam_disabled"}
if not settings.get("chat_enabled", True):
return {"relayed": False, "reason": "chat_disabled"}
c = db.query(CrmChatConversation).filter(
CrmChatConversation.id == int(conversation_id),
CrmChatConversation.business_id == int(business_id),
).first()
if not c:
raise ApiError("CRM_CHAT_CONVERSATION_NOT_FOUND", "Conversation not found", http_status=404)
metadata = c.extra_metadata if isinstance(c.extra_metadata, dict) else {}
if str(metadata.get("source") or "").lower() != "basalam":
return {"relayed": False, "reason": "not_basalam_conversation"}
chat_id = str(metadata.get("basalam_chat_id") or "").strip()
if not chat_id:
return {"relayed": False, "reason": "missing_chat_id"}
text = str(body or "").strip()
attachment = None
if file_storage_id:
attachment = await _upload_crm_file_to_basalam(db, int(business_id), settings, file_storage_id)
_basalam_request(
settings=settings,
method="POST",
path=f"/v1/chats/{int(chat_id)}/messages" if chat_id.isdigit() else f"/v1/chats/{chat_id}/messages",
payload={
"message_type": "file",
"attachment": {"files": [attachment]},
},
)
if text:
_basalam_request(
settings=settings,
method="POST",
path=f"/v1/chats/{int(chat_id)}/messages" if chat_id.isdigit() else f"/v1/chats/{chat_id}/messages",
payload={
"message_type": "text",
"content": {"text": text},
},
)
trigger_workflows(
db=db,
business_id=int(business_id),
trigger_type="crm.chat.message.sent",
trigger_data={
"conversation_id": int(c.id),
"sender_role": "agent",
"body": text,
"source": "basalam",
"operator_relay": True,
"operator_relay_channel": "basalam",
"agent_user_id": int(user_id),
"basalam_chat_id": chat_id,
"file_storage_id": str(file_storage_id or "") or None,
},
user_id=int(user_id),
)
return {
"relayed": True,
"chat_id": chat_id,
"attachment_uploaded": bool(attachment),
}
def _extract_transactions(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
if isinstance(payload.get("data"), list):
return [x for x in payload["data"] if isinstance(x, dict)]
data = payload.get("data")
if isinstance(data, dict) and isinstance(data.get("data"), list):
return [x for x in data["data"] if isinstance(x, dict)]
if isinstance(payload.get("items"), list):
return [x for x in payload["items"] if isinstance(x, dict)]
return []
def _tx_is_paid_or_unverified(tx: Dict[str, Any]) -> bool:
status = tx.get("status")
if isinstance(status, dict):
sid = status.get("id")
slug = str(status.get("slug") or "").lower()
if sid in (3, 5):
return True
if slug in ("success", "unverified"):
return True
if tx.get("status_id") in (3, 5):
return True
return False
def _find_invoice_by_basalam_reference(db: Session, business_id: int, reference_id: str) -> Optional[Document]:
if not reference_id:
return None
candidates = (
db.query(Document)
.filter(
Document.business_id == int(business_id),
Document.document_type.in_(["invoice_sales", "invoice_sales_return"]),
)
.order_by(Document.id.desc())
.limit(300)
.all()
)
for doc in candidates:
extra = doc.extra_info if isinstance(doc.extra_info, dict) else {}
if str(extra.get("basalam_order_id") or "") == reference_id:
return doc
return None
def _existing_basalam_receipt_for_transaction_hash(
db: Session,
business_id: int,
hash_id: Optional[str],
) -> Optional[int]:
"""جلوگیری از ثبت دوبارهٔ رسید برای همان hash_id تراکنش باسلام."""
hid = str(hash_id or "").strip()
if not hid:
return None
rows = (
db.query(Document.id, Document.extra_info)
.filter(
Document.business_id == int(business_id),
Document.document_type == "receipt",
)
.order_by(Document.id.desc())
.limit(1500)
.all()
)
for doc_id, extra in rows:
if not isinstance(extra, dict):
continue
if str(extra.get("source") or "").lower() != "basalam":
continue
if str(extra.get("hash_id") or "").strip() == hid:
return int(doc_id)
return None
def _invoice_person_id(db: Session, invoice_id: int) -> Optional[int]:
line = (
db.query(DocumentLine)
.filter(DocumentLine.document_id == int(invoice_id), DocumentLine.person_id.isnot(None))
.first()
)
return int(line.person_id) if line and line.person_id else None
def _payment_account_line_from_settings(settings: Dict[str, Any], amount: float) -> Optional[Dict[str, Any]]:
mode = str(settings.get("payment_register_mode") or "manual_review")
if mode == "auto_bank":
bank_id = settings.get("default_bank_account_id")
if not bank_id:
return None
return {
"amount": amount,
"transaction_type": "bank",
"bank_id": int(bank_id),
"description": "Basalam payment sync",
}
if mode == "auto_cash":
cash_id = settings.get("default_cash_register_id")
if not cash_id:
return None
return {
"amount": amount,
"transaction_type": "cash_register",
"cash_register_id": int(cash_id),
"description": "Basalam payment sync",
}
return None
def _append_basalam_receipt_to_invoice_links(
db: Session,
business_id: int,
invoice_id: int,
receipt_document_id: int,
) -> None:
"""پیوند رسید باسلام به فاکتور برای مانده‌گیری و گزارش."""
inv = (
db.query(Document)
.filter(Document.id == int(invoice_id), Document.business_id == int(business_id))
.first()
)
if not inv:
return
extra = dict(inv.extra_info) if isinstance(inv.extra_info, dict) else {}
links = dict(extra.get("links") or {})
raw_ids = links.get(INVOICE_LINK_RECEIPT_PAYMENT_IDS) or []
ids: List[int] = []
for x in raw_ids:
try:
ids.append(int(x))
except (TypeError, ValueError):
continue
rid = int(receipt_document_id)
if rid not in ids:
ids.append(rid)
links[INVOICE_LINK_RECEIPT_PAYMENT_IDS] = ids
extra["links"] = links
inv.extra_info = _normalize_document_extra_info_for_storage(extra)
flag_modified(inv, "extra_info")
db.add(inv)
def _basalam_payment_reconcile_gate(
db: Session,
business_id: int,
invoice: Document,
settings: Dict[str, Any],
payment_amount: float,
) -> Optional[Dict[str, Any]]:
"""
مانع ثبت رسید در صورت بیش‌پرداخت نسبت به ماندهٔ فاکتور.
در خطای محاسبهٔ مانده، جهت جلوگیری از قطع بی‌جهت تراکنش، عبور داده می‌شود (متریک جداگانه).
"""
if not settings.get("payment_reconcile_block_overpayment", True):
return None
tol = float(settings.get("payment_reconcile_tolerance_rial") or 1.0)
remaining_val: Optional[float] = None
try:
rem = calculate_invoice_remaining(db, int(business_id), int(invoice.id))
remaining_val = float(rem.get("remaining") or 0.0)
except Exception as exc:
logger.warning(
"basalam_payment_remaining_calc_failed",
invoice_id=invoice.id,
business_id=int(business_id),
error=str(exc),
)
record_basalam_metric("payment_reconcile_remaining_calc_failed", 1)
return None
pay = float(payment_amount)
if remaining_val <= tol and pay > tol:
record_basalam_metric("payment_reconcile_blocked", 1)
return {
"status": "payment_invoice_already_settled",
"invoice_id": invoice.id,
"remaining": remaining_val,
"payment_amount": pay,
}
if pay > remaining_val + tol:
record_basalam_metric("payment_reconcile_blocked", 1)
return {
"status": "payment_exceeds_invoice_remaining",
"invoice_id": invoice.id,
"remaining": remaining_val,
"payment_amount": pay,
}
return None
def _sync_single_payment_transaction(
db: Session,
business_id: int,
actor_user_id: int,
settings: Dict[str, Any],
tx: Dict[str, Any],
) -> Dict[str, Any]:
if not _tx_is_paid_or_unverified(tx):
return {"status": "ignored_status", "hash_id": tx.get("hash_id")}
_ensure_business_irr_only_for_basalam(db, business_id)
reference_id = str(tx.get("reference_id") or "").strip()
if not reference_id:
return {"status": "missing_reference_id", "hash_id": tx.get("hash_id")}
hash_key = str(tx.get("hash_id") or "").strip()
if hash_key:
existing_receipt = _existing_basalam_receipt_for_transaction_hash(db, business_id, hash_key)
if existing_receipt:
return {
"status": "already_synced",
"receipt_payment_id": existing_receipt,
"hash_id": tx.get("hash_id"),
"reference_id": reference_id,
}
invoice = _find_invoice_by_basalam_reference(db, business_id, reference_id)
if not invoice:
return {"status": "invoice_not_found", "reference_id": reference_id}
inv_currency_code = (
db.query(Currency.code).filter(Currency.id == int(invoice.currency_id)).scalar()
)
if str(inv_currency_code or "").strip().upper() != "IRR":
return {
"status": "invoice_currency_not_irr",
"invoice_id": invoice.id,
"currency_code": inv_currency_code,
}
person_id = _invoice_person_id(db, invoice.id)
if not person_id:
return {"status": "invoice_person_not_found", "invoice_id": invoice.id}
amount = _incoming_basalam_amount_to_rial_amount(tx.get("amount"), settings)
if amount <= 0:
return {"status": "invalid_amount", "invoice_id": invoice.id}
blocked = _basalam_payment_reconcile_gate(db, business_id, invoice, settings, float(amount))
if blocked:
return blocked
account_line = _payment_account_line_from_settings(settings, amount)
if not account_line:
return {
"status": "manual_review_required",
"invoice_id": invoice.id,
"reason": "payment_register_mode or default account is not configured",
}
payload = {
"document_type": "receipt",
"document_date": date.today().isoformat(),
"currency_id": int(invoice.currency_id),
"description": f"Basalam payment {tx.get('hash_id') or ''}".strip(),
"person_lines": [
{
"person_id": int(person_id),
"amount": amount,
"description": f"Basalam reference {reference_id}",
}
],
"account_lines": [account_line],
"extra_info": {
"source": "basalam",
"basalam_transaction": tx,
"reference_id": reference_id,
"hash_id": tx.get("hash_id"),
},
}
created = create_receipt_payment(
db=db,
business_id=int(business_id),
user_id=int(actor_user_id),
data=payload,
)
rp_id = created.get("data", {}).get("id") if isinstance(created, dict) else None
if rp_id:
try:
_append_basalam_receipt_to_invoice_links(db, int(business_id), int(invoice.id), int(rp_id))
db.commit()
except Exception as exc:
logger.warning(
"basalam_receipt_invoice_link_failed",
invoice_id=invoice.id,
receipt_id=rp_id,
error=str(exc),
)
return {
"status": "synced",
"invoice_id": invoice.id,
"receipt_payment_id": rp_id,
"hash_id": tx.get("hash_id"),
"reference_id": reference_id,
}
def sync_unverified_payments(
db: Session,
business_id: int,
user_id: Optional[int],
verify_remote: Optional[bool] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
if not settings.get("enabled"):
raise ApiError("BASALAM_DISABLED", "Basalam integration is disabled for this business.", http_status=409)
if not settings.get("payment_sync_enabled", True):
raise ApiError("BASALAM_PAYMENT_SYNC_DISABLED", "Payment sync is disabled in settings.", http_status=409)
actor_user_id = _resolve_actor_user_id(db, business_id, user_id)
fetched = _basalam_request(
settings=settings,
method="GET",
path="/v1/pay/transactions/unverified",
query={"page": 1, "per_page": 50},
)
txs = _extract_transactions(fetched)
if not txs:
return {"accepted": True, "processed": 0, "synced": 0, "results": []}
_ensure_business_irr_only_for_basalam(db, int(business_id))
record_basalam_metric("payment_sync_batches", 1)
should_verify = settings.get("payment_verify_remote", True) if verify_remote is None else bool(verify_remote)
results: List[Dict[str, Any]] = []
synced = 0
for tx in txs:
hash_id = str(tx.get("hash_id") or "").strip()
dedupe_key = _event_dedupe_key(business_id, "payment.unverified", hash_id or _event_id(tx))
if _is_duplicate_event(business_id, dedupe_key):
results.append({"status": "duplicate", "hash_id": hash_id})
continue
result = _sync_single_payment_transaction(
db=db,
business_id=int(business_id),
actor_user_id=int(actor_user_id),
settings=settings,
tx=tx,
)
if result.get("status") == "synced":
synced += 1
record_basalam_metric("payment_sync_receipt_created", 1)
trigger_workflows(
db=db,
business_id=int(business_id),
trigger_type="basalam.order.paid",
trigger_data={
"source": "basalam",
"event_type": "payment.unverified",
"transaction": tx,
"sync_result": result,
},
)
if should_verify and hash_id:
try:
_basalam_request(
settings=settings,
method="POST",
path=f"/v1/pay/transactions/{hash_id}/verify",
)
result["verified_remotely"] = True
except ApiError as exc:
result["verified_remotely"] = False
result["verify_error"] = exc.code
results.append(result)
if result.get("status") in _PAYMENT_DLQ_STATUSES:
snap: Dict[str, Any] = {}
if isinstance(tx, dict):
for k in ("hash_id", "reference_id", "amount"):
if tx.get(k) is not None:
snap[k] = tx.get(k)
details = {k: v for k, v in result.items() if k != "status"}
_append_sync_dead_letter(
db,
int(business_id),
{
"type": "payment_sync",
"subtype": result.get("status"),
"details": details,
"transaction": snap,
},
)
record_basalam_metric("payment_sync_dlq_appended", 1)
_remember_event_in_settings(db, business_id, settings, dedupe_key)
logger.info(
"basalam_payment_sync_completed",
business_id=int(business_id),
processed=len(txs),
synced=synced,
results=len(results),
)
return {"accepted": True, "processed": len(txs), "synced": synced, "results": results}
def _chat_token_hash(business_id: int, basalam_chat_id: str) -> str:
base = f"basalam:{business_id}:{basalam_chat_id}"
return hashlib.sha256(base.encode("utf-8")).hexdigest()
def _get_or_create_basalam_widget(db: Session, business_id: int, settings: Dict[str, Any]) -> CrmChatWidget:
widget_id = settings.get("crm_widget_id")
if widget_id:
widget = db.query(CrmChatWidget).filter(CrmChatWidget.id == int(widget_id)).first()
if widget and widget.business_id == int(business_id):
return widget
widget = (
db.query(CrmChatWidget)
.filter(CrmChatWidget.business_id == int(business_id), CrmChatWidget.name == "Basalam Chat Bridge")
.first()
)
if widget:
update_settings(db, business_id, {"crm_widget_id": int(widget.id)})
return widget
widget = crm_chat_service.create_widget(
db=db,
business_id=int(business_id),
name="Basalam Chat Bridge",
allowed_origins=[],
settings={"channel": "basalam", "managed_by": "basalam_connector"},
is_active=True,
)
update_settings(db, business_id, {"crm_widget_id": int(widget.id)})
return widget
def _get_or_create_basalam_conversation(
db: Session,
business_id: int,
settings: Dict[str, Any],
basalam_chat_id: str,
customer: Dict[str, Any],
) -> CrmChatConversation:
th = _chat_token_hash(business_id, basalam_chat_id)
existing = (
db.query(CrmChatConversation)
.filter(
CrmChatConversation.business_id == int(business_id),
CrmChatConversation.visitor_token_hash == th,
)
.first()
)
if existing:
return existing
widget = _get_or_create_basalam_widget(db, business_id, settings)
full_name = _extract_person_name(customer)
parts = [p for p in full_name.split(" ") if p]
first_name = parts[0] if parts else "Basalam"
last_name = " ".join(parts[1:]) if len(parts) > 1 else "User"
email = str(customer.get("email") or "").strip().lower() or f"basalam-{basalam_chat_id}@local.invalid"
phone = _normalize_mobile(customer.get("mobile") or customer.get("phone")) or "00000000000"
person_id = _find_or_create_person(db, business_id, settings, customer)
c = CrmChatConversation(
business_id=int(business_id),
widget_id=int(widget.id),
status="open",
visitor_first_name=first_name[:120],
visitor_last_name=last_name[:120],
visitor_email=email[:255],
visitor_phone=phone[:64],
visitor_token_hash=th,
page_url=None,
extra_metadata={"source": "basalam", "basalam_chat_id": basalam_chat_id},
person_id=int(person_id) if person_id else None,
assigned_to_user_id=None,
)
db.add(c)
db.commit()
db.refresh(c)
return c
def sync_inbound_chat_messages(
db: Session,
business_id: int,
payload: Dict[str, Any],
user_id: Optional[int] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
if not settings.get("enabled"):
raise ApiError("BASALAM_DISABLED", "Basalam integration is disabled for this business.", http_status=409)
if not settings.get("chat_enabled", True):
raise ApiError("BASALAM_CHAT_DISABLED", "Chat bridge is disabled in settings.", http_status=409)
chat_id = str(payload.get("chat_id") or payload.get("conversation_id") or "").strip()
if not chat_id:
raise ApiError("BASALAM_CHAT_ID_REQUIRED", "chat_id is required.", http_status=400)
messages = payload.get("messages")
if isinstance(messages, dict):
messages = [messages]
if not isinstance(messages, list):
message_body = str(payload.get("body") or payload.get("text") or "").strip()
if not message_body:
raise ApiError("BASALAM_MESSAGE_REQUIRED", "message body is required.", http_status=400)
messages = [{"body": message_body}]
customer = payload.get("customer") if isinstance(payload.get("customer"), dict) else {}
conversation = _get_or_create_basalam_conversation(db, business_id, settings, chat_id, customer)
processed = 0
created_messages: List[int] = []
actor_user_id = _resolve_actor_user_id(db, business_id, user_id)
for msg in messages:
if not isinstance(msg, dict):
continue
body = str(msg.get("body") or msg.get("text") or "").strip()
if not body:
attachment = msg.get("attachment") if isinstance(msg.get("attachment"), dict) else {}
files = attachment.get("files") if isinstance(attachment.get("files"), list) else []
if files:
first_file = files[0] if isinstance(files[0], dict) else {}
file_url = str(first_file.get("url") or "").strip()
file_name = str(first_file.get("name") or "attachment").strip()
body = f"📎 {file_name}" + (f"\n{file_url}" if file_url else "")
if not body:
continue
ext_msg_id = str(msg.get("id") or msg.get("message_id") or "")
dedupe_key = _event_dedupe_key(business_id, "chat.message.received", f"{chat_id}:{ext_msg_id or body[:30]}")
if _is_duplicate_event(business_id, dedupe_key):
continue
row = CrmChatMessage(
conversation_id=int(conversation.id),
sender_role="visitor",
body=body[:8000],
user_id=None,
file_storage_id=None,
)
db.add(row)
conversation.last_message_at = datetime.utcnow()
conversation.updated_at = datetime.utcnow()
db.commit()
db.refresh(row)
processed += 1
created_messages.append(int(row.id))
trigger_workflows(
db=db,
business_id=int(business_id),
trigger_type="crm.chat.message.received",
trigger_data={
"conversation_id": int(conversation.id),
"widget_id": int(conversation.widget_id),
"message_id": int(row.id),
"body": body[:8000],
"sender_role": "visitor",
"source": "basalam",
"basalam_chat_id": chat_id,
},
user_id=int(actor_user_id),
)
trigger_workflows(
db=db,
business_id=int(business_id),
trigger_type="basalam.chat.message.received",
trigger_data={
"source": "basalam",
"chat_id": chat_id,
"message_id": ext_msg_id,
"body": body[:8000],
"crm_conversation_id": int(conversation.id),
},
user_id=int(actor_user_id),
)
_remember_event_in_settings(db, business_id, settings, dedupe_key)
return {
"accepted": True,
"chat_id": chat_id,
"crm_conversation_id": int(conversation.id),
"processed_messages": processed,
"message_ids": created_messages,
}
async def send_chat_reply_to_basalam(
db: Session,
business_id: int,
conversation_id: int,
body: str,
user_id: int,
basalam_chat_id: Optional[str] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
if not settings.get("enabled"):
raise ApiError("BASALAM_DISABLED", "Basalam integration is disabled for this business.", http_status=409)
if not settings.get("chat_enabled", True):
raise ApiError("BASALAM_CHAT_DISABLED", "Chat bridge is disabled in settings.", http_status=409)
c = db.query(CrmChatConversation).filter(
CrmChatConversation.id == int(conversation_id),
CrmChatConversation.business_id == int(business_id),
).first()
if not c:
raise ApiError("CRM_CHAT_CONVERSATION_NOT_FOUND", "Conversation not found", http_status=404)
metadata = c.extra_metadata if isinstance(c.extra_metadata, dict) else {}
chat_id = str(
basalam_chat_id
or metadata.get("basalam_chat_id")
or ""
).strip()
if not chat_id:
raise ApiError("BASALAM_CHAT_ID_REQUIRED", "Basalam chat id not found for this conversation.", http_status=400)
text = str(body or "").strip()
if not text:
raise ApiError("BASALAM_MESSAGE_REQUIRED", "Message body is required.", http_status=400)
remote_payload = {
"message_type": "text",
"content": {"text": text},
}
remote_resp = _basalam_request(
settings=settings,
method="POST",
path=f"/v1/chats/{int(chat_id)}/messages" if chat_id.isdigit() else f"/v1/chats/{chat_id}/messages",
payload=remote_payload,
)
local_msg = await crm_chat_service.post_agent_message(
db=db,
business_id=int(business_id),
conversation_id=int(conversation_id),
body=text,
user_id=int(user_id),
automation_context={
"operator_relay": True,
"operator_relay_channel": "basalam",
"basalam_chat_id": chat_id,
},
)
return {
"accepted": True,
"chat_id": chat_id,
"crm_conversation_id": int(conversation_id),
"crm_message": local_msg,
"basalam_response": remote_resp,
}
def process_webhook(
db: Session,
business_id: int,
payload: Dict[str, Any],
raw_body: bytes,
signature: Optional[str],
user_id: Optional[int] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
if not settings.get("enabled"):
record_basalam_metric("webhook_disabled", 1)
raise ApiError("BASALAM_DISABLED", "Basalam integration is disabled for this business.", http_status=409)
if not _verify_webhook_signature(raw_body, signature, str(settings.get("webhook_secret") or "")):
record_basalam_metric("webhook_signature_invalid", 1)
raise ApiError("BASALAM_INVALID_SIGNATURE", "Invalid webhook signature.", http_status=401)
record_basalam_metric("webhook_received", 1)
event_type = _event_type(payload)
event_id = _event_id(payload)
trigger_key = _workflow_event_key(event_type)
actor_user_id = _resolve_actor_user_id(db, business_id, user_id)
dedupe_key = _event_dedupe_key(business_id, event_type, event_id)
if dedupe_key in settings.get("recent_event_keys", []) or _is_duplicate_event(business_id, dedupe_key):
logger.info(
"basalam_webhook_duplicate",
business_id=int(business_id),
event_type=event_type,
event_id=str(event_id),
)
record_basalam_metric("webhook_duplicate", 1)
return {"accepted": True, "event_type": event_type, "event_id": event_id, "duplicate": True}
trigger_workflows(
db=db,
business_id=int(business_id),
trigger_type=trigger_key,
trigger_data={
"source": "basalam",
"event_type": event_type,
"event_id": event_id,
"payload": payload,
"received_at": datetime.utcnow().isoformat(),
},
)
sync_info: Dict[str, Any] = {"processed_orders": 0, "skipped_duplicates": 0, "results": []}
chat_info: Dict[str, Any] = {"processed_messages": 0}
if settings.get("order_sync_enabled"):
orders = _extract_order_candidates(payload)
if orders:
sync_info = _sync_orders(
db=db,
business_id=int(business_id),
settings=settings,
orders=orders,
source="webhook",
event_type=event_type,
event_id=event_id,
actor_user_id=actor_user_id,
)
if settings.get("chat_enabled", True) and "chat" in str(event_type or "").lower():
inbound_body = payload.get("message") if isinstance(payload.get("message"), dict) else payload
inferred_chat_id = payload.get("chat_id") or payload.get("conversation_id")
if not inferred_chat_id and isinstance(payload.get("chat"), dict):
inferred_chat_id = payload.get("chat", {}).get("id")
if not inferred_chat_id and isinstance(inbound_body, dict):
inferred_chat_id = inbound_body.get("chat_id")
chat_payload: Dict[str, Any] = {
"chat_id": inferred_chat_id
}
body = None
if isinstance(inbound_body, dict):
body = inbound_body.get("text") or inbound_body.get("body") or inbound_body.get("content")
msg_id = inbound_body.get("id") or inbound_body.get("message_id")
if body:
chat_payload["messages"] = [{"id": msg_id, "body": body}]
sender = payload.get("sender") if isinstance(payload.get("sender"), dict) else {}
if not sender and isinstance(inbound_body, dict) and isinstance(inbound_body.get("sender"), dict):
sender = inbound_body.get("sender")
customer = payload.get("customer") if isinstance(payload.get("customer"), dict) else {}
if sender:
customer = sender
if customer:
chat_payload["customer"] = customer
if chat_payload.get("chat_id"):
chat_info = sync_inbound_chat_messages(
db=db,
business_id=int(business_id),
payload=chat_payload,
user_id=actor_user_id,
)
update_settings(
db,
business_id,
{
"last_webhook_event_at": datetime.utcnow().isoformat(),
"last_webhook_event_type": event_type,
},
)
_remember_event_in_settings(db, business_id, settings, dedupe_key)
logger.info(
"basalam_webhook_processed",
business_id=int(business_id),
event_type=event_type,
workflow_trigger=trigger_key,
processed_orders=int(sync_info.get("processed_orders") or 0),
skipped_order_duplicates=int(sync_info.get("skipped_duplicates") or 0),
processed_messages=int(chat_info.get("processed_messages") or 0),
)
record_basalam_metric("webhook_processed_ok", 1)
return {
"accepted": True,
"event_type": event_type,
"event_id": event_id,
"workflow_trigger": trigger_key,
**sync_info,
**chat_info,
}
def manual_sync_orders(
db: Session,
business_id: int,
payload: Dict[str, Any],
user_id: Optional[int] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
if not settings.get("enabled"):
raise ApiError("BASALAM_DISABLED", "Basalam integration is disabled for this business.", http_status=409)
if not settings.get("order_sync_enabled"):
raise ApiError("BASALAM_ORDER_SYNC_DISABLED", "Order sync is disabled in Basalam settings.", http_status=409)
orders = payload.get("orders")
if not isinstance(orders, list):
raise ApiError("BASALAM_INVALID_SYNC_PAYLOAD", "orders must be an array.", http_status=400)
actor_user_id = _resolve_actor_user_id(db, business_id, user_id)
event_type = "order.created"
event_id = f"manual-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
sync_info = _sync_orders(
db=db,
business_id=int(business_id),
settings=settings,
orders=[o for o in orders if isinstance(o, dict)],
source="manual",
event_type=event_type,
event_id=event_id,
actor_user_id=actor_user_id,
)
return {
"accepted": True,
"event_id": event_id,
"tag": settings.get("default_order_tag", "basalam"),
**sync_info,
}
def manual_sync_products(
db: Session,
business_id: int,
payload: Dict[str, Any],
user_id: Optional[int] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
if not settings.get("enabled"):
raise ApiError("BASALAM_DISABLED", "Basalam integration is disabled for this business.", http_status=409)
if not settings.get("product_sync_enabled", True):
raise ApiError("BASALAM_PRODUCT_SYNC_DISABLED", "Product sync is disabled in settings.", http_status=409)
_ensure_business_irr_only_for_basalam(db, int(business_id))
products = payload.get("products")
if isinstance(products, dict):
products = [products]
if not isinstance(products, list):
raise ApiError("BASALAM_INVALID_SYNC_PAYLOAD", "products must be an array.", http_status=400)
actor_user_id = _resolve_actor_user_id(db, business_id, user_id)
processed = 0
created_or_matched = 0
manual_review = 0
results: List[Dict[str, Any]] = []
pending_reviews = settings.get("pending_product_reviews") if isinstance(settings.get("pending_product_reviews"), list) else []
pending_conflicts = (
settings.get("pending_product_conflicts")
if isinstance(settings.get("pending_product_conflicts"), list)
else []
)
variant_strategy = str(settings.get("product_variant_strategy") or "manual_review")
for p in products:
if not isinstance(p, dict):
continue
if p.get("variants") and variant_strategy == "manual_review":
review_row = {
"conflict_id": _new_conflict_id("variant"),
"type": "variant_conflict",
"direction": "pull",
"reason": "remote_has_variants",
"basalam_product_id": _extract_product_basalam_id(p),
"remote_title": p.get("title") or p.get("name"),
"payload": p,
"created_at": datetime.utcnow().isoformat(),
}
pending_conflicts.append(review_row)
manual_review += 1
processed += 1
results.append({"status": "manual_review_required", "reason": "remote_has_variants"})
continue
basalam_pid = _extract_product_basalam_id(p)
line_like = {
"product_id": basalam_pid,
"product_name": p.get("title") or p.get("name") or p.get("product_name"),
"sku": p.get("sku") or p.get("code"),
"unit_price": p.get("price") or p.get("unit_price"),
"unit": p.get("unit"),
"description": p.get("description"),
}
product_id = _find_or_create_product(db, business_id, settings, line_like)
processed += 1
if product_id:
created_or_matched += 1
results.append(
{
"status": "synced",
"basalam_product_id": basalam_pid,
"product_id": int(product_id),
}
)
else:
manual_review += 1
review_row = {
"type": "product",
"basalam_product_id": basalam_pid,
"payload": p,
"created_at": datetime.utcnow().isoformat(),
}
pending_reviews.append(review_row)
results.append({"status": "manual_review_required", "basalam_product_id": basalam_pid})
trigger_workflows(
db=db,
business_id=int(business_id),
trigger_type="basalam.webhook.received",
trigger_data={
"source": "basalam",
"event_type": "product.manual_review_required",
"payload": p,
"review_row": review_row,
},
user_id=int(actor_user_id),
)
update_settings(
db,
business_id,
{
"pending_product_reviews": pending_reviews[-200:],
"pending_product_conflicts": pending_conflicts[-300:],
},
)
return {
"accepted": True,
"processed_products": processed,
"synced_products": created_or_matched,
"manual_review_products": manual_review,
"results": results,
}
def _get_local_product(
db: Session,
business_id: int,
item: Dict[str, Any],
) -> Optional[Product]:
local_id = item.get("local_product_id") or item.get("product_id")
if str(local_id or "").isdigit():
p = db.query(Product).filter(Product.business_id == int(business_id), Product.id == int(local_id)).first()
if p:
return p
code = str(item.get("code") or item.get("sku") or "").strip()
if code:
p = db.query(Product).filter(Product.business_id == int(business_id), Product.code == code).first()
if p:
return p
name = str(item.get("name") or "").strip()
if name:
return db.query(Product).filter(Product.business_id == int(business_id), Product.name == name).first()
return None
def _build_basalam_product_request_from_local(
local_product: Product,
item: Dict[str, Any],
settings: Dict[str, Any],
) -> Any:
try:
from basalam_sdk.core.models import ProductRequestSchema
except Exception as exc:
raise ApiError(
"BASALAM_SDK_NOT_INSTALLED",
"basalam-sdk is not installed. Install package basalam-sdk in API environment.",
http_status=500,
details={"reason": str(exc)},
)
stock = item.get("stock")
if stock is None:
stock = settings.get("default_basalam_stock", 1)
try:
stock_int = max(0, int(stock))
except Exception:
stock_int = 1
price_src = item.get("primary_price")
if price_src is None:
price_src = local_product.base_sales_price
price_wire = _internal_rial_amount_to_basalam_wire(price_src, settings)
price_int = int(round(price_wire))
req_payload: Dict[str, Any] = {
"name": str(item.get("name") or local_product.name or "").strip() or f"Product {local_product.id}",
"brief": str(item.get("brief") or "")[:200] or None,
"description": str(item.get("description") or local_product.description or "") or None,
"primary_price": price_int if price_int > 0 else None,
"stock": stock_int,
"sku": str(item.get("sku") or local_product.code or "").strip() or None,
}
category_id = item.get("category_id")
if category_id is None:
category_id = settings.get("default_basalam_category_id")
if str(category_id or "").isdigit():
req_payload["category_id"] = int(category_id)
clean = {k: v for k, v in req_payload.items() if v is not None}
return ProductRequestSchema(**clean)
def publish_products_to_basalam(
db: Session,
business_id: int,
payload: Dict[str, Any],
user_id: Optional[int] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
if not settings.get("enabled"):
raise ApiError("BASALAM_DISABLED", "Basalam integration is disabled for this business.", http_status=409)
_ensure_business_irr_only_for_basalam(db, int(business_id))
products = payload.get("products")
if isinstance(products, dict):
products = [products]
if not isinstance(products, list):
raise ApiError("BASALAM_INVALID_SYNC_PAYLOAD", "products must be an array.", http_status=400)
vendor_id_raw = payload.get("vendor_id") or settings.get("default_basalam_vendor_id")
if not str(vendor_id_raw or "").isdigit():
raise ApiError(
"BASALAM_VENDOR_ID_REQUIRED",
"vendor_id is required for product publish (or set default_basalam_vendor_id).",
http_status=400,
)
vendor_id = int(vendor_id_raw)
actor_user_id = _resolve_actor_user_id(db, business_id, user_id)
client = _get_basalam_sdk_client(settings)
mappings = settings.get("mappings", {}).get("products", {})
local_to_remote: Dict[str, str] = {}
for k, v in mappings.items():
local_to_remote[str(v)] = str(k)
retry_queue = (
settings.get("pending_product_publish_retries")
if isinstance(settings.get("pending_product_publish_retries"), list)
else []
)
conflict_queue = (
settings.get("pending_product_conflicts")
if isinstance(settings.get("pending_product_conflicts"), list)
else []
)
price_strategy = str(settings.get("product_conflict_price_strategy") or "local_wins")
stock_strategy = str(settings.get("product_conflict_stock_strategy") or "local_wins")
variant_strategy = str(settings.get("product_variant_strategy") or "manual_review")
processed = 0
published = 0
results: List[Dict[str, Any]] = []
for item in products:
if not isinstance(item, dict):
continue
local_product = _get_local_product(db, business_id, item)
if not local_product:
results.append({"status": "local_product_not_found", "input": item})
continue
if (item.get("variants") or (local_product.inventory_mode or "bulk") == "unique") and variant_strategy == "manual_review":
processed += 1
conflict_queue.append(
{
"conflict_id": _new_conflict_id("variant"),
"type": "variant_conflict",
"direction": "push",
"reason": "local_has_variants_or_unique_mode",
"local_product_id": int(local_product.id),
"local_product_name": local_product.name,
"local_product_code": local_product.code,
"payload": item,
"created_at": datetime.utcnow().isoformat(),
}
)
results.append({"status": "manual_review_required", "local_product_id": int(local_product.id), "reason": "variant_conflict"})
continue
req = _build_basalam_product_request_from_local(local_product, item, settings)
remote_id = str(item.get("basalam_product_id") or local_to_remote.get(str(local_product.id)) or "").strip()
try:
req_payload = req.model_dump(exclude_none=True)
if remote_id.isdigit() and (price_strategy != "local_wins" or stock_strategy != "local_wins"):
remote_obj = client.core.get_product_sync(product_id=int(remote_id))
remote_dict = _sdk_to_dict(remote_obj)
remote_price, remote_stock = _extract_remote_price_stock(remote_dict if isinstance(remote_dict, dict) else {})
local_price = req_payload.get("primary_price")
local_stock = req_payload.get("stock")
price_diff = remote_price is not None and local_price is not None and int(remote_price) != int(local_price)
stock_diff = remote_stock is not None and local_stock is not None and int(remote_stock) != int(local_stock)
if (price_diff and price_strategy == "manual_review") or (stock_diff and stock_strategy == "manual_review"):
processed += 1
conflict_queue.append(
{
"conflict_id": _new_conflict_id("field"),
"type": "field_conflict",
"direction": "push",
"local_product_id": int(local_product.id),
"local_product_name": local_product.name,
"local_product_code": local_product.code,
"basalam_product_id": int(remote_id),
"remote_title": remote_dict.get("title") if isinstance(remote_dict, dict) else None,
"local_price": local_price,
"remote_price": remote_price,
"local_stock": local_stock,
"remote_stock": remote_stock,
"created_at": datetime.utcnow().isoformat(),
}
)
results.append({"status": "manual_review_required", "local_product_id": int(local_product.id), "reason": "field_conflict"})
continue
if price_diff and price_strategy == "remote_wins":
req_payload["primary_price"] = int(remote_price)
if stock_diff and stock_strategy == "remote_wins":
req_payload["stock"] = int(remote_stock)
from basalam_sdk.core.models import ProductRequestSchema
req = ProductRequestSchema(**req_payload)
if remote_id.isdigit():
resp = client.core.update_product_sync(product_id=int(remote_id), request=req)
action = "updated"
else:
resp = client.core.create_product_sync(vendor_id=vendor_id, request=req)
action = "created"
parsed = _sdk_to_dict(resp)
pdata = parsed.get("data") if isinstance(parsed, dict) and isinstance(parsed.get("data"), dict) else parsed
resolved_remote_id = str((pdata or {}).get("id") or remote_id or "").strip()
if resolved_remote_id:
mappings[resolved_remote_id] = int(local_product.id)
processed += 1
published += 1
result_row = {
"status": action,
"local_product_id": int(local_product.id),
"basalam_product_id": resolved_remote_id or None,
}
results.append(result_row)
trigger_workflows(
db=db,
business_id=int(business_id),
trigger_type="basalam.order.updated",
trigger_data={
"source": "basalam",
"event_type": "product.publish",
"action": action,
"local_product_id": int(local_product.id),
"basalam_product_id": resolved_remote_id or None,
},
user_id=int(actor_user_id),
)
except Exception as exc:
processed += 1
retry_queue.append(
{
"local_product_id": int(local_product.id),
"vendor_id": int(vendor_id),
"payload": item,
"error": str(exc),
"created_at": datetime.utcnow().isoformat(),
}
)
results.append(
{
"status": "failed",
"local_product_id": int(local_product.id),
"reason": str(exc),
}
)
base_mappings = settings.get("mappings", {}) if isinstance(settings.get("mappings"), dict) else {}
base_mappings["products"] = mappings
update_settings(
db,
business_id,
{
"mappings": base_mappings,
"last_product_push_at": datetime.utcnow().isoformat(),
"pending_product_publish_retries": retry_queue[-300:],
"pending_product_conflicts": conflict_queue[-300:],
},
)
return {
"accepted": True,
"processed_products": processed,
"published_products": published,
"results": results,
}
def _extract_basalam_products(payload: Dict[str, Any]) -> List[Dict[str, Any]]:
if isinstance(payload.get("data"), list):
return [x for x in payload.get("data", []) if isinstance(x, dict)]
data = payload.get("data")
if isinstance(data, dict) and isinstance(data.get("data"), list):
return [x for x in data.get("data", []) if isinstance(x, dict)]
if isinstance(payload.get("items"), list):
return [x for x in payload.get("items", []) if isinstance(x, dict)]
return []
def _extract_remote_price_stock(remote_payload: Dict[str, Any]) -> Tuple[Optional[int], Optional[int]]:
price_val = remote_payload.get("primary_price")
if price_val is None:
price_val = remote_payload.get("price")
try:
price_int = int(_parse_decimal(price_val, 0)) if price_val is not None else None
except Exception:
price_int = None
stock_int: Optional[int] = None
inv = remote_payload.get("inventory")
if isinstance(inv, dict):
for k in ("stock", "available", "quantity", "total"):
if inv.get(k) is not None:
try:
stock_int = int(inv.get(k))
break
except Exception:
pass
if stock_int is None and remote_payload.get("stock") is not None:
try:
stock_int = int(remote_payload.get("stock"))
except Exception:
stock_int = None
return price_int, stock_int
def _new_conflict_id(prefix: str = "pc") -> str:
return f"{prefix}-{datetime.utcnow().strftime('%Y%m%d%H%M%S%f')}"
def _append_sync_dead_letter(db: Session, business_id: int, entry: Dict[str, Any]) -> None:
settings = get_settings(db, business_id)
q = settings.get("sync_dead_letter")
queue = [x for x in q if isinstance(x, dict)] if isinstance(q, list) else []
row = dict(entry)
row.setdefault("created_at", datetime.utcnow().isoformat())
row.setdefault("dlq_id", _new_conflict_id("dlq"))
queue.append(row)
update_settings(db, business_id, {"sync_dead_letter": queue[-SYNC_DEAD_LETTER_MAX:]})
def list_sync_dead_letter(
db: Session,
business_id: int,
*,
limit: int = 50,
offset: int = 0,
item_type: Optional[str] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
q = [x for x in settings.get("sync_dead_letter", []) if isinstance(x, dict)]
if item_type:
t = str(item_type).strip().lower()
q = [x for x in q if str(x.get("type") or "").lower() == t]
total = len(q)
lo = max(0, int(offset))
hi = lo + max(1, min(200, int(limit)))
return {"items": q[lo:hi], "total": total, "limit": limit, "offset": lo}
def clear_sync_dead_letter(
db: Session,
business_id: int,
*,
mode: str = "all",
dlq_ids: Optional[List[Any]] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
q = [x for x in settings.get("sync_dead_letter", []) if isinstance(x, dict)]
m = str(mode or "").strip().lower()
if m == "all":
update_settings(db, business_id, {"sync_dead_letter": []})
return {"accepted": True, "cleared_count": len(q), "remaining_count": 0, "mode": "all"}
if m == "ids":
ids = dlq_ids if isinstance(dlq_ids, list) else []
id_set = {str(i).strip() for i in ids if i is not None and str(i).strip()}
if not id_set:
return {"accepted": True, "cleared_count": 0, "remaining_count": len(q), "mode": "ids"}
remaining = [x for x in q if str(x.get("dlq_id") or "").strip() not in id_set]
cleared = len(q) - len(remaining)
update_settings(db, business_id, {"sync_dead_letter": remaining[-SYNC_DEAD_LETTER_MAX:]})
return {
"accepted": True,
"cleared_count": cleared,
"remaining_count": len(remaining),
"mode": "ids",
}
raise ApiError(
"BASALAM_DLQ_INVALID_CLEAR_MODE",
"clear mode must be 'all' or 'ids'.",
http_status=400,
)
def pull_products_from_basalam(
db: Session,
business_id: int,
payload: Dict[str, Any],
user_id: Optional[int] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
if not settings.get("enabled"):
raise ApiError("BASALAM_DISABLED", "Basalam integration is disabled for this business.", http_status=409)
if not settings.get("product_sync_enabled", True):
raise ApiError("BASALAM_PRODUCT_SYNC_DISABLED", "Product sync is disabled in settings.", http_status=409)
page = int(payload.get("page") or 1)
per_page = int(payload.get("per_page") or 50)
params: Dict[str, Any] = {"page": max(1, page), "per_page": max(1, min(200, per_page))}
if payload.get("created_at"):
params["created_at"] = payload.get("created_at")
client = _get_basalam_sdk_client(settings)
try:
remote = client.core.request_sync(method="GET", path="/v1/products", params=params, require_auth=True)
except Exception as exc:
raise ApiError(
"BASALAM_SDK_CALL_ERROR",
"Basalam SDK request failed for products pull.",
http_status=502,
details={"reason": str(exc)},
)
parsed = _sdk_to_dict(remote)
remote_payload = parsed if isinstance(parsed, dict) else {"data": parsed}
products = _extract_basalam_products(remote_payload)
sync_result = manual_sync_products(
db=db,
business_id=int(business_id),
payload={"products": products},
user_id=user_id,
)
update_settings(
db,
business_id,
{
"last_product_pull_at": datetime.utcnow().isoformat(),
},
)
return {
"accepted": True,
"fetched_products": len(products),
**sync_result,
}
def push_products_incremental(
db: Session,
business_id: int,
payload: Dict[str, Any],
user_id: Optional[int] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
if not settings.get("enabled"):
raise ApiError("BASALAM_DISABLED", "Basalam integration is disabled for this business.", http_status=409)
if not settings.get("product_sync_enabled", True):
raise ApiError("BASALAM_PRODUCT_SYNC_DISABLED", "Product sync is disabled in settings.", http_status=409)
since_minutes = int(payload.get("since_minutes") or 120)
limit = int(payload.get("limit") or 50)
if since_minutes < 1:
since_minutes = 1
if limit < 1:
limit = 1
if limit > 500:
limit = 500
cutoff = datetime.utcnow() - timedelta(minutes=since_minutes)
rows = (
db.query(Product)
.filter(Product.business_id == int(business_id), Product.updated_at >= cutoff)
.order_by(Product.updated_at.desc())
.limit(limit)
.all()
)
products_payload: List[Dict[str, Any]] = []
for p in rows:
products_payload.append(
{
"local_product_id": int(p.id),
"name": p.name,
"description": p.description,
"sku": p.code,
"primary_price": int(_parse_decimal(p.base_sales_price, 0)),
"stock": int(payload.get("stock") or settings.get("default_basalam_stock", 1)),
}
)
out = publish_products_to_basalam(
db=db,
business_id=int(business_id),
payload={
"vendor_id": payload.get("vendor_id") or settings.get("default_basalam_vendor_id"),
"products": products_payload,
},
user_id=user_id,
)
return {"accepted": True, "candidates": len(rows), **out}
def retry_failed_product_publishes(
db: Session,
business_id: int,
payload: Dict[str, Any],
user_id: Optional[int] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
queue = settings.get("pending_product_publish_retries")
if not isinstance(queue, list) or not queue:
return {"accepted": True, "retried": 0, "published_products": 0, "remaining_queue": 0}
limit = int(payload.get("limit") or 20)
limit = max(1, min(100, limit))
to_retry = [x for x in queue[:limit] if isinstance(x, dict)]
remaining = [x for x in queue[limit:] if isinstance(x, dict)]
merged_results: List[Dict[str, Any]] = []
published_total = 0
for item in to_retry:
local_id = item.get("local_product_id")
publish_payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
if local_id and "local_product_id" not in publish_payload:
publish_payload["local_product_id"] = local_id
out = publish_products_to_basalam(
db=db,
business_id=int(business_id),
payload={
"vendor_id": item.get("vendor_id") or payload.get("vendor_id") or settings.get("default_basalam_vendor_id"),
"products": [publish_payload],
},
user_id=user_id,
)
merged_results.extend(out.get("results", []))
published_total += int(out.get("published_products") or 0)
latest = get_settings(db, business_id)
latest_q = latest.get("pending_product_publish_retries")
if not isinstance(latest_q, list):
latest_q = []
update_settings(
db,
business_id,
{"pending_product_publish_retries": (remaining + latest_q)[-300:]},
)
return {
"accepted": True,
"retried": len(to_retry),
"published_products": published_total,
"remaining_queue": len(remaining),
"results": merged_results,
}
def list_product_conflicts(
db: Session,
business_id: int,
*,
conflict_type: Optional[str] = None,
direction: Optional[str] = None,
search: Optional[str] = None,
sort_by: Optional[str] = None,
sort_dir: Optional[str] = None,
limit: int = 25,
offset: int = 0,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
q = settings.get("pending_product_conflicts")
items = [dict(x) for x in q if isinstance(x, dict)] if isinstance(q, list) else []
for i, row in enumerate(items):
if not row.get("conflict_id"):
row["conflict_id"] = f"legacy-{i + 1}"
t = str(conflict_type or "").strip().lower()
if t and t != "all":
items = [x for x in items if str(x.get("type") or "").strip().lower() == t]
d = str(direction or "").strip().lower()
if d and d != "all":
items = [x for x in items if str(x.get("direction") or "").strip().lower() == d]
s = str(search or "").strip().lower()
if s:
def _hit(x: Dict[str, Any]) -> bool:
for key in ("conflict_id", "type", "direction", "reason", "last_error", "local_product_id", "basalam_product_id"):
if s in str(x.get(key) or "").lower():
return True
return False
items = [x for x in items if _hit(x)]
sb = str(sort_by or "created_at").strip().lower()
sd = str(sort_dir or "desc").strip().lower()
reverse = sd != "asc"
if sb == "type":
items.sort(key=lambda x: str(x.get("type") or ""), reverse=reverse)
elif sb == "direction":
items.sort(key=lambda x: str(x.get("direction") or ""), reverse=reverse)
elif sb == "conflict_id":
items.sort(key=lambda x: str(x.get("conflict_id") or ""), reverse=reverse)
else:
# default: created_at
items.sort(key=lambda x: str(x.get("created_at") or ""), reverse=reverse)
total = len(items)
lim = max(1, min(200, int(limit)))
off = max(0, int(offset))
page_items = items[off:off + lim]
by_type: Dict[str, int] = {}
by_direction: Dict[str, int] = {}
for x in items:
tkey = str(x.get("type") or "unknown")
dkey = str(x.get("direction") or "unknown")
by_type[tkey] = int(by_type.get(tkey, 0)) + 1
by_direction[dkey] = int(by_direction.get(dkey, 0)) + 1
return {
"accepted": True,
"items": page_items,
"total": total,
"limit": lim,
"offset": off,
"has_more": (off + lim) < total,
"summary": {"by_type": by_type, "by_direction": by_direction},
}
def clear_product_conflicts(
db: Session,
business_id: int,
) -> Dict[str, Any]:
update_settings(db, business_id, {"pending_product_conflicts": []})
return {"accepted": True, "cleared": True}
def resolve_product_conflicts(
db: Session,
business_id: int,
payload: Dict[str, Any],
user_id: Optional[int] = None,
) -> Dict[str, Any]:
settings = get_settings(db, business_id)
q = settings.get("pending_product_conflicts")
queue = [dict(x) for x in q if isinstance(x, dict)] if isinstance(q, list) else []
for i, row in enumerate(queue):
if not row.get("conflict_id"):
row["conflict_id"] = f"legacy-{i + 1}"
resolution = str(payload.get("resolution") or "").strip()
if resolution not in {"local_wins", "remote_wins", "discard"}:
raise ApiError(
"BASALAM_CONFLICT_RESOLUTION_INVALID",
"resolution must be one of: local_wins, remote_wins, discard",
http_status=400,
)
selected_ids = payload.get("conflict_ids")
target_ids = set(str(x) for x in selected_ids if str(x).strip()) if isinstance(selected_ids, list) else set()
limit = int(payload.get("limit") or 20)
limit = max(1, min(200, limit))
actor_user_id = _resolve_actor_user_id(db, business_id, user_id)
processed = 0
resolved = 0
results: List[Dict[str, Any]] = []
remaining: List[Dict[str, Any]] = []
for row in queue:
cid = str(row.get("conflict_id") or "")
should_take = (cid in target_ids) if target_ids else (processed < limit)
if not should_take:
remaining.append(row)
continue
processed += 1
try:
if resolution == "discard":
resolved += 1
results.append({"conflict_id": cid, "status": "discarded"})
continue
ctype = str(row.get("type") or "")
if ctype == "field_conflict":
local_product_id = row.get("local_product_id")
if resolution == "local_wins":
local_item = {"local_product_id": local_product_id}
out = publish_products_to_basalam(
db=db,
business_id=int(business_id),
payload={
"vendor_id": payload.get("vendor_id") or settings.get("default_basalam_vendor_id"),
"products": [local_item],
},
user_id=actor_user_id,
)
ok = int(out.get("published_products") or 0) > 0
if ok:
resolved += 1
results.append({"conflict_id": cid, "status": "resolved_local_wins"})
else:
row["last_error"] = "publish_failed"
remaining.append(row)
results.append({"conflict_id": cid, "status": "failed", "reason": "publish_failed"})
else:
p = None
if str(local_product_id or "").isdigit():
p = (
db.query(Product)
.filter(Product.business_id == int(business_id), Product.id == int(local_product_id))
.first()
)
if not p:
row["last_error"] = "local_product_not_found"
remaining.append(row)
results.append({"conflict_id": cid, "status": "failed", "reason": "local_product_not_found"})
else:
remote_price = row.get("remote_price")
if remote_price is not None:
p.base_sales_price = _parse_decimal(remote_price, 0)
db.add(p)
db.commit()
resolved += 1
results.append({"conflict_id": cid, "status": "resolved_remote_wins"})
elif ctype == "variant_conflict":
direction = str(row.get("direction") or "")
if resolution == "local_wins" and direction == "push":
local_item = row.get("payload") if isinstance(row.get("payload"), dict) else {}
if row.get("local_product_id") and not local_item.get("local_product_id"):
local_item["local_product_id"] = row.get("local_product_id")
out = publish_products_to_basalam(
db=db,
business_id=int(business_id),
payload={
"vendor_id": payload.get("vendor_id") or settings.get("default_basalam_vendor_id"),
"products": [local_item],
},
user_id=actor_user_id,
)
ok = int(out.get("published_products") or 0) > 0
if ok:
resolved += 1
results.append({"conflict_id": cid, "status": "resolved_local_wins"})
else:
row["last_error"] = "publish_failed"
remaining.append(row)
results.append({"conflict_id": cid, "status": "failed", "reason": "publish_failed"})
elif resolution == "remote_wins" and direction == "pull":
remote_p = row.get("payload") if isinstance(row.get("payload"), dict) else {}
line_like = {
"product_id": _extract_product_basalam_id(remote_p),
"product_name": remote_p.get("title") or remote_p.get("name") or remote_p.get("product_name"),
"sku": remote_p.get("sku") or remote_p.get("code"),
"unit_price": remote_p.get("price") or remote_p.get("unit_price"),
"description": remote_p.get("description"),
}
pid = _find_or_create_product(db, business_id, settings, line_like)
if pid:
resolved += 1
results.append({"conflict_id": cid, "status": "resolved_remote_wins"})
else:
row["last_error"] = "remote_sync_failed"
remaining.append(row)
results.append({"conflict_id": cid, "status": "failed", "reason": "remote_sync_failed"})
else:
row["last_error"] = "unsupported_resolution_for_variant_conflict"
remaining.append(row)
results.append(
{
"conflict_id": cid,
"status": "failed",
"reason": "unsupported_resolution_for_variant_conflict",
}
)
else:
row["last_error"] = "unknown_conflict_type"
remaining.append(row)
results.append({"conflict_id": cid, "status": "failed", "reason": "unknown_conflict_type"})
except Exception as exc:
row["last_error"] = str(exc)
remaining.append(row)
results.append({"conflict_id": cid, "status": "failed", "reason": str(exc)})
update_settings(db, business_id, {"pending_product_conflicts": remaining[-300:]})
return {
"accepted": True,
"processed": processed,
"resolved": resolved,
"remaining_conflicts": len(remaining),
"results": results,
}