ifixkart-backend/Backend/app/services/OrderService.py

461 lines
16 KiB
Python

"""
@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