488 lines
18 KiB
Python
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
|