525 lines
19 KiB
Python
525 lines
19 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",
|
|
cash_amount: float = 0.0,
|
|
digital_amount: float = 0.0,
|
|
digital_method: Optional[str] = None,
|
|
razorpay_order_id: Optional[str] = None,
|
|
razorpay_payment_id: Optional[str] = None,
|
|
razorpay_signature: Optional[str] = None,
|
|
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, verifies Razorpay signature if digital/mixed, and generates the Sales Invoice.
|
|
"""
|
|
if not items_payload:
|
|
raise HTTPException(status_code=400, detail="Order must contain at least one item")
|
|
|
|
# Verify Razorpay signature if online or mixed digital payment
|
|
if payment_method.upper() in ["UPI", "CARD", "MIXED_PAYMENT"] and razorpay_payment_id:
|
|
from app.core.razorpay import RazorpayService
|
|
rzp = RazorpayService()
|
|
if razorpay_order_id and razorpay_signature:
|
|
is_valid = rzp.verify_payment_signature(
|
|
rzp_order_id=razorpay_order_id,
|
|
rzp_payment_id=razorpay_payment_id,
|
|
rzp_signature=razorpay_signature
|
|
)
|
|
if not is_valid:
|
|
raise HTTPException(status_code=400, detail="Razorpay digital payment signature verification failed")
|
|
|
|
# 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)
|
|
|
|
# Formulate detail payment log reason
|
|
if payment_method.upper() == "MIXED_PAYMENT":
|
|
pay_reason = f"Walk-in order created via Mixed Payment (Cash: ₹{cash_amount:.2f}, {digital_method or 'Digital'}: ₹{digital_amount:.2f}, Rzp ID: {razorpay_payment_id or 'N/A'})"
|
|
else:
|
|
pay_reason = f"Walk-in order created (Paid via {payment_method}, Rzp ID: {razorpay_payment_id or 'N/A'})"
|
|
|
|
history = OrderStatusHistory(
|
|
history_id=str(ulid.ULID()),
|
|
order_id=order_id,
|
|
previous_status=None,
|
|
new_status="ORDER_CONFIRMED",
|
|
changed_by=actor_email,
|
|
reason=pay_reason
|
|
)
|
|
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
|
|
|
|
@staticmethod
|
|
def cancel_ecommerce_order(
|
|
db: Session,
|
|
order: Order,
|
|
reason: str = "Payment failed or cancelled by user"
|
|
) -> Order:
|
|
"""
|
|
Cancels an unpaid e-commerce order: releases stock reservation and sets status to CANCELLED / PAYMENT_FAILED.
|
|
"""
|
|
if order.payment_status == "PAYMENT_CAPTURED":
|
|
return order
|
|
|
|
order.status = "CANCELLED"
|
|
order.payment_status = "PAYMENT_FAILED"
|
|
|
|
for item in order.items:
|
|
record_ledger_entry(
|
|
variant_id=item.variant_id,
|
|
event_type="ONLINE_CANCEL_RELEASE",
|
|
qty=0,
|
|
reference_id=order.order_id,
|
|
db=db,
|
|
notes=f"Stock reservation released for cancelled order {order.order_no}",
|
|
commit=False,
|
|
)
|
|
|
|
history = OrderStatusHistory(
|
|
history_id=str(ulid.ULID()),
|
|
order_id=order.order_id,
|
|
previous_status=order.status,
|
|
new_status="CANCELLED",
|
|
changed_by="SYSTEM",
|
|
reason=reason
|
|
)
|
|
db.add(history)
|
|
db.commit()
|
|
return order
|
|
|