""" @service OrderService (Backend/app/services/OrderService.py) @purpose Unified business logic for E-Commerce online checkout, Walk-in POS counter orders, and Razorpay payment confirmation. """ import ulid import json from datetime import datetime, timezone from typing import List, Optional, Dict, Any from sqlalchemy.orm import Session from fastapi import HTTPException, status from app.models.EcomCustomerModel import EcomCustomer, CustomerAddress from app.models.CartModel import Cart from app.models.ProductModel import ProductVariant from app.models.OrderModel import Order, OrderItem, OrderStatusHistory from app.models.InvoiceModel import Invoice from app.services.InventoryService import get_available_stock, record_ledger_entry FREE_SHIPPING_THRESHOLD = 99.0 FLAT_SHIPPING_COST = 15.0 def generate_order_number(db: Session) -> str: today = datetime.now(timezone.utc).strftime("%Y%m%d") last_order = ( db.query(Order) .filter(Order.order_no.like(f"ORD-{today}-%")) .order_by(Order.order_no.desc()) .first() ) if not last_order: seq = 1 else: try: seq = int(last_order.order_no.split("-")[-1]) + 1 except Exception: seq = 1 return f"ORD-{today}-{seq:04d}" def generate_invoice_number(db: Session) -> str: today = datetime.now() fy = today.strftime("%y") last_inv = db.query(Invoice).order_by(Invoice.created_at.desc()).first() if not last_inv: seq = 1 else: try: seq = int(last_inv.invoice_no.split("-")[-1]) + 1 except Exception: seq = 1 return f"C1P1-{fy}-{seq:06d}" def create_invoice_for_order(order: Order, db: Session) -> Invoice: """ Creates an Invoice database entry for an order. Memory-rendered on download. """ existing = db.query(Invoice).filter(Invoice.order_id == order.order_id).first() if existing: return existing tax_amount = float(order.tax_amount or 0) invoice = Invoice( invoice_id=str(ulid.ULID()), invoice_no=generate_invoice_number(db), order_id=order.order_id, customer_id=order.customer_id, subtotal=float(order.total_amount), discount_amount=float(order.discount_amount or 0), cgst=round(tax_amount / 2, 2), sgst=round(tax_amount / 2, 2), igst=0.0, total_amount=float(order.final_amount), pdf_path=None, # Enforce dynamic on-the-fly PDF rendering status="GENERATED", ) db.add(invoice) db.flush() return invoice class OrderService: @staticmethod def create_ecommerce_order( db: Session, customer: EcomCustomer, address_id: str, payment_method: str = "COD" ) -> Dict[str, Any]: """ Processes cart validation, stock reservation, order creation, and conditionally creates invoices for COD. """ cart = db.query(Cart).filter(Cart.customer_id == customer.customer_id).first() if not cart or not cart.items_json: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Your cart is empty. Cannot initiate checkout." ) items = cart.items_json if isinstance(items, str): items = json.loads(items) if not isinstance(items, list) or len(items) == 0: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Your cart is empty. Cannot initiate checkout." ) address = db.query(CustomerAddress).filter( CustomerAddress.address_id == address_id, CustomerAddress.customer_id == customer.customer_id ).first() if not address: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Selected shipping address not found" ) address_dict = { "full_name": address.full_name, "phone": address.phone, "street_address": address.street_address, "city": address.city, "state": address.state, "pincode": address.pincode, "address_type": address.address_type } address_json = json.dumps(address_dict) validated_items = [] subtotal = 0.0 for item in items: variant_id = item.get("variant_id") qty = int(item.get("qty", 1)) variant = ( db.query(ProductVariant) .filter(ProductVariant.variant_id == variant_id) .with_for_update() .first() ) if not variant: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Product variant {variant_id} no longer exists." ) available = get_available_stock(variant_id, db) if available < qty: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Insufficient stock for variant {variant.sku}. Available: {available}, requested: {qty}." ) product_name = "Product Variant" if variant.product: product_name = variant.product.name unit_price = float(variant.price) total_price = unit_price * qty subtotal += total_price validated_items.append({ "variant": variant, "product_name": product_name, "qty": qty, "unit_price": unit_price, "total_price": total_price }) discount = 0.0 shipping_cost = 0.0 if subtotal >= FREE_SHIPPING_THRESHOLD else FLAT_SHIPPING_COST taxable_amount = subtotal - discount tax_rate = 18.0 tax_amount = round((taxable_amount * tax_rate) / 100, 2) final_amount = round(subtotal - discount + tax_amount + shipping_cost, 2) method_upper = payment_method.upper() order_id = str(ulid.ULID()) order_no = generate_order_number(db) order = Order( order_id=order_id, order_no=order_no, customer_id=customer.customer_id, total_amount=subtotal, discount_amount=discount, tax_amount=tax_amount, shipping_cost=shipping_cost, final_amount=final_amount, status="ORDER_CREATED", payment_status="PAYMENT_PENDING", fulfillment_status="UNFULFILLED", shipping_address_json=address_json, billing_address_json=address_json ) db.add(order) for vi in validated_items: order_item = OrderItem( item_id=str(ulid.ULID()), order_id=order_id, product_id=vi["variant"].product_id, variant_id=vi["variant"].variant_id, product_name=vi["product_name"], sku=vi["variant"].sku, unit_price=vi["unit_price"], quantity=vi["qty"], total_price=vi["total_price"] ) db.add(order_item) record_ledger_entry( variant_id=vi["variant"].variant_id, event_type="ONLINE_RESERVE", qty=0, reference_id=order_id, db=db, notes=f"Stock reservation for checkout order {order_no}", commit=False, ) history = OrderStatusHistory( history_id=str(ulid.ULID()), order_id=order_id, previous_status=None, new_status="ORDER_CREATED", changed_by=customer.email, reason=f"Order checkout created ({method_upper})" ) db.add(history) # For COD, generate Invoice immediately. For RAZORPAY, generate after capture. invoice = None if method_upper == "COD": invoice = create_invoice_for_order(order, db) cart.items_json = None db.commit() return { "order_id": order.order_id, "order_no": order.order_no, "total_amount": float(order.total_amount), "tax_amount": float(order.tax_amount), "shipping_cost": float(order.shipping_cost), "final_amount": float(order.final_amount), "status": order.status, "payment_status": order.payment_status, "payment_method": method_upper, "invoice_id": invoice.invoice_id if invoice else None, "invoice_no": invoice.invoice_no if invoice else None, } @staticmethod def create_walk_in_order( db: Session, items_payload: List[Dict[str, Any]], customer_name: Optional[str] = None, customer_email: Optional[str] = None, customer_phone: Optional[str] = None, shipping_address: Optional[str] = None, payment_status: str = "PAID", payment_method: str = "CASH", actor_email: str = "SYSTEM" ) -> Dict[str, Any]: """ Creates a walk-in / POS counter sale order, deducts stock instantly (POS_SALE), resolves guest details if empty, and generates the GST invoice row. """ if not items_payload: raise HTTPException(status_code=400, detail="Order must contain at least one item") # Resolve or create Customer (Guest profile fallback if empty) phone_clean = (customer_phone or "").strip() email_clean = (customer_email or "").strip() name_clean = (customer_name or "").strip() customer = None if email_clean: customer = db.query(EcomCustomer).filter(EcomCustomer.email == email_clean).first() elif phone_clean: customer = db.query(EcomCustomer).filter(EcomCustomer.phone == phone_clean).first() if not customer: guest_suffix = str(ulid.ULID())[-8:].lower() first_name = name_clean.split(" ")[0] if name_clean else "Walk-in" last_name = " ".join(name_clean.split(" ")[1:]) if name_clean and len(name_clean.split(" ")) > 1 else "Guest" customer = EcomCustomer( customer_id=str(ulid.ULID()), email=email_clean or f"guest_{guest_suffix}@store.local", first_name=first_name, last_name=last_name, phone=phone_clean if phone_clean else None, is_active=True ) db.add(customer) db.commit() db.refresh(customer) subtotal = 0.0 validated_items = [] for item in items_payload: variant_id = item.get("variant_id") quantity = int(item.get("quantity", 1)) unit_price = float(item.get("unit_price", 0.0)) variant = db.query(ProductVariant).filter(ProductVariant.variant_id == variant_id).with_for_update().first() if not variant: raise HTTPException(status_code=404, detail=f"Product variant {variant_id} not found") # For Walk-in POS Counter sales, allow selling items physically present in store regardless of digital stock count available = get_available_stock(variant_id, db) total_price = unit_price * quantity subtotal += total_price validated_items.append({ "variant": variant, "product_name": variant.product.name if variant.product else "Variant Product", "sku": variant.sku, "qty": quantity, "unit_price": unit_price, "total_price": total_price }) tax_amount = round((subtotal * 18.0) / 100, 2) final_amount = round(subtotal + tax_amount, 2) order_no = generate_order_number(db) order_id = str(ulid.ULID()) addr_str = shipping_address or "Offline Counter / Main Store Floor" address_json = json.dumps({ "full_name": customer.first_name + " " + customer.last_name, "phone": customer.phone, "street_address": addr_str }) order = Order( order_id=order_id, order_no=order_no, customer_id=customer.customer_id, total_amount=subtotal, discount_amount=0.0, tax_amount=tax_amount, shipping_cost=0.0, final_amount=final_amount, status="ORDER_CONFIRMED", payment_status="PAID" if payment_status.upper() == "PAID" else "PAYMENT_PENDING", fulfillment_status="FULFILLED", shipping_address_json=address_json, billing_address_json=address_json ) db.add(order) for vi in validated_items: order_item = OrderItem( item_id=str(ulid.ULID()), order_id=order_id, product_id=vi["variant"].product_id, variant_id=vi["variant"].variant_id, product_name=vi["product_name"], sku=vi["sku"], unit_price=vi["unit_price"], quantity=vi["qty"], total_price=vi["total_price"] ) db.add(order_item) record_ledger_entry( variant_id=vi["variant"].variant_id, event_type="POS_SALE", qty=-vi["qty"], reference_id=order_id, db=db, notes=f"Counter sale walk-in checkout order {order_no}", commit=False ) invoice = create_invoice_for_order(order, db) history = OrderStatusHistory( history_id=str(ulid.ULID()), order_id=order_id, previous_status=None, new_status="ORDER_CONFIRMED", changed_by=actor_email, reason=f"Walk-in counter order created (Paid via {payment_method})" ) db.add(history) db.commit() db.refresh(order) return { "order_id": order.order_id, "order_no": order.order_no, "customer_id": customer.customer_id, "customer_email": customer.email, "customer_name": f"{customer.first_name} {customer.last_name}", "total_amount": float(order.total_amount), "discount_amount": float(order.discount_amount), "tax_amount": float(order.tax_amount), "shipping_cost": float(order.shipping_cost), "final_amount": float(order.final_amount), "status": order.status, "payment_status": order.payment_status, "fulfillment_status": order.fulfillment_status, "invoice_id": invoice.invoice_id if invoice else None, "invoice_no": invoice.invoice_no if invoice else None, "created_at": order.created_at } @staticmethod def confirm_ecommerce_payment( db: Session, order: Order, payment_method: str = "RAZORPAY" ) -> Invoice: """ Confirms an e-commerce online order payment: deducts stock (ONLINE_SALE_FROM_RESERVATION), transitions order statuses to ORDER_CONFIRMED / PAYMENT_CAPTURED, and creates Invoice. """ if order.payment_status == "PAYMENT_CAPTURED": existing_inv = db.query(Invoice).filter(Invoice.order_id == order.order_id).first() return existing_inv # 1. Transition statuses order.status = "ORDER_CONFIRMED" order.payment_status = "PAYMENT_CAPTURED" # 2. Record inventory sale deduction for item in order.items: record_ledger_entry( variant_id=item.variant_id, event_type="ONLINE_SALE_FROM_RESERVATION", qty=-item.quantity, reference_id=order.order_id, db=db, notes=f"Physical stock sale confirmed from reservation for order {order.order_no}", commit=False, ) # 3. Create Invoice invoice = create_invoice_for_order(order, db) # 4. History log history = OrderStatusHistory( history_id=str(ulid.ULID()), order_id=order.order_id, previous_status="PAYMENT_PENDING", new_status="ORDER_CONFIRMED", changed_by="SYSTEM", reason=f"Payment verified via {payment_method}" ) db.add(history) db.commit() return invoice