TorbatYar/backend/services/loyalty/app/services/wallet_engine.py
Mortezakoohjani 071c484530 Ship Loyalty phases 7.2-7.6 (points through wallet) with production deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 21:27:22 +03:30

488 lines
18 KiB
Python

"""Wallet Engine — immutable ledger writes and account lifecycle (Phase 7.6).
Mirrors the Point Engine (Phase 7.2): WalletAccount is a shell only; balance
is always SUM(signed WalletLedgerEntry.amount). Never add a mutable balance
column on WalletAccount.
"""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID, uuid4
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.events.publisher import TransactionalEventPublisher
from app.events.types import LoyaltyEventType
from app.models.types import AuditAction, WalletAccountStatus, WalletLedgerEntryType
from app.models.wallet import WalletAccount, WalletLedgerEntry
from app.repositories.foundation import LoyaltyProgramRepository, MemberRepository
from app.repositories.wallet import WalletAccountRepository, WalletLedgerRepository
from app.schemas.wallet import (
WalletAccountCreate,
WalletAccountUpdate,
WalletAdjustRequest,
WalletCreditRequest,
WalletDebitRequest,
WalletTransferRequest,
)
from app.services.audit_service import AuditService
from app.validators.foundation import (
release_unique_token,
validate_code,
validate_currency_code,
)
from app.validators.wallet import (
ensure_not_same_account,
ensure_same_currency,
ensure_wallet_open,
signed_amount,
validate_adjust_amount,
validate_positive_amount,
validate_wallet_account_status,
)
from shared.exceptions import AppError, NotFoundError
from shared.security import CurrentUser
def _now() -> datetime:
return datetime.now(timezone.utc)
def _next_wallet_account_number() -> str:
return f"WA-{uuid4().hex[:10].upper()}"
def _map_integrity(*, error_code: str, message: str) -> AppError:
return AppError(message, status_code=409, error_code=error_code)
class WalletEngineService:
def __init__(
self,
session: AsyncSession,
publisher: TransactionalEventPublisher | None = None,
) -> None:
self.session = session
self.accounts = WalletAccountRepository(session)
self.ledger = WalletLedgerRepository(session)
self.programs = LoyaltyProgramRepository(session)
self.members = MemberRepository(session)
self.audit = AuditService(session)
self.publisher = publisher or TransactionalEventPublisher(session)
# ------------------------------------------------------------------
# Account lifecycle
# ------------------------------------------------------------------
async def create(
self,
tenant_id: UUID,
body: WalletAccountCreate,
*,
actor: CurrentUser | None = None,
) -> WalletAccount:
actor_id = actor.user_id if actor else None
program = await self.programs.get(tenant_id, body.program_id)
if program is None:
raise NotFoundError(
"برنامه وفاداری یافت نشد", error_code="program_not_found"
)
member = await self.members.get(tenant_id, body.member_id)
if member is None or member.program_id != body.program_id:
raise NotFoundError("عضو یافت نشد", error_code="member_not_found")
if (
await self.accounts.get_by_member_program(
tenant_id, body.program_id, body.member_id
)
is not None
):
raise AppError(
"کیف پول برای این عضو در این برنامه وجود دارد",
status_code=409,
error_code="wallet_account_exists",
)
account_number = (
validate_code(body.account_number, field="account_number")
if body.account_number
else _next_wallet_account_number()
)
if await self.accounts.get_by_account_number(tenant_id, account_number):
raise AppError(
"شماره کیف پول تکراری است",
status_code=409,
error_code="wallet_account_number_exists",
)
entity = WalletAccount(
tenant_id=tenant_id,
program_id=body.program_id,
member_id=body.member_id,
account_number=account_number,
currency_code=validate_currency_code(body.currency_code),
status=validate_wallet_account_status(body.status),
created_by=actor_id,
updated_by=actor_id,
)
try:
await self.accounts.add(entity)
await self.audit.record(
tenant_id=tenant_id,
entity_type="wallet_account",
entity_id=entity.id,
action=AuditAction.WALLET_OPEN,
actor_user_id=actor_id,
message=f"Wallet {entity.account_number} opened",
)
await self.publisher.publish(
event_type=LoyaltyEventType.WALLET_OPENED,
aggregate_type="wallet_account",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={
"account_number": entity.account_number,
"member_id": str(entity.member_id),
},
)
await self.session.commit()
await self.session.refresh(entity)
except IntegrityError:
await self.session.rollback()
raise _map_integrity(
error_code="wallet_account_conflict",
message="کیف پول قابل ایجاد نیست",
)
return entity
async def update(
self,
tenant_id: UUID,
account_id: UUID,
body: WalletAccountUpdate,
*,
actor: CurrentUser | None = None,
) -> WalletAccount:
entity = await self.get(tenant_id, account_id)
data = body.model_dump(exclude_unset=True)
if "status" in data:
if data["status"] is None:
raise AppError(
"status نمی‌تواند null باشد",
status_code=422,
error_code="null_not_allowed",
details={"field": "status"},
)
entity.status = validate_wallet_account_status(data["status"])
actor_id = actor.user_id if actor else None
entity.updated_by = actor_id
await self.audit.record(
tenant_id=tenant_id,
entity_type="wallet_account",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=actor_id,
changes={"status": entity.status.value},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, account_id: UUID) -> WalletAccount:
entity = await self.accounts.get(tenant_id, account_id)
if entity is None:
raise NotFoundError(
"کیف پول یافت نشد", error_code="wallet_account_not_found"
)
return entity
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
return await self.accounts.list_by_tenant(tenant_id, offset=offset, limit=limit)
async def _get_open_account(self, tenant_id: UUID, account_id: UUID) -> WalletAccount:
entity = await self.get(tenant_id, account_id)
ensure_wallet_open(entity.status)
return entity
async def get_balance(self, tenant_id: UUID, account_id: UUID) -> dict:
entity = await self.get(tenant_id, account_id)
balance = await self.ledger.balance_for_account(tenant_id, account_id)
return {
"wallet_account_id": account_id,
"member_id": entity.member_id,
"program_id": entity.program_id,
"status": entity.status,
"currency_code": entity.currency_code,
"balance": balance,
}
async def list_ledger(
self,
tenant_id: UUID,
account_id: UUID,
*,
entry_type: WalletLedgerEntryType | None = None,
offset: int = 0,
limit: int = 50,
):
await self.get(tenant_id, account_id)
rows = await self.ledger.list_for_account(
tenant_id, account_id, entry_type=entry_type, offset=offset, limit=limit
)
total = await self.ledger.count_for_account(
tenant_id, account_id, entry_type=entry_type
)
return rows, total
async def soft_delete(
self,
tenant_id: UUID,
account_id: UUID,
*,
actor: CurrentUser | None = None,
) -> WalletAccount:
entity = await self.get(tenant_id, account_id)
actor_id = actor.user_id if actor else None
entity.account_number = release_unique_token(entity.account_number, entity.id)
entity.status = WalletAccountStatus.CLOSED
await self.accounts.soft_delete(entity, deleted_by=actor_id)
await self.audit.record(
tenant_id=tenant_id,
entity_type="wallet_account",
entity_id=entity.id,
action=AuditAction.WALLET_CLOSE,
actor_user_id=actor_id,
)
await self.publisher.publish(
event_type=LoyaltyEventType.WALLET_CLOSED,
aggregate_type="wallet_account",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"account_number": entity.account_number},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
# ------------------------------------------------------------------
# Ledger writes
# ------------------------------------------------------------------
async def _append(
self,
*,
tenant_id: UUID,
account: WalletAccount,
entry_type: WalletLedgerEntryType,
signed: int,
body_idempotency: str | None,
reason: str | None,
reference_type: str | None,
reference_id: str | None,
metadata: dict | None,
actor: CurrentUser | None,
event_type: LoyaltyEventType,
audit_action: AuditAction,
auto_commit: bool = True,
) -> WalletLedgerEntry:
if body_idempotency:
existing = await self.ledger.get_by_idempotency(tenant_id, body_idempotency)
if existing is not None:
return existing
balance = await self.ledger.balance_for_account(tenant_id, account.id)
new_balance = balance + signed
if new_balance < 0:
raise AppError(
"موجودی کیف پول کافی نیست",
status_code=409,
error_code="wallet_insufficient_funds",
details={"balance": balance, "requested": signed},
)
actor_id = actor.user_id if actor else None
entry = WalletLedgerEntry(
tenant_id=tenant_id,
wallet_account_id=account.id,
program_id=account.program_id,
member_id=account.member_id,
entry_type=entry_type,
amount=signed,
balance_after=new_balance,
idempotency_key=body_idempotency,
reference_type=reference_type,
reference_id=reference_id,
reason=reason,
metadata_json=metadata,
actor_user_id=actor_id,
created_at=_now(),
)
await self.ledger.add(entry)
await self.audit.record(
tenant_id=tenant_id,
entity_type="wallet_ledger_entry",
entity_id=entry.id,
action=audit_action,
actor_user_id=actor_id,
message=f"{entry_type.value} {signed} on {account.account_number}",
changes={
"amount": signed,
"balance_after": new_balance,
"wallet_account_id": str(account.id),
},
)
await self.publisher.publish(
event_type=event_type,
aggregate_type="wallet_ledger_entry",
aggregate_id=entry.id,
tenant_id=tenant_id,
payload={
"wallet_account_id": str(account.id),
"entry_type": entry_type.value,
"amount": signed,
"balance_after": new_balance,
},
)
if auto_commit:
await self.session.commit()
await self.session.refresh(entry)
return entry
async def credit(
self,
tenant_id: UUID,
account_id: UUID,
body: WalletCreditRequest,
*,
actor: CurrentUser | None = None,
auto_commit: bool = True,
) -> WalletLedgerEntry:
account = await self._get_open_account(tenant_id, account_id)
amount = validate_positive_amount(body.amount)
return await self._append(
tenant_id=tenant_id,
account=account,
entry_type=WalletLedgerEntryType.CREDIT,
signed=signed_amount(WalletLedgerEntryType.CREDIT, amount),
body_idempotency=body.idempotency_key,
reason=body.reason,
reference_type=body.reference_type,
reference_id=body.reference_id,
metadata=body.metadata,
actor=actor,
event_type=LoyaltyEventType.WALLET_CREDITED,
audit_action=AuditAction.WALLET_CREDIT,
auto_commit=auto_commit,
)
async def debit(
self,
tenant_id: UUID,
account_id: UUID,
body: WalletDebitRequest,
*,
actor: CurrentUser | None = None,
auto_commit: bool = True,
) -> WalletLedgerEntry:
account = await self._get_open_account(tenant_id, account_id)
amount = validate_positive_amount(body.amount)
return await self._append(
tenant_id=tenant_id,
account=account,
entry_type=WalletLedgerEntryType.DEBIT,
signed=signed_amount(WalletLedgerEntryType.DEBIT, amount),
body_idempotency=body.idempotency_key,
reason=body.reason,
reference_type=body.reference_type,
reference_id=body.reference_id,
metadata=body.metadata,
actor=actor,
event_type=LoyaltyEventType.WALLET_DEBITED,
audit_action=AuditAction.WALLET_DEBIT,
auto_commit=auto_commit,
)
async def adjust(
self,
tenant_id: UUID,
account_id: UUID,
body: WalletAdjustRequest,
*,
actor: CurrentUser | None = None,
auto_commit: bool = True,
) -> WalletLedgerEntry:
account = await self._get_open_account(tenant_id, account_id)
amount = validate_adjust_amount(body.amount)
return await self._append(
tenant_id=tenant_id,
account=account,
entry_type=WalletLedgerEntryType.ADJUST,
signed=signed_amount(WalletLedgerEntryType.ADJUST, amount),
body_idempotency=body.idempotency_key,
reason=body.reason,
reference_type=body.reference_type,
reference_id=body.reference_id,
metadata=body.metadata,
actor=actor,
event_type=LoyaltyEventType.WALLET_ADJUSTED,
audit_action=AuditAction.WALLET_ADJUST,
auto_commit=auto_commit,
)
async def transfer(
self,
tenant_id: UUID,
source_account_id: UUID,
body: WalletTransferRequest,
*,
actor: CurrentUser | None = None,
) -> tuple[WalletLedgerEntry, WalletLedgerEntry]:
source = await self._get_open_account(tenant_id, source_account_id)
target = await self._get_open_account(tenant_id, body.target_wallet_id)
ensure_not_same_account(source.id, target.id)
ensure_same_currency(source.currency_code, target.currency_code)
amount = validate_positive_amount(body.amount)
out_key = f"{body.idempotency_key}:out" if body.idempotency_key else None
in_key = f"{body.idempotency_key}:in" if body.idempotency_key else None
if out_key:
existing_out = await self.ledger.get_by_idempotency(tenant_id, out_key)
if existing_out is not None:
existing_in = await self.ledger.get_by_idempotency(tenant_id, in_key)
return existing_out, existing_in
transfer_group = str(uuid4())
base_metadata = dict(body.metadata or {})
out_entry = await self._append(
tenant_id=tenant_id,
account=source,
entry_type=WalletLedgerEntryType.TRANSFER_OUT,
signed=signed_amount(WalletLedgerEntryType.TRANSFER_OUT, amount),
body_idempotency=out_key,
reason=body.reason,
reference_type="wallet_transfer",
reference_id=transfer_group,
metadata={**base_metadata, "transfer_group": transfer_group, "counterparty_wallet_id": str(target.id)},
actor=actor,
event_type=LoyaltyEventType.WALLET_TRANSFERRED,
audit_action=AuditAction.WALLET_TRANSFER,
auto_commit=False,
)
in_entry = await self._append(
tenant_id=tenant_id,
account=target,
entry_type=WalletLedgerEntryType.TRANSFER_IN,
signed=signed_amount(WalletLedgerEntryType.TRANSFER_IN, amount),
body_idempotency=in_key,
reason=body.reason,
reference_type="wallet_transfer",
reference_id=transfer_group,
metadata={**base_metadata, "transfer_group": transfer_group, "counterparty_wallet_id": str(source.id)},
actor=actor,
event_type=LoyaltyEventType.WALLET_TRANSFERRED,
audit_action=AuditAction.WALLET_TRANSFER,
auto_commit=False,
)
await self.session.commit()
await self.session.refresh(out_entry)
await self.session.refresh(in_entry)
return out_entry, in_entry