from io import BytesIO from typing import List from uuid import uuid4 from fastapi import HTTPException from number_to_string import get_string_by_number from sqlalchemy import select from sqlalchemy.orm import selectinload, joinedload from starlette import status from weasyprint import HTML, CSS import backend.config import constants from constants import MONTHS, ENV from external.billing import BillingClient, CreateBillingRequestValue, CreateBillRequestSchema, CreateBillRequestItems, \ BillStatusUpdateRequest, NotificationChannel, NotifyReceivedBillRequestSchema, DeleteBillRequestSchema, \ ProductBillingDocumentPdf, ServiceBillingDocumentPdf from models import CardBillRequest, Card, CardProduct, CardService as CardServiceModel, CardGroup, \ GroupBillRequest from schemas.auth import UserUnion from schemas.billing import * from services.base import BaseService from services.card import CardsService from utils.auth import verify_user_deal_editor from utils.list_utils import to_locale_number class BillingService(BaseService): async def _process_card_update_details( self, request: BillStatusUpdateRequest, ): bill_request = await self._get_card_bill_by_id(request.listener_transaction_id) if not bill_request: return bill_request.pdf_url = request.info.pdf_url bill_request.invoice_number = request.info.invoice_number async def _process_group_update_details( self, request: BillStatusUpdateRequest, ): prefix = "group-" if not request.listener_transaction_id.startswith(prefix): return request_id = int(request.listener_transaction_id.removeprefix(prefix)) group_bill_request = await self._get_group_bill_by_id(request_id) if not group_bill_request: return group_bill_request.pdf_url = request.info.pdf_url group_bill_request.invoice_number = request.info.invoice_number async def _process_update_details( self, request: BillStatusUpdateRequest ): billing_client = BillingClient(backend.config.BILLING_API_KEY) notify_received_request = NotifyReceivedBillRequestSchema( listener_transaction_id=request.listener_transaction_id, channel=NotificationChannel.PAYMENT_DETAILS, received=True ) response = await billing_client.notify_received(notify_received_request) if not response.ok: return if type(request.listener_transaction_id) is int: await self._process_card_update_details(request) else: await self._process_group_update_details(request) await self.session.commit() async def _process_card_update_verification( self, request: BillStatusUpdateRequest ): card_bill_request = await self._get_card_bill_by_id(request.listener_transaction_id) if not card_bill_request: return card_bill_request.paid = request.info.payed async def _process_group_update_verification( self, request: BillStatusUpdateRequest ): prefix = "group-" if not request.listener_transaction_id.startswith(prefix): return group_id = int(request.listener_transaction_id.removeprefix(prefix)) group_bill_request = await self._get_group_bill_by_id(group_id) if not group_bill_request: return group_bill_request.paid = request.info.payed async def _process_update_verification( self, request: BillStatusUpdateRequest ): billing_client = BillingClient(backend.config.BILLING_API_KEY) notify_received_request = NotifyReceivedBillRequestSchema( listener_transaction_id=request.listener_transaction_id, channel=NotificationChannel.PAYMENT_VERIFICATION, received=True ) response = await billing_client.notify_received(notify_received_request) if not response.ok: return if type(request.listener_transaction_id) is int: await self._process_card_update_verification(request) else: await self._process_group_update_verification(request) await self.session.commit() async def process_update( self, request: BillStatusUpdateRequest ): if request.channel == NotificationChannel.PAYMENT_DETAILS: await self._process_update_details(request) elif request.channel == NotificationChannel.PAYMENT_VERIFICATION: await self._process_update_verification(request) async def create_card_bill_request(self, card: Card) -> CardBillRequest: card_bill_request = CardBillRequest( card_id=card.id, created_at=datetime.datetime.now() ) self.session.add(card_bill_request) await self.session.flush() return card_bill_request async def create_group_bill_request(self, group: CardGroup) -> GroupBillRequest: group_bill_request = GroupBillRequest( group_id=group.id, created_at=datetime.datetime.now() ) self.session.add(group_bill_request) await self.session.flush() return group_bill_request @staticmethod def _get_billing_values_from_services(services) -> List[ CreateBillingRequestValue ]: billing_request_values: List[CreateBillingRequestValue] = [] for service in services: # Omit services that have no price if not service.price: continue billing_request_values.append( CreateBillingRequestValue( name=service.name, price=service.price, amount=service.quantity ) ) return billing_request_values async def create_card_billing(self, user, request: CreateCardBillRequest) -> CreateCardBillResponse: try: verify_user_deal_editor(user) card_service = CardsService(self.session) billing_client = BillingClient(backend.config.BILLING_API_KEY) basic_card: Card = await card_service.get_by_id(user, request.card_id, return_raw=True) if basic_card.group: cards = await self._get_cards_by_group_id(basic_card.group.id) else: cards = [basic_card] (services, products, is_size_needed) = await self._get_products_for_card(cards) services: dict[str, ServiceBillingDocumentPdf] products: dict[str, ProductBillingDocumentPdf] is_size_needed: bool billing_request_values: List[CreateBillingRequestValue] = self._get_billing_values_from_services( services.values() ) card = basic_card transaction_prefix = "group-" if card.group else "" inn: str = card.client.details.inn.strip() one_request_size = 100 for i in range(0, len(billing_request_values), one_request_size): values = billing_request_values[i:i + one_request_size] if basic_card.group: bill_request = await self.create_group_bill_request(basic_card.group) else: bill_request = await self.create_card_bill_request(basic_card) listener_transaction_id = transaction_prefix + str(bill_request.id) create_bill_request = CreateBillRequestSchema( listener_transaction_id=listener_transaction_id, payer_name=card.client.name, payer_inn=inn, payer_phone=card.client.details.phone_number, items=CreateBillRequestItems( values=values, ) ) create_bill_response = await billing_client.create(create_bill_request) if not create_bill_response.ok and 'Request already exists' in create_bill_response.message: await self._cancel_one_bill(bill_request, listener_transaction_id) create_bill_response = await billing_client.create(create_bill_request) if not create_bill_response.ok: raise Exception(create_bill_response.message or 'Неизвестная ошибка') if card.group: for card in card.group.cards: card.is_locked = True else: card.is_locked = True await self.session.commit() return CreateCardBillResponse(ok=True, message='Заявка успешно создана!') except Exception as e: await self.session.rollback() return CreateCardBillResponse(ok=False, message=str(e)) async def _get_card_bill_by_id(self, request_id: int) -> Optional[CardBillRequest]: return await self.session.scalar( select(CardBillRequest) .where(CardBillRequest.id == request_id) ) async def _get_card_bill_by_card_id(self, card_id: int) -> List[CardBillRequest]: result = await self.session.scalars( select(CardBillRequest) .where(CardBillRequest.card_id == card_id) ) return [*result.all()] async def _get_group_bill_by_id(self, request_id: int) -> Optional[GroupBillRequest]: return await self.session.scalar( select(GroupBillRequest) .where(GroupBillRequest.id == request_id) ) async def _get_group_bill_by_group_id(self, group_id: int) -> List[GroupBillRequest]: result = await self.session.scalars( select(GroupBillRequest) .where(GroupBillRequest.group_id == group_id) ) return [*result.all()] async def get_card_bill_by_id(self, card_id: int) -> GetCardBillById: bill = await self._get_card_bill_by_id(card_id) if not bill: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Card bill was not found') return GetCardBillById(card_bill=CardBillRequestSchema.model_validate(bill)) async def _cancel_one_bill(self, bill: CardBillRequest | GroupBillRequest, listener_transaction_id: str) -> None: billing_client = BillingClient(backend.config.BILLING_API_KEY) response = await billing_client.delete( DeleteBillRequestSchema(listener_transaction_id=listener_transaction_id) ) if not response.ok: raise Exception('Ошибка удаления счета со стороны Billing сервиса') await self.session.delete(bill) async def cancel_card_billing(self, user: UserUnion, request: CancelCardBillRequest) -> CancelCardBillResponse: try: verify_user_deal_editor(user) card = await self._get_card_by_id(request.card_id) if not card: return CancelCardBillResponse(ok=False, message='Сделка не найдена') if card.group: bills = await self._get_group_bill_by_group_id(card.group.id) else: bills = await self._get_card_bill_by_card_id(request.card_id) if not bills: return CancelCardBillResponse(ok=False, message='Заявка не найдена') for bill in bills: if card.group: listener_transaction_id = f"group-{bill.id}" else: listener_transaction_id = str(bill.id) await self._cancel_one_bill(bill, listener_transaction_id) await self.session.commit() return CancelCardBillResponse(ok=True, message='Заявка успешно отозвана') except Exception as e: await self.session.rollback() return CancelCardBillResponse(ok=False, message=str(e)) def _gen_key_for_service(self, service: ServiceBillingDocumentPdf) -> str: return f"{service.name}-{service.price}" def _gen_key_for_product(self, product: ProductBillingDocumentPdf) -> str: article = product.article if product.article else uuid4() return f"{article}-{product.size}-{product.price}" async def _get_products_for_card(self, cards: list[Card]) -> tuple[dict, dict, bool]: services: dict[str, ServiceBillingDocumentPdf] = {} products: dict[str, ProductBillingDocumentPdf] = {} is_size_needed: bool = False for card in cards: for product in card.products: product_price = 0 for service in product.services: service_data = ServiceBillingDocumentPdf( name=f'[{product.product.name}] - {service.service.name}', price=service.price, quantity=product.quantity ) key = self._gen_key_for_service(service_data) if key in services: services[key].quantity += product.quantity else: services[key] = service_data product_price += service_data.price is_size_needed = is_size_needed | bool(product.product.size) product_data = ProductBillingDocumentPdf( article=product.product.article, size=product.product.size if product.product.size else "", price=product_price, quantity=product.quantity, ) product_key = self._gen_key_for_product(product_data) if product_key in products: products[product_key].quantity += product_data.quantity else: products[product_key] = product_data for service in card.services: service_data = ServiceBillingDocumentPdf( name=service.service.name, price=service.price, quantity=service.quantity ) key = self._gen_key_for_service(service_data) if key in services: services[key].quantity += service_data.quantity else: services[key] = service_data return services, products, is_size_needed async def _get_card_by_id(self, card_id: int) -> Optional[Card]: card: Card | None = await self.session.scalar( select(Card) .where(Card.id == card_id) .options( selectinload(Card.products).selectinload(CardProduct.services), selectinload(Card.services).selectinload(CardServiceModel.service), joinedload(Card.shipping_warehouse), joinedload(Card.client), selectinload(Card.group).selectinload(CardGroup.cards), ) ) return card async def _get_cards_by_group_id(self, group_id: int) -> List[Card]: group: CardGroup | None = await self.session.scalar( select(CardGroup) .where(CardGroup.id == group_id) .options( selectinload(CardGroup.cards).selectinload(Card.products).selectinload(CardProduct.services), selectinload(CardGroup.cards).selectinload(Card.services).selectinload(CardServiceModel.service), selectinload(CardGroup.cards).joinedload(Card.shipping_warehouse), selectinload(CardGroup.cards).joinedload(Card.client), selectinload(CardGroup.cards).selectinload(Card.group).selectinload(CardGroup.cards), ) ) return group.cards if group else [] async def _create_billing_document_html(self, card_id: int): card = await self._get_card_by_id(card_id) if not card: return "" if card.group: cards = await self._get_cards_by_group_id(card.group.id) else: cards = [card] (services, products, is_size_needed) = await self._get_products_for_card(cards) price = sum((service.price * service.quantity for service in services.values())) price_words = get_string_by_number(price)[0:-10] price = to_locale_number(price) template = ENV.get_template("bill-of-payment.html") now = datetime.datetime.now() curr_date = f"{now.day} {MONTHS[now.month - 1]} {now.year} г." return template.render({ "products": products, "services": services, "is_size_needed": is_size_needed, "deal_price": price, "deal_price_words": price_words, "deal": card, "curr_date": curr_date }) async def create_billing_document_pdf(self, card_id) -> BytesIO: doc = await self._create_billing_document_html(card_id) pdf_file = BytesIO() HTML(string=doc).write_pdf(pdf_file, stylesheets=[CSS(constants.APP_PATH + '/static/css/bill-of-payment.css')]) return pdf_file