"""Business logic service for receipts workflow.""" from decimal import Decimal, ROUND_HALF_UP from typing import List, Optional, Tuple from sqlalchemy.ext.asyncio import AsyncSession from backend.modules.data_entry.db.models.receipt import Receipt, ReceiptStatus, ReceiptDirection from backend.modules.data_entry.db.models.accounting_entry import EntryType from backend.modules.data_entry.db.crud.receipt import ReceiptCRUD from backend.modules.data_entry.db.crud.accounting_entry import AccountingEntryCRUD from backend.modules.data_entry.schemas.receipt import ( ReceiptCreate, ReceiptUpdate, ReceiptFilter, ReceiptResponse, ReceiptListResponse, AccountingEntryCreate, ) from backend.modules.data_entry.services.expense_types import EXPENSE_TYPES, get_expense_type # Payment mode to accounting account mapping PAYMENT_MODE_ACCOUNTS = { 'casa': ('5311', 'Casa in lei'), 'banca': ('5121', 'Conturi la banci in lei'), 'avans_decontare': ('542', 'Avansuri de trezorerie'), } class ReceiptService: """Service for receipt business logic and workflow.""" @staticmethod async def create_receipt( session: AsyncSession, data: ReceiptCreate, created_by: str, ) -> Receipt: """Create a new receipt in DRAFT status.""" return await ReceiptCRUD.create(session, data, created_by) @staticmethod async def get_receipt( session: AsyncSession, receipt_id: int, ) -> Optional[Receipt]: """Get receipt by ID with all relationships.""" return await ReceiptCRUD.get_by_id(session, receipt_id, include_relations=True) @staticmethod async def get_receipts( session: AsyncSession, filters: ReceiptFilter, ) -> ReceiptListResponse: """Get paginated list of receipts.""" receipts, total = await ReceiptCRUD.get_list(session, filters) pages = (total + filters.page_size - 1) // filters.page_size if total > 0 else 1 return ReceiptListResponse( items=[ReceiptResponse.model_validate(r) for r in receipts], total=total, page=filters.page, page_size=filters.page_size, pages=pages, ) @staticmethod async def update_receipt( session: AsyncSession, receipt_id: int, data: ReceiptUpdate, username: str, ) -> Tuple[bool, str, Optional[Receipt]]: """ Update receipt (only DRAFT status). Returns (success, message, receipt). """ receipt = await ReceiptCRUD.get_by_id(session, receipt_id) if not receipt: return False, "Receipt not found", None if not await ReceiptCRUD.can_edit(receipt, username): return False, "Cannot edit this receipt", None updated = await ReceiptCRUD.update(session, receipt, data) return True, "Receipt updated", updated @staticmethod async def delete_receipt( session: AsyncSession, receipt_id: int, username: str, ) -> Tuple[bool, str]: """ Delete receipt (only DRAFT status). Returns (success, message). """ receipt = await ReceiptCRUD.get_by_id(session, receipt_id) if not receipt: return False, "Receipt not found" if not await ReceiptCRUD.can_delete(receipt, username): return False, "Cannot delete this receipt" await ReceiptCRUD.delete(session, receipt) return True, "Receipt deleted" @staticmethod def generate_accounting_entries(receipt: Receipt) -> List[AccountingEntryCreate]: """ Generate accounting entries based on receipt data and expense type. """ entries: List[AccountingEntryCreate] = [] # Get expense type configuration expense_type = get_expense_type(receipt.expense_type_code or "OTHER") if not expense_type: expense_type = EXPENSE_TYPES["OTHER"] amount = Decimal(str(receipt.amount)) if receipt.direction == ReceiptDirection.CHELTUIALA: # Expense: Debit expense account, Credit cash/bank if expense_type.has_vat: # Calculate net and VAT vat_rate = expense_type.vat_percent / Decimal("100") net_amount = (amount / (1 + vat_rate)).quantize( Decimal("0.01"), rounding=ROUND_HALF_UP ) vat_amount = amount - net_amount # Debit: Expense account (net) entries.append(AccountingEntryCreate( entry_type=EntryType.DEBIT, account_code=expense_type.account_code, account_name=expense_type.account_name, amount=net_amount, )) # Debit: VAT deductible entries.append(AccountingEntryCreate( entry_type=EntryType.DEBIT, account_code=expense_type.vat_account, account_name="TVA deductibila", amount=vat_amount, )) else: # No VAT - full amount to expense entries.append(AccountingEntryCreate( entry_type=EntryType.DEBIT, account_code=expense_type.account_code, account_name=expense_type.account_name, amount=amount, )) # Credit entry - based on payment_mode (new) or cash_register (legacy) if receipt.payment_mode and receipt.payment_mode in PAYMENT_MODE_ACCOUNTS: credit_account, credit_name = PAYMENT_MODE_ACCOUNTS[receipt.payment_mode] elif receipt.cash_register_account: # Backwards compatibility for existing receipts credit_account = receipt.cash_register_account credit_name = receipt.cash_register_name or "Casa/Banca" else: # Default fallback credit_account = "5311" credit_name = "Casa in lei" entries.append(AccountingEntryCreate( entry_type=EntryType.CREDIT, account_code=credit_account, account_name=credit_name, amount=amount, )) else: # Income: Debit cash/bank, Credit income account # Based on payment_mode (new) or cash_register (legacy) if receipt.payment_mode and receipt.payment_mode in PAYMENT_MODE_ACCOUNTS: cash_account, cash_name = PAYMENT_MODE_ACCOUNTS[receipt.payment_mode] elif receipt.cash_register_account: cash_account = receipt.cash_register_account cash_name = receipt.cash_register_name or "Casa/Banca" else: cash_account = "5311" cash_name = "Casa in lei" # Debit: Cash/Bank entries.append(AccountingEntryCreate( entry_type=EntryType.DEBIT, account_code=cash_account, account_name=cash_name, amount=amount, )) # Credit: Income account (7xx - to be configured) entries.append(AccountingEntryCreate( entry_type=EntryType.CREDIT, account_code="7588", account_name="Alte venituri din exploatare", amount=amount, )) return entries @staticmethod async def submit_for_review( session: AsyncSession, receipt_id: int, username: str, ) -> Tuple[bool, str, Optional[Receipt]]: """ Submit receipt for review (DRAFT/REJECTED → PENDING_REVIEW). Generates accounting entries automatically. """ receipt = await ReceiptCRUD.get_by_id(session, receipt_id) if not receipt: return False, "Receipt not found", None if not await ReceiptCRUD.can_submit(receipt, username): return False, "Cannot submit this receipt", None # Check if receipt has at least one attachment if not receipt.attachments: return False, "Receipt must have at least one attachment", None # Check required fields if not receipt.expense_type_code: return False, "Expense type is required", None # Validate payment_mode or cash_register (backwards compatibility) if not receipt.payment_mode and not receipt.cash_register_account: return False, "Modul de plata este obligatoriu", None # Generate accounting entries entries = ReceiptService.generate_accounting_entries(receipt) # Delete existing entries and create new ones await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id) await AccountingEntryCRUD.create_bulk(session, receipt_id, entries, is_auto_generated=True) # Refresh receipt to clear stale relationship references after entry deletion await session.refresh(receipt) # Update status updated = await ReceiptCRUD.update_status( session, receipt, ReceiptStatus.PENDING_REVIEW ) # Reload with entries updated = await ReceiptCRUD.get_by_id(session, receipt_id) return True, "Receipt submitted for review", updated @staticmethod async def approve_receipt( session: AsyncSession, receipt_id: int, username: str, ) -> Tuple[bool, str, Optional[Receipt]]: """ Approve receipt (PENDING_REVIEW → APPROVED). Requires valid CUI (fiscal code) for approval. """ receipt = await ReceiptCRUD.get_by_id(session, receipt_id) if not receipt: return False, "Receipt not found", None if receipt.status != ReceiptStatus.PENDING_REVIEW: return False, "Receipt is not pending review", None # Validate CUI is present (required for Oracle import) if not receipt.cui: return False, "Trebuie completat codul fiscal (CUI) pentru aprobare", None # Validate accounting entries if not receipt.entries: return False, "Receipt has no accounting entries", None # Update status updated = await ReceiptCRUD.update_status( session, receipt, ReceiptStatus.APPROVED, reviewed_by=username ) return True, "Receipt approved", updated @staticmethod async def unapprove_receipt( session: AsyncSession, receipt_id: int, username: str, ) -> Tuple[bool, str, Optional[Receipt]]: """ Unapprove receipt (APPROVED → PENDING_REVIEW). Returns receipt to pending review for corrections. """ receipt = await ReceiptCRUD.get_by_id(session, receipt_id) if not receipt: return False, "Receipt not found", None if receipt.status != ReceiptStatus.APPROVED: return False, "Receipt is not approved", None # Update status back to pending review updated = await ReceiptCRUD.update_status( session, receipt, ReceiptStatus.PENDING_REVIEW ) return True, "Receipt returned to pending review", updated @staticmethod async def reject_receipt( session: AsyncSession, receipt_id: int, username: str, reason: str, ) -> Tuple[bool, str, Optional[Receipt]]: """ Reject receipt (PENDING_REVIEW → REJECTED). """ receipt = await ReceiptCRUD.get_by_id(session, receipt_id) if not receipt: return False, "Receipt not found", None if receipt.status != ReceiptStatus.PENDING_REVIEW: return False, "Receipt is not pending review", None # Update status updated = await ReceiptCRUD.update_status( session, receipt, ReceiptStatus.REJECTED, reviewed_by=username, rejection_reason=reason, ) return True, "Receipt rejected", updated @staticmethod async def resubmit_receipt( session: AsyncSession, receipt_id: int, username: str, ) -> Tuple[bool, str, Optional[Receipt]]: """ Resubmit rejected receipt after corrections (REJECTED → PENDING_REVIEW). """ receipt = await ReceiptCRUD.get_by_id(session, receipt_id) if not receipt: return False, "Receipt not found", None if receipt.status != ReceiptStatus.REJECTED: return False, "Receipt is not rejected", None if receipt.created_by != username: return False, "Only the creator can resubmit", None # Re-generate accounting entries entries = ReceiptService.generate_accounting_entries(receipt) await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id) await AccountingEntryCRUD.create_bulk(session, receipt_id, entries, is_auto_generated=True) # Refresh receipt to clear stale relationship references after entry deletion await session.refresh(receipt) # Update status updated = await ReceiptCRUD.update_status( session, receipt, ReceiptStatus.PENDING_REVIEW ) # Reload with entries updated = await ReceiptCRUD.get_by_id(session, receipt_id) return True, "Receipt resubmitted for review", updated @staticmethod async def regenerate_entries( session: AsyncSession, receipt_id: int, username: str, ) -> Tuple[bool, str, List[AccountingEntryCreate]]: """ Regenerate accounting entries for a receipt. """ receipt = await ReceiptCRUD.get_by_id(session, receipt_id) if not receipt: return False, "Receipt not found", [] if receipt.status not in [ReceiptStatus.DRAFT, ReceiptStatus.PENDING_REVIEW]: return False, "Cannot regenerate entries for this receipt status", [] # Generate new entries entries = ReceiptService.generate_accounting_entries(receipt) # Replace existing entries await AccountingEntryCRUD.delete_all_for_receipt(session, receipt_id) await AccountingEntryCRUD.create_bulk(session, receipt_id, entries, is_auto_generated=True) return True, "Entries regenerated", entries @staticmethod async def update_entries( session: AsyncSession, receipt_id: int, entries: List[AccountingEntryCreate], username: str, ) -> Tuple[bool, str, List]: """ Update accounting entries for a receipt (accountant action). """ receipt = await ReceiptCRUD.get_by_id(session, receipt_id) if not receipt: return False, "Receipt not found", [] if receipt.status != ReceiptStatus.PENDING_REVIEW: return False, "Can only modify entries for receipts pending review", [] # Validate entries is_valid, error = await AccountingEntryCRUD.validate_entries(entries) if not is_valid: return False, error, [] # Replace entries updated_entries = await AccountingEntryCRUD.replace_all_for_receipt( session, receipt_id, entries, username ) return True, "Entries updated", updated_entries @staticmethod async def get_pending_count( session: AsyncSession, company_id: Optional[int] = None, ) -> int: """Get count of receipts pending review.""" receipts = await ReceiptCRUD.get_pending_review(session, company_id) return len(receipts)