Watch
1
0
Fork
You've already forked Seyyed_arc
0
forked from hesabix/arc
Seyyed_arc/hesabixAPI/app/services/wallet_service.py
2026-06-14 00:37:58 +00:00

1791 lines
62 KiB
Python
Executable file

from __future__ import annotations
from typing import Optional, Dict, Any, List
from decimal import Decimal
import json
import structlog
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import select, and_
from sqlalchemy.exc import IntegrityError
from adapters.db.models.wallet import WalletAccount, WalletTransaction, WalletPayout, WalletSetting
from adapters.db.models.bank_account import BankAccount
from adapters.db.models.business import Business
from adapters.db.models.document import Document
from adapters.db.models.document_line import DocumentLine
from adapters.db.models.account import Account
from adapters.db.models.fiscal_year import FiscalYear
from app.core.responses import ApiError
from app.services.system_settings_service import get_wallet_settings
# Lazy import to avoid circular dependency
# from app.services.business_service import ensure_wallet_currency_in_business
from datetime import datetime, date
logger = structlog.get_logger()
def _ensure_wallet_account(db: Session, business_id: int) -> WalletAccount:
obj = db.execute(
select(WalletAccount).where(WalletAccount.business_id == int(business_id))
).scalars().first()
if obj:
return obj
obj = WalletAccount(
business_id=int(business_id),
available_balance=Decimal("0"),
pending_balance=Decimal("0"),
status="active",
)
db.add(obj)
db.flush()
return obj
def _get_wallet_account_for_update(db: Session, business_id: int) -> WalletAccount:
"""
قفل ردیفی روی حساب کیف‌پول برای جلوگیری از رقابت در به‌روزرسانی مانده‌ها
"""
acc = (
db.query(WalletAccount)
.filter(WalletAccount.business_id == int(business_id))
.with_for_update()
.first()
)
if acc:
return acc
# اگر وجود ندارد، ایجاد سپس تلاش مجدد برای قفل
acc = _ensure_wallet_account(db, business_id)
db.flush()
try:
acc = (
db.query(WalletAccount)
.filter(WalletAccount.business_id == int(business_id))
.with_for_update()
.first()
) or acc
except Exception:
pass
return acc
def charge_wallet_for_service(
db: Session,
business_id: int,
amount: Decimal,
*,
description: str,
tx_type: str = "internal_service_charge",
document_id: int | None = None,
extra_info: Dict[str, Any] | None = None,
allow_negative_balance: bool = False,
) -> Dict[str, Any]:
"""
کسر مبلغ از کیف‌پول برای سرویس‌های داخلی (مثل سناریو درآمدزایی اسناد)
"""
amount = Decimal(str(amount or 0))
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "مبلغ باید بزرگتر از صفر باشد", http_status=400)
account = _get_wallet_account_for_update(db, business_id)
available = Decimal(str(account.available_balance or 0))
if not allow_negative_balance and available < amount:
raise ApiError("INSUFFICIENT_FUNDS", "موجودی کیف پول کافی نیست", http_status=400)
account.available_balance = available - amount
db.flush()
extra_info_json = json.dumps(extra_info) if extra_info else None
tx = WalletTransaction(
business_id=int(business_id),
type=tx_type,
status="succeeded",
amount=amount,
fee_amount=Decimal("0"),
description=description,
document_id=document_id,
extra_info=extra_info_json,
)
db.add(tx)
db.flush()
return {
"transaction_id": tx.id,
"status": tx.status,
"available_balance": float(account.available_balance or 0),
}
def credit_wallet_for_service(
db: Session,
business_id: int,
amount: Decimal,
*,
description: str,
tx_type: str = "marketplace_sale_credit",
extra_info: Dict[str, Any] | None = None,
) -> Dict[str, Any]:
"""واریز مبلغ به کیف‌پول (مثلاً سهم ناشر از فروش مارکت‌پلیس)."""
amount = Decimal(str(amount or 0))
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "مبلغ باید بزرگتر از صفر باشد", http_status=400)
account = _get_wallet_account_for_update(db, business_id)
account.available_balance = Decimal(str(account.available_balance or 0)) + amount
db.flush()
extra_info_json = json.dumps(extra_info) if extra_info else None
tx = WalletTransaction(
business_id=int(business_id),
type=tx_type,
status="succeeded",
amount=amount,
fee_amount=Decimal("0"),
description=description,
extra_info=extra_info_json,
)
db.add(tx)
db.flush()
return {
"transaction_id": tx.id,
"status": tx.status,
"available_balance": float(account.available_balance or 0),
}
def get_wallet_overview(db: Session, business_id: int) -> Dict[str, Any]:
_ = db.query(Business).filter(Business.id == int(business_id)).first() or None
if _ is None:
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
account = _ensure_wallet_account(db, business_id)
settings = get_wallet_settings(db)
return {
"business_id": business_id,
"available_balance": float(account.available_balance or 0),
"pending_balance": float(account.pending_balance or 0),
"status": account.status,
"base_currency_code": settings.get("wallet_base_currency_code"),
"base_currency_id": settings.get("wallet_base_currency_id"),
"base_currency_title": settings.get("wallet_base_currency_title"),
"base_currency_symbol": settings.get("wallet_base_currency_symbol"),
}
def list_wallet_transactions(
db: Session,
business_id: int,
limit: int = 50,
skip: int = 0,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
) -> List[Dict[str, Any]]:
q = (
db.query(WalletTransaction)
.filter(WalletTransaction.business_id == int(business_id))
.order_by(WalletTransaction.id.desc())
)
if from_date is not None:
q = q.filter(WalletTransaction.created_at >= from_date)
if to_date is not None:
q = q.filter(WalletTransaction.created_at <= to_date)
items = q.offset(max(0, int(skip))).limit(max(1, min(200, int(limit)))).all()
return [
{
"id": it.id,
"type": it.type,
"status": it.status,
"amount": float(it.amount or 0),
"fee_amount": float(it.fee_amount or 0) if it.fee_amount is not None else None,
"description": it.description,
"external_ref": it.external_ref,
"document_id": it.document_id,
"created_at": it.created_at,
"updated_at": it.updated_at,
}
for it in items
]
def get_wallet_metrics(
db: Session,
business_id: int,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
) -> Dict[str, Any]:
account = _ensure_wallet_account(db, business_id)
# پایه: مجموع‌ها از WalletTransaction
q = db.query(WalletTransaction).filter(WalletTransaction.business_id == int(business_id))
if from_date is not None:
q = q.filter(WalletTransaction.created_at >= from_date)
if to_date is not None:
q = q.filter(WalletTransaction.created_at <= to_date)
transactions = q.all()
gross_in = Decimal("0")
fees_in = Decimal("0")
gross_out = Decimal("0")
fees_out = Decimal("0")
for tx in transactions:
amt = Decimal(str(tx.amount or 0))
fee = Decimal(str(tx.fee_amount or 0))
t = (tx.type or "").lower()
st = (tx.status or "").lower()
if st not in ("succeeded", "pending", "approved", "processing"): # موفق/در جریان را در گزارش لحاظ می‌کنیم
continue
if t in ("top_up", "customer_payment"):
gross_in += amt
fees_in += fee if fee > 0 else Decimal("0")
elif t in ("payout_settlement", "refund"):
gross_out += amt
fees_out += fee if fee > 0 else Decimal("0")
# سایر انواع در صورت نیاز بعداً اضافه شوند
net_in = gross_in - fees_in
net_out = gross_out + fees_out # خروجی خالصی که از کیف‌پول خارج می‌شود
return {
"period": {
"from": from_date,
"to": to_date,
},
"totals": {
"gross_in": float(gross_in),
"fees_in": float(fees_in),
"net_in": float(net_in),
"gross_out": float(gross_out),
"fees_out": float(fees_out),
"net_out": float(net_out),
},
"balances": {
"available": float(account.available_balance or 0),
"pending": float(account.pending_balance or 0),
},
}
def create_payout_request(
db: Session,
business_id: int,
user_id: int,
payload: Dict[str, Any],
) -> Dict[str, Any]:
amount = Decimal(str(payload.get("amount") or 0))
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "مبلغ نامعتبر است", http_status=400)
bank_account_id = payload.get("bank_account_id")
if not bank_account_id:
raise ApiError("BANK_ACCOUNT_REQUIRED", "شناسه حساب بانکی الزامی است", http_status=400)
bank_acc = db.query(BankAccount).filter(BankAccount.id == int(bank_account_id)).first()
if not bank_acc:
raise ApiError("BANK_ACCOUNT_NOT_FOUND", "حساب بانکی یافت نشد", http_status=404)
if not bank_acc.is_active:
raise ApiError("BANK_ACCOUNT_INACTIVE", "حساب بانکی غیرفعال است", http_status=400)
account = _get_wallet_account_for_update(db, business_id)
available = Decimal(str(account.available_balance or 0))
pending_balance = Decimal(str(account.pending_balance or 0))
if amount > available:
raise ApiError("INSUFFICIENT_FUNDS", "موجودی کافی نیست", http_status=400)
# قفل مبلغ: کسر از مانده قابل برداشت
account.available_balance = available - amount
account.pending_balance = pending_balance + amount
db.flush()
payout = WalletPayout(
business_id=int(business_id),
bank_account_id=int(bank_account_id),
gross_amount=amount,
fees=Decimal("0"),
net_amount=amount,
status="requested",
schedule_type=str(payload.get("schedule_type") or "manual"),
external_ref=None,
)
db.add(payout)
db.flush()
# ثبت تراکنش کنترلی
tx = WalletTransaction(
business_id=int(business_id),
type="payout_request",
status="pending",
amount=amount,
fee_amount=Decimal("0"),
description=str(payload.get("description") or "درخواست تسویه"),
external_ref=str(payout.id),
document_id=None,
)
db.add(tx)
db.flush()
return {
"id": payout.id,
"status": payout.status,
"gross_amount": float(payout.gross_amount),
"net_amount": float(payout.net_amount),
"bank_account_id": payout.bank_account_id,
}
def approve_payout_request(db: Session, payout_id: int, approver_user_id: int) -> Dict[str, Any]:
payout = db.query(WalletPayout).filter(WalletPayout.id == int(payout_id)).first()
if not payout:
raise ApiError("PAYOUT_NOT_FOUND", "درخواست تسویه یافت نشد", http_status=404)
if payout.status != "requested":
raise ApiError("INVALID_STATE", "تنها درخواست‌های در وضعیت requested قابل تایید هستند", http_status=400)
payout.status = "approved"
db.flush()
return {"id": payout.id, "status": payout.status}
def cancel_payout_request(db: Session, payout_id: int, canceller_user_id: int) -> Dict[str, Any]:
payout = db.query(WalletPayout).filter(WalletPayout.id == int(payout_id)).first()
if not payout:
raise ApiError("PAYOUT_NOT_FOUND", "درخواست تسویه یافت نشد", http_status=404)
if payout.status not in ("requested", "approved"):
raise ApiError("INVALID_STATE", "فقط درخواست‌های requested/approved قابل لغو هستند", http_status=400)
# بازگردانی مبلغ به مانده قابل برداشت و آزادسازی pending
account = _get_wallet_account_for_update(db, payout.business_id)
gross_amount = Decimal(str(payout.gross_amount or 0))
account.available_balance = Decimal(str(account.available_balance or 0)) + gross_amount
current_pending = Decimal(str(account.pending_balance or 0))
if current_pending <= gross_amount:
account.pending_balance = Decimal("0")
else:
account.pending_balance = current_pending - gross_amount
db.flush()
payout.status = "canceled"
db.flush()
return {"id": payout.id, "status": payout.status}
def settle_payout(
db: Session,
payout_id: int,
user_id: int,
*,
settlement_date: date | datetime | None = None,
bank_tracking_code: str | None = None,
fee_amount: Decimal | None = None,
note: str | None = None,
auto_approve_if_requested: bool = False,
) -> Dict[str, Any]:
def _to_datetime(value: date | datetime | None) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
try:
return datetime.fromisoformat(str(value))
except Exception:
return None
payout = db.query(WalletPayout).filter(WalletPayout.id == int(payout_id)).first()
if not payout:
raise ApiError("PAYOUT_NOT_FOUND", "درخواست تسویه یافت نشد", http_status=404)
if payout.status == "requested" and auto_approve_if_requested:
approve_payout_request(db, payout_id, user_id)
db.refresh(payout)
if payout.status not in ("approved", "processing"):
raise ApiError("INVALID_STATE", "تسویه تنها پس از تایید/در حال پردازش مجاز است", http_status=400)
fee_value = Decimal(str(fee_amount if fee_amount is not None else payout.fees or 0))
if fee_value < 0:
fee_value = Decimal("0")
gross_amount = Decimal(str(payout.gross_amount or 0))
if fee_value > gross_amount:
fee_value = gross_amount
net_amount = gross_amount - fee_value
payout.fees = fee_value
payout.net_amount = net_amount
settlement_dt = _to_datetime(settlement_date) or datetime.utcnow()
payout.settlement_date = settlement_dt
if bank_tracking_code:
payout.bank_tracking_code = bank_tracking_code
if note is not None:
payout.settlement_note = note
account = _get_wallet_account_for_update(db, payout.business_id)
current_pending = Decimal(str(account.pending_balance or 0))
if current_pending <= gross_amount:
account.pending_balance = Decimal("0")
else:
account.pending_balance = current_pending - gross_amount
db.flush()
# ایجاد سند پرداخت
try:
doc_id = _post_payout_document(
db,
business_id=int(payout.business_id),
user_id=int(user_id),
net_amount=net_amount,
fee_amount=fee_value,
)
except Exception:
doc_id = None
payout.status = "settled"
payout.document_id = doc_id
db.flush()
# به‌روزرسانی تراکنش درخواست
try:
request_tx = (
db.query(WalletTransaction)
.filter(
WalletTransaction.business_id == int(payout.business_id),
WalletTransaction.type == "payout_request",
WalletTransaction.external_ref == str(payout.id),
)
.order_by(WalletTransaction.id.desc())
.first()
)
if request_tx:
request_tx.status = "succeeded"
if doc_id:
request_tx.document_id = doc_id
except Exception:
pass
# ثبت تراکنش تسویه
try:
extra_payload = {
"bank_tracking_code": payout.bank_tracking_code,
"settlement_date": payout.settlement_date.isoformat() if payout.settlement_date else None,
}
tx = WalletTransaction(
business_id=int(payout.business_id),
type="payout_settlement",
status="succeeded",
amount=gross_amount,
fee_amount=fee_value,
description="تسویه کیف‌پول",
document_id=doc_id,
external_ref=str(payout.id),
extra_info=json.dumps(extra_payload),
)
db.add(tx)
db.flush()
except Exception:
pass
return {
"id": payout.id,
"status": payout.status,
"document_id": doc_id,
"settlement_date": payout.settlement_date,
}
def get_business_wallet_settings(db: Session, business_id: int) -> Dict[str, Any]:
obj = db.query(WalletSetting).filter(WalletSetting.business_id == int(business_id)).first()
if not obj:
return {
"business_id": business_id,
"mode": "manual",
"frequency": None,
"threshold_amount": None,
"min_reserve": None,
"default_bank_account_id": None,
}
return {
"business_id": business_id,
"mode": obj.mode,
"frequency": obj.frequency,
"threshold_amount": float(obj.threshold_amount) if obj.threshold_amount is not None else None,
"min_reserve": float(obj.min_reserve) if obj.min_reserve is not None else None,
"default_bank_account_id": obj.default_bank_account_id,
}
def update_business_wallet_settings(db: Session, business_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
obj = db.query(WalletSetting).filter(WalletSetting.business_id == int(business_id)).first()
if not obj:
obj = WalletSetting(business_id=int(business_id))
db.add(obj)
mode = str(payload.get("mode") or obj.mode or "manual")
frequency = payload.get("frequency") if payload.get("frequency") in (None, "daily", "weekly") else obj.frequency
def _dec(v):
return Decimal(str(v)) if v is not None and str(v).strip() != "" else None
obj.mode = mode
obj.frequency = frequency
obj.threshold_amount = _dec(payload.get("threshold_amount"))
obj.min_reserve = _dec(payload.get("min_reserve"))
obj.default_bank_account_id = int(payload.get("default_bank_account_id")) if payload.get("default_bank_account_id") else None
db.flush()
return get_business_wallet_settings(db, business_id)
def run_auto_settlement(db: Session, business_id: int, user_id: int) -> Dict[str, Any]:
"""
منطق ساده: اگر mode=auto و (available - min_reserve) >= threshold آنگاه به حساب پیش‌فرض تسویه کن.
"""
settings = get_business_wallet_settings(db, business_id)
if (settings.get("mode") or "manual") != "auto":
return {"executed": False, "reason": "AUTO_MODE_DISABLED"}
threshold = Decimal(str(settings.get("threshold_amount") or 0))
min_reserve = Decimal(str(settings.get("min_reserve") or 0))
default_bank_account_id = settings.get("default_bank_account_id")
if not default_bank_account_id:
return {"executed": False, "reason": "NO_DEFAULT_BANK_ACCOUNT"}
account = _get_wallet_account_for_update(db, business_id)
available = Decimal(str(account.available_balance or 0))
cand = available - min_reserve
if cand <= 0 or cand < threshold:
return {"executed": False, "reason": "THRESHOLD_NOT_MET", "available": float(available)}
# ایجاد payout و تسویه
payload = {
"bank_account_id": int(default_bank_account_id),
"amount": float(cand),
"description": "تسویه خودکار",
}
pr = create_payout_request(db, business_id, user_id, payload)
pa = db.query(WalletPayout).filter(WalletPayout.id == int(pr["id"])).first()
# تایید و تسویه
approve_payout_request(db, pa.id, user_id)
result = settle_payout(
db,
pa.id,
user_id,
settlement_date=datetime.utcnow(),
bank_tracking_code=f"AUTO-{pa.id}",
fee_amount=Decimal(str(pa.fees or 0)),
note="AUTO_SETTLEMENT",
)
return {"executed": True, "payout": result}
def _serialize_wallet_payout(payout: WalletPayout) -> Dict[str, Any]:
bank_acc = payout.bank_account
business = payout.business
return {
"id": payout.id,
"business_id": payout.business_id,
"business_name": getattr(business, "name", None),
"bank_account_id": payout.bank_account_id,
"bank_account": {
"id": getattr(bank_acc, "id", None),
"iban": getattr(bank_acc, "iban", None),
"bank_name": getattr(bank_acc, "bank_name", None),
"owner_name": getattr(bank_acc, "owner_name", None),
} if bank_acc else None,
"gross_amount": float(payout.gross_amount or 0),
"fees": float(payout.fees or 0),
"net_amount": float(payout.net_amount or 0),
"status": payout.status,
"schedule_type": payout.schedule_type,
"external_ref": payout.external_ref,
"document_id": payout.document_id,
"settlement_date": payout.settlement_date,
"bank_tracking_code": payout.bank_tracking_code,
"settlement_note": payout.settlement_note,
"created_at": payout.created_at,
"updated_at": payout.updated_at,
}
def list_wallet_payouts_admin(
db: Session,
*,
statuses: List[str] | None = None,
business_id: int | None = None,
skip: int = 0,
limit: int = 50,
) -> Dict[str, Any]:
q = (
db.query(WalletPayout)
.options(
joinedload(WalletPayout.bank_account),
joinedload(WalletPayout.business),
)
.order_by(WalletPayout.id.desc())
)
if statuses:
normalized = [str(s).lower() for s in statuses if s]
if normalized:
q = q.filter(WalletPayout.status.in_(normalized))
if business_id:
q = q.filter(WalletPayout.business_id == int(business_id))
total = q.count()
items = (
q.offset(max(0, int(skip)))
.limit(max(1, min(200, int(limit))))
.all()
)
return {
"items": [_serialize_wallet_payout(item) for item in items],
"total": total,
"skip": skip,
"limit": limit,
}
def get_wallet_payouts_stats_admin(db: Session) -> Dict[str, Any]:
"""
آمار کلی درخواست‌های تسویه برای داشبورد ادمین
"""
from sqlalchemy import func
from datetime import datetime, timedelta
now = datetime.utcnow()
month_start = datetime(now.year, now.month, 1)
# تعداد کل درخواست‌ها
total_count = db.query(WalletPayout).count()
# تعداد بر اساس وضعیت
status_counts = {}
for status in ["requested", "approved", "processing", "settled", "canceled", "failed"]:
count = db.query(WalletPayout).filter(WalletPayout.status == status).count()
status_counts[status] = count
# مجموع مبالغ در انتظار تسویه (requested + approved + processing)
pending_statuses = ["requested", "approved", "processing"]
pending_total = (
db.query(func.sum(WalletPayout.gross_amount))
.filter(WalletPayout.status.in_(pending_statuses))
.scalar() or Decimal("0")
)
# مجموع مبالغ تسویه شده در ماه جاری
monthly_settled = (
db.query(func.sum(WalletPayout.net_amount))
.filter(
WalletPayout.status == "settled",
WalletPayout.settlement_date >= month_start,
)
.scalar() or Decimal("0")
)
# مجموع کارمزدهای ماه جاری
monthly_fees = (
db.query(func.sum(WalletPayout.fees))
.filter(
WalletPayout.status == "settled",
WalletPayout.settlement_date >= month_start,
)
.scalar() or Decimal("0")
)
# تعداد درخواست‌های قدیمی (> 7 روز)
old_threshold = now - timedelta(days=7)
old_count = (
db.query(WalletPayout)
.filter(
WalletPayout.status.in_(pending_statuses),
WalletPayout.created_at < old_threshold,
)
.count()
)
return {
"total_count": total_count,
"status_counts": status_counts,
"pending_total": float(pending_total),
"monthly_settled": float(monthly_settled),
"monthly_fees": float(monthly_fees),
"old_pending_count": old_count,
}
def get_wallet_payout_admin(db: Session, payout_id: int) -> Dict[str, Any]:
payout = (
db.query(WalletPayout)
.options(
joinedload(WalletPayout.bank_account),
joinedload(WalletPayout.business),
)
.filter(WalletPayout.id == int(payout_id))
.first()
)
if not payout:
raise ApiError("PAYOUT_NOT_FOUND", "درخواست تسویه یافت نشد", http_status=404)
return _serialize_wallet_payout(payout)
def create_top_up_request(db: Session, business_id: int, user_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
ایجاد درخواست افزایش اعتبار (در انتظار تایید درگاه)
- مانده pending افزایش می‌یابد تا پس از تایید به available منتقل شود
"""
logger.info("create_top_up_request_start", business_id=business_id, user_id=user_id, amount=payload.get("amount"))
amount = Decimal(str(payload.get("amount") or 0))
if amount <= 0:
logger.error("create_top_up_request_invalid_amount", amount=float(amount))
raise ApiError("INVALID_AMOUNT", "مبلغ نامعتبر است", http_status=400)
gateway_id = payload.get("gateway_id")
source = payload.get("source", "app") # پیش‌فرض: app
# اعتبارسنجی gateway_id در صورت ارسال
if gateway_id:
try:
from adapters.db.models.payment_gateway import PaymentGateway
gw = db.query(PaymentGateway).filter(PaymentGateway.id == int(gateway_id)).first()
if not gw:
logger.error("create_top_up_request_gateway_not_found", gateway_id=gateway_id)
raise ApiError("GATEWAY_NOT_FOUND", "درگاه پرداخت یافت نشد", http_status=404)
if not gw.is_active:
logger.error("create_top_up_request_gateway_inactive", gateway_id=gateway_id)
raise ApiError("GATEWAY_DISABLED", "درگاه پرداخت غیرفعال است", http_status=400)
except ApiError:
raise
except Exception as ex:
logger.error("create_top_up_request_gateway_check_failed", gateway_id=gateway_id, error=str(ex))
raise ApiError("GATEWAY_CHECK_FAILED", "خطا در بررسی درگاه پرداخت", http_status=500)
# اگر gateway_id نداریم، فقط تراکنش ایجاد می‌کنیم بدون افزایش pending
# (برای پرداخت دستی یا روش‌های دیگر)
account = _get_wallet_account_for_update(db, business_id)
# بارگذاری اطلاعات کاربر برای ارسال به درگاه پرداخت
from adapters.db.models.user import User
user = db.query(User).filter(User.id == int(user_id)).first()
# ذخیره user_id و اطلاعات کاربر در extra_info برای استفاده بعدی
extra_info_dict = {"created_by_user_id": user_id, "source": source}
if user:
# نام کامل کاربر (ترکیب first_name و last_name یا mobile)
user_full_name = None
if user.first_name or user.last_name:
user_full_name = f"{user.first_name or ''} {user.last_name or ''}".strip()
elif user.mobile:
user_full_name = user.mobile
extra_info_dict["user_name"] = user_full_name
extra_info_dict["user_email"] = user.email
extra_info_dict["user_mobile"] = user.mobile
extra_info_json = json.dumps(extra_info_dict, ensure_ascii=False)
tx = WalletTransaction(
business_id=int(business_id),
type="top_up",
status="pending",
amount=amount,
fee_amount=Decimal("0"),
description=str(payload.get("description") or "افزایش اعتبار"),
external_ref=None,
document_id=None,
extra_info=extra_info_json,
)
db.add(tx)
db.flush()
# تولید لینک درگاه پرداخت (در صورت ارسال gateway_id)
payment_url = None
if gateway_id:
# فقط در صورت وجود gateway_id، pending_balance را افزایش می‌دهیم
account.pending_balance = Decimal(str(account.pending_balance or 0)) + amount
db.flush()
logger.info("create_top_up_request_pending_increased", tx_id=tx.id, amount=float(amount), new_pending=float(account.pending_balance))
try:
from app.services.payment_service import initiate_payment
init_res = initiate_payment(
db=db,
business_id=int(business_id),
tx_id=int(tx.id),
amount=float(amount),
gateway_id=int(gateway_id),
)
payment_url = init_res.payment_url
logger.info("create_top_up_request_payment_url_created", tx_id=tx.id, payment_url=payment_url)
except Exception as ex:
# اگر ایجاد لینک شکست بخورد، مانده pending به حالت قبل برگردد و تراکنش failed شود
logger.error("create_top_up_request_gateway_init_failed", tx_id=tx.id, error=str(ex), exc_info=True)
try:
current_pending = Decimal(str(account.pending_balance or 0))
if current_pending >= amount:
account.pending_balance = current_pending - amount
else:
account.pending_balance = Decimal("0")
tx.status = "failed"
db.flush()
logger.info("create_top_up_request_rollback_completed", tx_id=tx.id, new_pending=float(account.pending_balance))
except Exception as rollback_ex:
logger.error("create_top_up_request_rollback_failed", tx_id=tx.id, error=str(rollback_ex), exc_info=True)
raise ApiError("GATEWAY_INIT_FAILED", f"خطا در ایجاد لینک پرداخت: {str(ex)}", http_status=502)
else:
logger.info("create_top_up_request_no_gateway", tx_id=tx.id, message="تراکنش بدون درگاه ایجاد شد")
logger.info("create_top_up_request_completed", tx_id=tx.id, status=tx.status, has_payment_url=payment_url is not None)
return {"transaction_id": tx.id, "status": tx.status, **({"payment_url": payment_url} if payment_url else {})}
def confirm_top_up(db: Session, tx_id: int, success: bool, external_ref: str | None = None, user_id: int | None = None) -> Dict[str, Any]:
"""
تایید/لغو top-up از وبهوک درگاه
- در موفقیت: انتقال از pending به available
- در عدم موفقیت: کاهش از pending
"""
logger.info("confirm_top_up_start", tx_id=tx_id, success=success, external_ref=external_ref, user_id=user_id)
tx = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if not tx:
logger.error("confirm_top_up_tx_not_found", tx_id=tx_id)
raise ApiError("TX_NOT_FOUND", "تراکنش افزایش اعتبار یافت نشد", http_status=404)
# پرداخت آنلاین از لینک عمومی فاکتور (سند دریافت؛ بدون تغییر کیف‌پول SaaS)
if (tx.type or "") == "public_invoice_share_pay":
from app.services.public_invoice_share_payment_service import confirm_public_invoice_share_payment
return confirm_public_invoice_share_payment(
db, tx, success=success, external_ref=external_ref, user_id=user_id
)
if tx.type != "top_up":
logger.error("confirm_top_up_tx_not_found", tx_id=tx_id)
raise ApiError("TX_NOT_FOUND", "تراکنش افزایش اعتبار یافت نشد", http_status=404)
# بررسی تعلق تراکنش به کسب‌وکار (امنیتی)
if tx.business_id is None:
logger.error("confirm_top_up_no_business_id", tx_id=tx_id)
raise ApiError("INVALID_TX", "تراکنش به کسب‌وکار مرتبط نیست", http_status=400)
# Idempotency guard: if already finalized, do nothing
if (tx.status or "").lower() in ("succeeded", "failed"):
logger.info("confirm_top_up_already_finalized", tx_id=tx_id, status=tx.status)
tx.external_ref = external_ref or tx.external_ref
db.flush()
return {"transaction_id": tx.id, "status": tx.status}
account = _get_wallet_account_for_update(db, tx.business_id)
if success:
# move pending -> available
gross = Decimal(str(tx.amount or 0))
fee = Decimal(str(tx.fee_amount or 0))
if fee < 0:
fee = Decimal("0")
if fee > gross:
fee = gross
net = gross - fee
# Prevent negative pending due to duplicate webhook/callback
current_pending = Decimal(str(account.pending_balance or 0))
if current_pending < gross:
logger.warning("confirm_top_up_insufficient_pending", tx_id=tx_id, current_pending=float(current_pending), gross=float(gross))
# اگر pending کمتر از gross باشد، فقط همان مقدار موجود را کم می‌کنیم
account.pending_balance = Decimal("0")
else:
account.pending_balance = current_pending - gross
account.available_balance = Decimal(str(account.available_balance or 0)) + net
tx.status = "succeeded"
logger.info("confirm_top_up_success", tx_id=tx_id, gross=float(gross), fee=float(fee), net=float(net), new_available=float(account.available_balance))
# create accounting document
try:
# استفاده از user_id از تراکنش یا پارامتر ورودی
doc_user_id = user_id if user_id and user_id > 0 else None
# اگر user_id نداریم، از extra_info تراکنش تلاش می‌کنیم
if not doc_user_id:
try:
extra = json.loads(tx.extra_info or "{}") if tx.extra_info else {}
doc_user_id = extra.get("created_by_user_id")
except Exception:
pass
# در نهایت اگر هنوز نداریم، از 0 استفاده می‌کنیم (سیستم)
doc_user_id = doc_user_id if doc_user_id and doc_user_id > 0 else 0
doc_id = _post_topup_document(db, tx.business_id, user_id=doc_user_id, amount=gross, fee_amount=fee)
tx.document_id = int(doc_id)
logger.info("confirm_top_up_document_created", tx_id=tx_id, document_id=doc_id)
except Exception as ex:
# اگر سند ایجاد نشد، تراکنش مالی معتبر است اما سند ندارد
logger.warning("confirm_top_up_document_failed", tx_id=tx_id, error=str(ex), exc_info=True)
else:
# rollback pending
current_pending = Decimal(str(account.pending_balance or 0))
dec_amt = Decimal(str(tx.amount or 0))
if current_pending < dec_amt:
logger.warning("confirm_top_up_insufficient_pending_rollback", tx_id=tx_id, current_pending=float(current_pending), dec_amt=float(dec_amt))
account.pending_balance = Decimal("0")
else:
account.pending_balance = current_pending - dec_amt
tx.status = "failed"
logger.info("confirm_top_up_failed", tx_id=tx_id, new_pending=float(account.pending_balance))
tx.external_ref = external_ref
db.flush()
logger.info("confirm_top_up_completed", tx_id=tx_id, status=tx.status)
return {"transaction_id": tx.id, "status": tx.status}
def refund_transaction(db: Session, tx_id: int, amount: Decimal | None = None, reason: str | None = None) -> Dict[str, Any]:
"""
استرداد تراکنش موفق (بازگشت وجه از کیف‌پول)
- کاهش از available به میزان مبلغ استرداد
"""
src = db.query(WalletTransaction).filter(WalletTransaction.id == int(tx_id)).first()
if not src or src.status != "succeeded":
raise ApiError("TX_NOT_REFUNDABLE", "تراکنش موفق برای استرداد پیدا نشد", http_status=400)
refund_amount = Decimal(str(amount if amount is not None else src.amount or 0))
if refund_amount <= 0 or refund_amount > Decimal(str(src.amount or 0)):
raise ApiError("INVALID_REFUND_AMOUNT", "مبلغ استرداد نامعتبر است", http_status=400)
account = _ensure_wallet_account(db, src.business_id)
available = Decimal(str(account.available_balance or 0))
if refund_amount > available:
raise ApiError("INSUFFICIENT_FUNDS", "موجودی کافی برای استرداد نیست", http_status=400)
account.available_balance = available - refund_amount
db.flush()
tx = WalletTransaction(
business_id=int(src.business_id),
type="refund",
status="succeeded",
amount=refund_amount,
description=reason or f"استرداد تراکنش {src.id}",
external_ref=None,
document_id=None,
)
db.add(tx)
db.flush()
return {"refund_transaction_id": tx.id, "status": tx.status}
def _parse_iso_date_only(dt: str | datetime | date) -> date:
try:
if isinstance(dt, date) and not isinstance(dt, datetime):
return dt
if isinstance(dt, datetime):
return dt.date()
return datetime.fromisoformat(str(dt)).date()
except Exception:
return datetime.utcnow().date()
def _get_current_fiscal_year(db: Session, business_id: int) -> FiscalYear:
fy = (
db.query(FiscalYear)
.filter(
and_(
FiscalYear.business_id == int(business_id),
FiscalYear.is_last == True, # noqa: E712
)
)
.first()
)
if not fy:
raise ApiError("FISCAL_YEAR_NOT_FOUND", "سال مالی جاری یافت نشد", http_status=400)
return fy
def _get_fixed_account_by_code(db: Session, account_code: str) -> Account:
acc = db.query(Account).filter(
and_(Account.business_id == None, Account.code == str(account_code)) # noqa: E711
).first()
if not acc:
raise ApiError("ACCOUNT_NOT_FOUND", f"Account with code {account_code} not found", http_status=500)
return acc
def _resolve_wallet_currency_id(db: Session) -> int:
settings = get_wallet_settings(db)
cid = settings.get("wallet_base_currency_id")
if cid:
return int(cid)
# fallback: resolve by code IRR
from adapters.db.models.currency import Currency
cur = db.query(Currency).filter(Currency.code == "IRR").first()
if not cur:
raise ApiError("CURRENCY_NOT_FOUND", "ارز پایه کیف‌پول یافت نشد", http_status=400)
return int(cur.id)
def _create_simple_document(
db: Session,
business_id: int,
user_id: int,
document_type: str, # 'receipt' | 'payment'
currency_id: int,
document_date: date,
description: str | None,
accounting_lines: list[dict],
) -> Document:
from app.services.document_numbering_service import generate_document_code
fiscal_year = _get_current_fiscal_year(db, business_id)
doc_day = _parse_iso_date_only(document_date)
document: Document | None = None
max_code_attempts = 5
for _attempt in range(max_code_attempts):
doc_code = generate_document_code(db, business_id, document_type, doc_day)
candidate = Document(
business_id=business_id,
fiscal_year_id=fiscal_year.id,
code=doc_code,
document_type=document_type,
document_date=doc_day,
currency_id=int(currency_id),
created_by_user_id=user_id,
registered_at=datetime.utcnow(),
is_proforma=False,
description=description,
extra_info={"source": "wallet"},
)
try:
with db.begin_nested():
db.add(candidate)
db.flush()
except IntegrityError as exc:
msg = str(getattr(exc.orig, "args", exc))
if "uq_documents_business_code" in msg or "Duplicate entry" in msg:
continue
raise
else:
document = candidate
break
if not document:
raise ApiError(
"DOCUMENT_CODE_RACE",
"تولید شماره سند پس از چند تلاش ناموفق بود. لطفاً دوباره تلاش کنید.",
http_status=409,
)
for ln in accounting_lines:
db.add(DocumentLine(
document_id=document.id,
account_id=int(ln["account_id"]),
debit=Decimal(str(ln.get("debit", 0) or 0)),
credit=Decimal(str(ln.get("credit", 0) or 0)),
description=ln.get("description"),
))
db.flush()
return document
def _post_topup_document(db: Session, business_id: int, user_id: int, amount: Decimal, fee_amount: Decimal | None = None, doc_date: date | None = None) -> int:
# بررسی و اضافه کردن ارز کیف پول به کسب و کار در صورت نیاز
try:
# Lazy import to avoid circular dependency
from app.services.business_service import ensure_wallet_currency_in_business
ensure_wallet_currency_in_business(db, business_id)
except Exception as e:
logger.warning("failed_to_ensure_wallet_currency", business_id=business_id, error=str(e))
currency_id = _resolve_wallet_currency_id(db)
wallet_acc = _get_fixed_account_by_code(db, "10205") # حساب کیف پول
bank_acc = _get_fixed_account_by_code(db, "10203")
fee_amt = Decimal(str(fee_amount or 0))
net = amount - fee_amt if amount >= fee_amt else Decimal("0")
lines = [
# Receipt pattern with commission (per existing commission logic):
# Dr 10205 (wallet) = net, Dr 70902 (fee expense) = fee, Cr 10203 (bank) = gross
{"account_id": wallet_acc.id, "debit": net, "credit": 0, "description": "افزایش اعتبار (خالص)"},
]
if fee_amt > 0:
commission_expense = _get_fixed_account_by_code(db, "70902")
lines.append({"account_id": commission_expense.id, "debit": fee_amt, "credit": 0, "description": "کارمزد درگاه"})
lines.append({"account_id": bank_acc.id, "debit": 0, "credit": amount, "description": "واریز از درگاه/بانک (ناخالص)"})
document = _create_simple_document(
db=db,
business_id=business_id,
user_id=user_id,
document_type="receipt",
currency_id=currency_id,
document_date=doc_date or datetime.utcnow().date(),
description="افزایش اعتبار کیف‌پول",
accounting_lines=lines,
)
return int(document.id)
def _post_payout_document(db: Session, business_id: int, user_id: int, net_amount: Decimal, fee_amount: Decimal | None = None, doc_date: date | None = None) -> int:
# بررسی و اضافه کردن ارز کیف پول به کسب و کار در صورت نیاز
try:
# Lazy import to avoid circular dependency
from app.services.business_service import ensure_wallet_currency_in_business
ensure_wallet_currency_in_business(db, business_id)
except Exception as e:
logger.warning("failed_to_ensure_wallet_currency", business_id=business_id, error=str(e))
currency_id = _resolve_wallet_currency_id(db)
wallet_acc = _get_fixed_account_by_code(db, "10205") # حساب کیف پول
bank_acc = _get_fixed_account_by_code(db, "10203")
fee_amt = Decimal(str(fee_amount or 0))
# Per existing commission logic for Payment: Dr bank = fee, Cr 70902 = fee
lines = [
{"account_id": bank_acc.id, "debit": net_amount, "credit": 0, "description": "وصول تسویه کیف‌پول (خالص)"},
{"account_id": wallet_acc.id, "debit": 0, "credit": net_amount, "description": "انتقال از کیف‌پول"},
]
if fee_amt > 0:
commission_expense = _get_fixed_account_by_code(db, "70902")
lines.append({"account_id": bank_acc.id, "debit": fee_amt, "credit": 0, "description": "کارمزد تسویه (الگوی پرداخت)"})
lines.append({"account_id": commission_expense.id, "debit": 0, "credit": fee_amt, "description": "کارمزد خدمات بانکی"})
document = _create_simple_document(
db=db,
business_id=business_id,
user_id=user_id,
document_type="payment",
currency_id=currency_id,
document_date=doc_date or datetime.utcnow().date(),
description="تسویه کیف‌پول به حساب بانکی",
accounting_lines=lines,
)
return int(document.id)
def _post_gift_credit_document(db: Session, business_id: int, user_id: int, amount: Decimal, description: str | None = None, doc_date: date | None = None) -> int:
"""
ایجاد سند حسابداری برای اعتبارات هدیه
Dr 10205 (wallet) = amount
Cr 60205 (gift credit income) = amount
"""
# بررسی و اضافه کردن ارز کیف پول به کسب و کار در صورت نیاز
try:
# Lazy import to avoid circular dependency
from app.services.business_service import ensure_wallet_currency_in_business
ensure_wallet_currency_in_business(db, business_id)
except Exception as e:
logger.warning("failed_to_ensure_wallet_currency", business_id=business_id, error=str(e))
currency_id = _resolve_wallet_currency_id(db)
wallet_acc = _get_fixed_account_by_code(db, "10205") # حساب کیف پول
gift_income_acc = _get_fixed_account_by_code(db, "60205")
lines = [
{"account_id": wallet_acc.id, "debit": amount, "credit": 0, "description": "افزایش اعتبار هدیه"},
{"account_id": gift_income_acc.id, "debit": 0, "credit": amount, "description": description or "اعتبارات هدیه از مدیر سیستم"},
]
document = _create_simple_document(
db=db,
business_id=business_id,
user_id=user_id,
document_type="receipt",
currency_id=currency_id,
document_date=doc_date or datetime.utcnow().date(),
description=description or "افزایش اعتبار هدیه توسط مدیر سیستم",
accounting_lines=lines,
)
return int(document.id)
def _ensure_zohal_expense_account(db: Session) -> Account:
"""
بررسی و ایجاد/به‌روزرسانی حساب هزینه سرویس‌های استعلامات (70509)
این تابع اطمینان می‌دهد که حساب 70509 با نام صحیح "هزینه سرویس‌های استعلامات" وجود دارد
حساب در گروه هزینه‌های عمومی (705) قرار دارد که مناسب هزینه‌های عملیاتی است
"""
account = db.query(Account).filter(
and_(
Account.code == "70509",
Account.business_id.is_(None)
)
).first()
expected_name = "هزینه سرویس‌های استعلامات"
if not account:
# دریافت حساب والد (705 - هزینه‌های عمومی)
parent_account = _get_fixed_account_by_code(db, "705")
# ایجاد حساب هزینه سرویس‌های استعلامات
account = Account(
name=expected_name,
code="70509",
account_type="accounting_document",
business_id=None, # حساب عمومی
parent_id=parent_account.id if parent_account else None
)
db.add(account)
db.flush()
else:
# بررسی و به‌روزرسانی نام حساب در صورت نیاز
if account.name != expected_name:
import logging
logger = logging.getLogger(__name__)
logger.info(f"به‌روزرسانی نام حساب 70509 از '{account.name}' به '{expected_name}'")
account.name = expected_name
# اطمینان از نوع حساب
if account.account_type != "accounting_document":
account.account_type = "accounting_document"
db.flush()
return account
def charge_wallet_for_zohal_service(
db: Session,
business_id: int,
user_id: int,
amount: Decimal,
service_id: int,
service_name: str,
description: str | None = None,
) -> Dict[str, Any]:
"""
کسر مبلغ از کیف‌پول برای سرویس‌های زحل و ایجاد سند حسابداری
"""
amount = Decimal(str(amount or 0))
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "مبلغ باید بزرگتر از صفر باشد", http_status=400)
# بررسی موجودی
account = _get_wallet_account_for_update(db, business_id)
available = Decimal(str(account.available_balance or 0))
if available < amount:
raise ApiError("INSUFFICIENT_FUNDS", "موجودی کیف پول کافی نیست", http_status=400)
# کسر از موجودی
account.available_balance = available - amount
db.flush()
# بررسی و اضافه کردن ارز کیف پول به کسب و کار در صورت نیاز
try:
# Lazy import to avoid circular dependency
from app.services.business_service import ensure_wallet_currency_in_business
ensure_wallet_currency_in_business(db, business_id)
except Exception as e:
logger.warning("failed_to_ensure_wallet_currency", business_id=business_id, error=str(e))
# ایجاد سند حسابداری
currency_id = _resolve_wallet_currency_id(db)
wallet_acc = _get_fixed_account_by_code(db, "10205") # حساب کیف پول
expense_acc = _ensure_zohal_expense_account(db) # حساب هزینه سرویس‌های استعلامات
lines = [
{"account_id": expense_acc.id, "debit": amount, "credit": 0, "description": description or f"هزینه سرویس {service_name}"},
{"account_id": wallet_acc.id, "debit": 0, "credit": amount, "description": f"کسر از کیف پول برای سرویس استعلامات"},
]
document = _create_simple_document(
db=db,
business_id=business_id,
user_id=user_id,
document_type="payment",
currency_id=currency_id,
document_date=datetime.utcnow().date(),
description=description or f"هزینه سرویس {service_name}",
accounting_lines=lines,
)
# ثبت تراکنش کیف پول
extra_info = {
"source": "zohal_service",
"service_id": service_id,
"service_name": service_name,
}
extra_info_json = json.dumps(extra_info)
tx = WalletTransaction(
business_id=int(business_id),
type="zohal_service_charge",
status="succeeded",
amount=amount,
fee_amount=Decimal("0"),
description=description or f"هزینه سرویس {service_name}",
document_id=document.id,
extra_info=extra_info_json,
)
db.add(tx)
db.flush()
return {
"wallet_transaction_id": tx.id,
"document_id": document.id,
"available_balance": float(account.available_balance or 0),
}
def _ensure_notification_expense_account(db: Session) -> Account:
"""
بررسی و ایجاد/به‌روزرسانی حساب هزینه ارسال پیامک ناتیفیکیشن (70510)
این تابع اطمینان می‌دهد که حساب 70510 با نام صحیح "هزینه ارسال پیامک ناتیفیکیشن" وجود دارد
حساب در گروه هزینه‌های عمومی (705) قرار دارد که مناسب هزینه‌های عملیاتی است
"""
account = db.query(Account).filter(
and_(
Account.code == "70510",
Account.business_id.is_(None)
)
).first()
expected_name = "هزینه ارسال پیامک ناتیفیکیشن"
if not account:
# دریافت حساب والد (705 - هزینه‌های عمومی)
parent_account = _get_fixed_account_by_code(db, "705")
# ایجاد حساب هزینه ارسال پیامک ناتیفیکیشن
account = Account(
name=expected_name,
code="70510",
account_type="accounting_document",
business_id=None, # حساب عمومی
parent_id=parent_account.id if parent_account else None
)
db.add(account)
db.flush()
else:
# بررسی و به‌روزرسانی نام حساب در صورت نیاز
if account.name != expected_name:
logger.info(f"به‌روزرسانی نام حساب 70510 از '{account.name}' به '{expected_name}'")
account.name = expected_name
# اطمینان از نوع حساب
if account.account_type != "accounting_document":
account.account_type = "accounting_document"
db.flush()
return account
def charge_wallet_for_notification(
db: Session,
business_id: int,
user_id: int,
amount: Decimal,
sms_count: int,
event_type: str,
template_id: int | None = None,
template_name: str | None = None,
description: str | None = None,
) -> Dict[str, Any]:
"""
کسر مبلغ از کیف‌پول برای ارسال پیامک ناتیفیکیشن و ایجاد سند حسابداری
Args:
db: Database session
business_id: شناسه کسب‌وکار
user_id: شناسه کاربر
amount: مبلغ کسر (بر اساس ارز کیف پول)
sms_count: تعداد پیامک
event_type: نوع رویداد
template_id: شناسه قالب (اختیاری)
template_name: نام قالب (اختیاری)
description: توضیحات اضافی (اختیاری)
Returns:
دیکشنری شامل:
- wallet_transaction_id: شناسه تراکنش کیف پول
- document_id: شناسه سند حسابداری
- available_balance: موجودی باقیمانده
Raises:
ApiError: در صورت موجودی ناکافی یا خطای دیگر
"""
amount = Decimal(str(amount or 0))
if amount <= 0:
raise ApiError("INVALID_AMOUNT", "مبلغ باید بزرگتر از صفر باشد", http_status=400)
# بررسی موجودی
account = _get_wallet_account_for_update(db, business_id)
available = Decimal(str(account.available_balance or 0))
if available < amount:
raise ApiError("INSUFFICIENT_FUNDS", "موجودی کیف پول کافی نیست", http_status=400)
# کسر از موجودی
account.available_balance = available - amount
db.flush()
# ایجاد سند حسابداری
currency_id = _resolve_wallet_currency_id(db)
wallet_acc = _get_fixed_account_by_code(db, "10205") # حساب کیف پول
expense_acc = _ensure_notification_expense_account(db) # حساب هزینه ارسال پیامک ناتیفیکیشن
template_desc = f" - قالب: {template_name}" if template_name else ""
desc = description or f"هزینه ارسال پیامک ناتیفیکیشن ({sms_count} پیامک){template_desc}"
lines = [
{"account_id": expense_acc.id, "debit": amount, "credit": 0, "description": desc},
{"account_id": wallet_acc.id, "debit": 0, "credit": amount, "description": f"کسر از کیف پول برای ارسال پیامک ناتیفیکیشن"},
]
document = _create_simple_document(
db=db,
business_id=business_id,
user_id=user_id,
document_type="payment",
currency_id=currency_id,
document_date=datetime.utcnow().date(),
description=desc,
accounting_lines=lines,
)
# ثبت تراکنش کیف پول
extra_info = {
"source": "business_notification",
"sms_count": sms_count,
"event_type": event_type,
"template_id": template_id,
"template_name": template_name,
}
extra_info_json = json.dumps(extra_info, ensure_ascii=False)
tx = WalletTransaction(
business_id=int(business_id),
type="notification_sms",
status="succeeded",
amount=amount,
fee_amount=Decimal("0"),
description=desc,
document_id=document.id,
extra_info=extra_info_json,
)
db.add(tx)
db.flush()
return {
"wallet_transaction_id": tx.id,
"document_id": document.id,
"available_balance": float(account.available_balance or 0),
}
def add_gift_balance_admin(
db: Session,
business_id: int,
user_id: int,
amount: Decimal,
description: str | None = None,
reason: str | None = None,
) -> Dict[str, Any]:
"""
افزودن موجودی هدیه به کیف‌پول کسب‌وکار توسط مدیر سیستم
Args:
db: Database session
business_id: شناسه کسب‌وکار
user_id: شناسه کاربر مدیر سیستم
amount: مبلغ هدیه
description: توضیحات (اختیاری)
reason: دلیل (اختیاری)
Returns:
اطلاعات کیف‌پول و تراکنش ایجاد شده
"""
logger.info(
"add_gift_balance_admin_start",
business_id=business_id,
user_id=user_id,
amount=float(amount),
description=description,
reason=reason
)
# بررسی کسب‌وکار
business = db.query(Business).filter(Business.id == int(business_id)).first()
if not business:
logger.error("add_gift_balance_admin_business_not_found", business_id=business_id)
raise ApiError("BUSINESS_NOT_FOUND", "کسب‌وکار یافت نشد", http_status=404)
logger.debug("add_gift_balance_admin_business_found", business_id=business_id, business_name=business.name)
# اعتبارسنجی مبلغ
amount = Decimal(str(amount))
if amount <= 0:
logger.error("add_gift_balance_admin_invalid_amount", amount=float(amount))
raise ApiError("INVALID_AMOUNT", "مبلغ باید بزرگتر از صفر باشد", http_status=400)
logger.debug("add_gift_balance_admin_amount_validated", amount=float(amount))
# دریافت یا ایجاد حساب کیف‌پول
logger.debug("add_gift_balance_admin_getting_wallet_account", business_id=business_id)
account = _get_wallet_account_for_update(db, business_id)
old_balance = float(account.available_balance or 0)
logger.debug(
"add_gift_balance_admin_wallet_account_found",
account_id=account.id,
old_balance=old_balance,
old_pending=float(account.pending_balance or 0)
)
# افزایش موجودی قابل استفاده
account.available_balance = Decimal(str(account.available_balance or 0)) + amount
new_balance = float(account.available_balance)
logger.debug(
"add_gift_balance_admin_balance_updated",
old_balance=old_balance,
new_balance=new_balance,
delta=float(amount)
)
try:
db.flush()
logger.debug("add_gift_balance_admin_wallet_balance_flushed")
except Exception as e:
logger.error("add_gift_balance_admin_flush_error", error=str(e), error_type=type(e).__name__, exc_info=True)
raise
# ثبت تراکنش
desc_text = description or "افزایش اعتبار هدیه توسط مدیر سیستم"
if reason:
desc_text = f"{desc_text} (دلیل: {reason})"
# تبدیل extra_info به JSON string
extra_info_dict = {"added_by": user_id}
if reason:
extra_info_dict["reason"] = reason
extra_info_json = json.dumps(extra_info_dict) if extra_info_dict else None
logger.debug(
"add_gift_balance_admin_creating_transaction",
description=desc_text,
extra_info=extra_info_json
)
tx = WalletTransaction(
business_id=int(business_id),
type="gift_credit",
status="succeeded",
amount=amount,
fee_amount=Decimal("0"),
description=desc_text,
external_ref=None,
document_id=None,
extra_info=extra_info_json,
)
db.add(tx)
try:
db.flush()
logger.info(
"add_gift_balance_admin_transaction_created",
transaction_id=tx.id,
business_id=business_id,
amount=float(amount)
)
except Exception as e:
logger.error("add_gift_balance_admin_transaction_flush_error", error=str(e), error_type=type(e).__name__, exc_info=True)
raise
# ایجاد سند حسابداری
logger.debug("add_gift_balance_admin_creating_document", business_id=business_id)
try:
doc_id = _post_gift_credit_document(db, business_id, user_id, amount, desc_text)
tx.document_id = int(doc_id)
db.flush()
logger.info(
"add_gift_balance_admin_document_created",
document_id=doc_id,
transaction_id=tx.id
)
except Exception as e:
# اگر سند ایجاد نشد، تراکنش مالی معتبر است اما سند ندارد
logger.warning(
"add_gift_balance_admin_document_creation_failed",
error=str(e),
error_type=type(e).__name__,
business_id=business_id,
amount=float(amount),
transaction_id=tx.id,
exc_info=True
)
# Commit تغییرات (اگر session خودمان commit می‌کند)
try:
# بررسی اینکه آیا session در حالت autocommit است
if db.is_active:
logger.debug("add_gift_balance_admin_session_is_active", in_transaction=db.in_transaction())
else:
logger.warning("add_gift_balance_admin_session_not_active")
except Exception:
pass
# بازگشت اطلاعات
result = {
"transaction_id": tx.id,
"business_id": business_id,
"amount": float(amount),
"available_balance": float(account.available_balance),
"pending_balance": float(account.pending_balance or 0),
"status": account.status,
"document_id": tx.document_id,
}
logger.info(
"add_gift_balance_admin_completed",
transaction_id=tx.id,
business_id=business_id,
amount=float(amount),
final_balance=result["available_balance"],
document_id=tx.document_id
)
return result
def check_document_has_wallet_transactions(db: Session, document_id: int) -> Dict[str, Any]:
"""
بررسی می‌کند که آیا یک سند به تراکنش‌های کیف پول مرتبط است یا نه
Args:
db: جلسه دیتابیس
document_id: شناسه سند
Returns:
{
"has_wallet_transactions": bool,
"has_protected_transactions": bool,
"transaction_count": int,
"transaction_ids": List[int],
"transaction_types": List[str],
"message": str | None
}
"""
transactions = db.query(WalletTransaction).filter(
WalletTransaction.document_id == document_id
).all()
if not transactions:
return {
"has_wallet_transactions": False,
"has_protected_transactions": False,
"transaction_count": 0,
"transaction_ids": [],
"transaction_types": [],
"message": None
}
transaction_ids = [tx.id for tx in transactions]
transaction_types = list(set([tx.type for tx in transactions]))
# انواع تراکنش‌های سیستمی که نباید حذف شوند
protected_types = [
"top_up", # واریز
"payout_request", # درخواست برداشت
"payout_settlement", # تسویه برداشت
"internal_invoice_payment", # پرداخت صورتحساب داخلی
"ai_subscription", # اشتراک هوش مصنوعی
"ai_usage", # استفاده از هوش مصنوعی
"customer_payment", # پرداخت مشتری (پکیج‌ها)
"internal_service_charge", # کسر سرویس داخلی
"document_usage_charge", # درآمدزایی به ازای سند
"document_volume_charge", # درآمدزایی حجمی اسناد
"zohal_service_charge", # کسر سرویس زحل
"plugin_purchase", # خرید افزونه (یکپارچگی کیف پول)
"refund", # بازگشت وجه
"fee", # کارمزد
"chargeback", # برگشت تراکنش
"reversal" # معکوس کردن تراکنش
]
has_protected = any(tx.type in protected_types for tx in transactions)
if has_protected:
protected_tx = [tx for tx in transactions if tx.type in protected_types]
protected_types_list = list(set([tx.type for tx in protected_tx]))
message = f"این سند به {len(protected_tx)} تراکنش کیف پول سیستمی مرتبط است و قابل حذف نمی‌باشد. انواع تراکنش‌ها: {', '.join(protected_types_list)}"
else:
message = f"این سند به {len(transactions)} تراکنش کیف پول مرتبط است"
return {
"has_wallet_transactions": True,
"has_protected_transactions": has_protected,
"transaction_count": len(transactions),
"transaction_ids": transaction_ids,
"transaction_types": transaction_types,
"message": message
}
def check_wallet_transaction_has_dependencies(db: Session, transaction_id: int) -> Dict[str, Any]:
"""
بررسی می‌کند که آیا یک تراکنش کیف پول به موجودیت‌های دیگر لینک شده است یا نه
Args:
db: جلسه دیتابیس
transaction_id: شناسه تراکنش کیف پول
Returns:
{
"has_dependencies": bool,
"dependencies": {
"ai_invoices": List[int],
"storage_invoices": List[int],
"marketplace_orders": List[int],
"zohal_services": List[int],
"ai_usage_logs": List[int]
},
"message": str | None
}
"""
dependencies = {
"ai_invoices": [],
"storage_invoices": [],
"marketplace_orders": [],
"zohal_services": [],
"ai_usage_logs": []
}
# بررسی AI Invoices
try:
from adapters.db.models.ai_invoice import AIInvoice
ai_invoices = db.query(AIInvoice).filter(
AIInvoice.wallet_transaction_id == transaction_id
).all()
dependencies["ai_invoices"] = [inv.id for inv in ai_invoices]
except Exception:
pass
# بررسی Storage Invoices
try:
from adapters.db.models.storage_plan import StorageInvoice
storage_invoices = db.query(StorageInvoice).filter(
StorageInvoice.wallet_transaction_id == transaction_id
).all()
dependencies["storage_invoices"] = [inv.id for inv in storage_invoices]
except Exception:
pass
# بررسی Marketplace Orders
try:
from adapters.db.models.marketplace import MarketplaceOrder
marketplace_orders = db.query(MarketplaceOrder).filter(
MarketplaceOrder.wallet_transaction_id == transaction_id
).all()
dependencies["marketplace_orders"] = [order.id for order in marketplace_orders]
except Exception:
pass
# بررسی Zohal Services
try:
from adapters.db.models.zohal import ZohalServiceUsage
zohal_services = db.query(ZohalServiceUsage).filter(
ZohalServiceUsage.wallet_transaction_id == transaction_id
).all()
dependencies["zohal_services"] = [svc.id for svc in zohal_services]
except Exception:
pass
# بررسی AI Usage Logs
try:
from adapters.db.models.ai_usage_log import AIUsageLog
ai_usage_logs = db.query(AIUsageLog).filter(
AIUsageLog.wallet_transaction_id == transaction_id
).all()
dependencies["ai_usage_logs"] = [log.id for log in ai_usage_logs]
except Exception:
pass
total_dependencies = sum(len(v) for v in dependencies.values())
has_dependencies = total_dependencies > 0
if has_dependencies:
dep_list = []
if dependencies["ai_invoices"]:
dep_list.append(f"{len(dependencies['ai_invoices'])} صورتحساب AI")
if dependencies["storage_invoices"]:
dep_list.append(f"{len(dependencies['storage_invoices'])} صورتحساب ذخیره‌سازی")
if dependencies["marketplace_orders"]:
dep_list.append(f"{len(dependencies['marketplace_orders'])} سفارش مارکت‌پلیس")
if dependencies["zohal_services"]:
dep_list.append(f"{len(dependencies['zohal_services'])} سرویس زحل")
if dependencies["ai_usage_logs"]:
dep_list.append(f"{len(dependencies['ai_usage_logs'])} لاگ استفاده AI")
message = f"این تراکنش به {', '.join(dep_list)} مرتبط است و قابل حذف نمی‌باشد"
else:
message = None
return {
"has_dependencies": has_dependencies,
"dependencies": dependencies,
"message": message
}