Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 91 additions & 27 deletions backend/app/services/quote_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from app.models.quote import Quote, QuoteLine
from app.models.sales_order import SalesOrder, SalesOrderLine
from app.models.user import User
from app.services.tax_calculation_service import calculate_sales_tax

logger = get_logger(__name__)

Expand Down Expand Up @@ -172,11 +173,31 @@ def _resolve_tax(db: Session, subtotal: Decimal, request, company_settings) -> t
default_tr = get_default_tax_rate(db)
if default_tr:
return default_tr.rate, subtotal * default_tr.rate, default_tr.name
elif company_settings and company_settings.tax_rate:
elif company_settings and company_settings.tax_rate is not None:
return company_settings.tax_rate, subtotal * company_settings.tax_rate, company_settings.tax_name
return None, None, None


def _calculate_quote_tax_amount(
*,
subtotal: Decimal,
tax_rate: Optional[Decimal],
shipping_cost: Decimal,
ship_to_state: Optional[str] = None,
company_settings: Optional[CompanySettings] = None,
) -> Optional[Decimal]:
if tax_rate is None:
return None

return calculate_sales_tax(
subtotal=subtotal,
tax_rate=tax_rate,
shipping_cost=shipping_cost,
ship_to_state=ship_to_state,
seller_state=company_settings.company_state if company_settings else None,
).tax_amount


def _get_customer_discount(db: Session, customer_id: int) -> Optional[Decimal]:
"""Look up customer's price level discount (PRO feature, graceful degradation)."""
from app.services.customer_service import get_customer_discount_percent
Expand Down Expand Up @@ -260,12 +281,16 @@ def create_quote(db: Session, request, user_id: int) -> Quote:
company_settings = db.query(CompanySettings).filter(CompanySettings.id == 1).first()

# Resolve tax
tax_rate, tax_amount, tax_name = _resolve_tax(db, subtotal, request, company_settings)
total_price = subtotal + (tax_amount or Decimal("0"))

# Add shipping cost
tax_rate, _tax_amount, tax_name = _resolve_tax(db, subtotal, request, company_settings)
shipping_cost = request.shipping_cost or Decimal("0")
total_price = total_price + shipping_cost
tax_amount = _calculate_quote_tax_amount(
subtotal=subtotal,
tax_rate=tax_rate,
shipping_cost=shipping_cost,
ship_to_state=getattr(request, "shipping_state", None),
company_settings=company_settings,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
total_price = subtotal + (tax_amount or Decimal("0")) + shipping_cost

# Validate material exists if color provided (single-item only)
effective_material_type = request.material_type or "PLA"
Expand Down Expand Up @@ -294,6 +319,7 @@ def create_quote(db: Session, request, user_id: int) -> Quote:
tax_name=tax_name,
discount_percent=discount_percent,
shipping_cost=shipping_cost if shipping_cost > 0 else None,
shipping_state=getattr(request, "shipping_state", None),
total_price=total_price,
material_type=effective_material_type if not has_lines else None,
color=request.color if not has_lines else None,
Expand Down Expand Up @@ -404,10 +430,25 @@ def update_quote(db: Session, quote_id: int, request) -> Quote:
# Update fields (exclude apply_tax as it's not a model field)
apply_tax = update_data.pop("apply_tax", None)
shipping_cost_updated = "shipping_cost" in update_data
shipping_state_updated = "shipping_state" in update_data

for field, value in update_data.items():
setattr(quote, field, value)

should_recalculate = (
lines_data is not None
or request.unit_price is not None
or request.quantity is not None
or apply_tax is not None
or shipping_cost_updated
or shipping_state_updated
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
company_settings = (
db.query(CompanySettings).filter(CompanySettings.id == 1).first()
if should_recalculate
else None
)

# If lines provided, replace all existing lines and recalculate from them
if lines_data is not None:
# Reject empty lines array (would crash on index access)
Expand Down Expand Up @@ -468,53 +509,76 @@ def update_quote(db: Session, quote_id: int, request) -> Quote:
quote.subtotal = subtotal

# Resolve tax (respect apply_tax toggle during multi-line edit)
company_settings = db.query(CompanySettings).filter(CompanySettings.id == 1).first()
shipping = quote.shipping_cost or Decimal("0")
if apply_tax is not None:
if apply_tax:
tax_rate, tax_amount, tax_name = _resolve_tax(db, subtotal, request, company_settings)
tax_rate, _tax_amount, tax_name = _resolve_tax(db, subtotal, request, company_settings)
quote.tax_rate = tax_rate
quote.tax_amount = tax_amount
quote.tax_amount = _calculate_quote_tax_amount(
subtotal=subtotal,
tax_rate=tax_rate,
shipping_cost=shipping,
ship_to_state=quote.shipping_state,
company_settings=company_settings,
)
quote.tax_name = tax_name
quote.total_price = subtotal + (tax_amount or Decimal("0")) + shipping
quote.total_price = subtotal + (quote.tax_amount or Decimal("0")) + shipping
else:
quote.tax_rate = None
quote.tax_amount = None
quote.total_price = subtotal + shipping
elif quote.tax_rate:
quote.tax_amount = subtotal * quote.tax_rate
quote.total_price = subtotal + quote.tax_amount + shipping
quote.tax_amount = _calculate_quote_tax_amount(
subtotal=subtotal,
tax_rate=quote.tax_rate,
shipping_cost=shipping,
ship_to_state=quote.shipping_state,
company_settings=company_settings,
)
quote.total_price = subtotal + (quote.tax_amount or Decimal("0")) + shipping
else:
quote.total_price = subtotal + shipping

# Recalculate pricing for single-item updates (only if no lines provided)
elif request.unit_price is not None or request.quantity is not None or apply_tax is not None or shipping_cost_updated:
unit_price = request.unit_price if request.unit_price is not None else quote.unit_price
quantity = request.quantity if request.quantity is not None else quote.quantity
subtotal = unit_price * quantity
elif should_recalculate:
if request.unit_price is None and quote.unit_price is None:
subtotal = quote.subtotal or Decimal("0")
else:
unit_price = request.unit_price if request.unit_price is not None else quote.unit_price
quantity = request.quantity if request.quantity is not None else quote.quantity
subtotal = unit_price * quantity
quote.subtotal = subtotal
shipping = quote.shipping_cost or Decimal("0")

# Handle tax calculation
if apply_tax is not None:
if apply_tax:
company_settings = db.query(CompanySettings).filter(CompanySettings.id == 1).first()
if company_settings and company_settings.tax_rate:
quote.tax_rate = company_settings.tax_rate
quote.tax_amount = subtotal * company_settings.tax_rate
quote.total_price = subtotal + quote.tax_amount + shipping
else:
quote.tax_rate = None
quote.tax_amount = None
quote.total_price = subtotal + shipping
tax_rate, _tax_amount, tax_name = _resolve_tax(db, subtotal, request, company_settings)
quote.tax_rate = tax_rate
quote.tax_amount = _calculate_quote_tax_amount(
subtotal=subtotal,
tax_rate=tax_rate,
shipping_cost=shipping,
ship_to_state=quote.shipping_state,
company_settings=company_settings,
)
quote.tax_name = tax_name
quote.total_price = subtotal + (quote.tax_amount or Decimal("0")) + shipping
else:
quote.tax_rate = None
quote.tax_amount = None
quote.tax_name = None
quote.total_price = subtotal + shipping
else:
if quote.tax_rate:
quote.tax_amount = subtotal * quote.tax_rate
quote.total_price = subtotal + quote.tax_amount + shipping
quote.tax_amount = _calculate_quote_tax_amount(
subtotal=subtotal,
tax_rate=quote.tax_rate,
shipping_cost=shipping,
ship_to_state=quote.shipping_state,
company_settings=company_settings,
)
quote.total_price = subtotal + (quote.tax_amount or Decimal("0")) + shipping
else:
quote.total_price = subtotal + shipping

Expand Down
69 changes: 49 additions & 20 deletions backend/app/services/sales_order_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from app.models.company_settings import CompanySettings
from app.models.order_event import OrderEvent
from app.services.customer_service import get_customer_discount_percent as _get_customer_discount_percent
from app.services.tax_calculation_service import calculate_sales_tax

logger = get_logger(__name__)

Expand Down Expand Up @@ -489,7 +490,11 @@ def validate_material_for_order(db: Session, material_inventory_id: int) -> Mate
return material


def get_company_tax_settings(db: Session) -> tuple[Optional[Decimal], bool, Optional[str]]:
def get_company_tax_settings(
db: Session,
*,
company_settings: Optional[CompanySettings] = None,
) -> tuple[Optional[Decimal], bool, Optional[str]]:
"""
Get company tax settings.

Expand All @@ -505,8 +510,13 @@ def get_company_tax_settings(db: Session) -> tuple[Optional[Decimal], bool, Opti
if default_tr:
return default_tr.rate, True, default_tr.name

company_settings = db.query(CompanySettings).filter(CompanySettings.id == 1).first()
if company_settings and company_settings.tax_enabled and company_settings.tax_rate:
if company_settings is None:
company_settings = db.query(CompanySettings).filter(CompanySettings.id == 1).first()
if (
company_settings
and company_settings.tax_enabled
and company_settings.tax_rate is not None
):
return Decimal(str(company_settings.tax_rate)), True, company_settings.tax_name
return None, False, None

Expand Down Expand Up @@ -754,11 +764,32 @@ def create_sales_order(
# Generate order number
order_number = generate_order_number(db)

# Resolve customer shipping state before tax calculation.
if customer:
if not shipping_address_line1 and customer.shipping_address_line1:
shipping_address_line1 = customer.shipping_address_line1
shipping_address_line2 = customer.shipping_address_line2
shipping_city = customer.shipping_city
shipping_state = shipping_state or customer.shipping_state
shipping_zip = customer.shipping_zip
shipping_country = customer.shipping_country or "USA"
elif shipping_state is None and customer.shipping_state:
shipping_state = customer.shipping_state

# Calculate tax
tax_rate, is_taxable, tax_name = get_company_tax_settings(db)
tax_amount = Decimal("0")
if tax_rate:
tax_amount = (total_price * tax_rate).quantize(Decimal("0.01"))
company_settings = db.query(CompanySettings).filter(CompanySettings.id == 1).first()
tax_rate, is_taxable, tax_name = get_company_tax_settings(
db,
company_settings=company_settings,
)
tax_result = calculate_sales_tax(
subtotal=total_price,
tax_rate=tax_rate,
shipping_cost=shipping_cost,
ship_to_state=shipping_state,
seller_state=company_settings.company_state if company_settings else None,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
tax_amount = tax_result.tax_amount
Comment thread
coderabbitai[bot] marked this conversation as resolved.

grand_total = total_price + shipping_cost + tax_amount

Expand All @@ -785,16 +816,6 @@ def create_sales_order(
# Use customer_id if provided, otherwise current user
user_id = customer_id if customer_id else created_by_user_id

# Auto-copy customer shipping address if not provided
if customer and not shipping_address_line1:
if customer.shipping_address_line1:
shipping_address_line1 = customer.shipping_address_line1
shipping_address_line2 = customer.shipping_address_line2
shipping_city = customer.shipping_city
shipping_state = customer.shipping_state
shipping_zip = customer.shipping_zip
shipping_country = customer.shipping_country or "USA"

# Create sales order
sales_order = SalesOrder(
user_id=user_id,
Expand Down Expand Up @@ -1352,13 +1373,21 @@ def _recalculate_order_totals(db: Session, order: SalesOrder) -> None:
order.total_price = line_total
order.quantity = int(line_qty)

shipping = order.shipping_cost or Decimal("0")
# Recalculate tax if order is taxable
if order.is_taxable and order.tax_rate:
order.tax_amount = (line_total * order.tax_rate).quantize(Decimal("0.01"))
if order.is_taxable and order.tax_rate is not None:
company_settings = db.query(CompanySettings).filter(CompanySettings.id == 1).first()
tax_result = calculate_sales_tax(
subtotal=line_total,
tax_rate=order.tax_rate,
shipping_cost=shipping,
ship_to_state=order.shipping_state,
seller_state=company_settings.company_state if company_settings else None,
)
order.tax_amount = tax_result.tax_amount
else:
order.tax_amount = order.tax_amount or Decimal("0")

shipping = order.shipping_cost or Decimal("0")
tax = order.tax_amount or Decimal("0")
order.grand_total = (line_total + tax + shipping).quantize(Decimal("0.01"))

Expand Down
Loading