diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 55d55b51..18c42c6f 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -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__) @@ -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 @@ -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, + ) + 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" @@ -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, @@ -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 + ) + 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) @@ -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 diff --git a/backend/app/services/sales_order_service.py b/backend/app/services/sales_order_service.py index 163db3ae..92205c4d 100644 --- a/backend/app/services/sales_order_service.py +++ b/backend/app/services/sales_order_service.py @@ -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__) @@ -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. @@ -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 @@ -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, + ) + tax_amount = tax_result.tax_amount grand_total = total_price + shipping_cost + tax_amount @@ -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, @@ -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")) diff --git a/backend/app/services/tax_calculation_service.py b/backend/app/services/tax_calculation_service.py new file mode 100644 index 00000000..67a416af --- /dev/null +++ b/backend/app/services/tax_calculation_service.py @@ -0,0 +1,99 @@ +"""Shared sales tax calculation helpers. + +This module keeps Core's built-in tax logic conservative and provider-neutral. +External providers such as QuickBooks, Avalara, or TaxJar can later implement +the same inputs while preserving the stored quote/order/invoice tax snapshots. +""" +from dataclasses import dataclass +from decimal import Decimal +from enum import Enum +from typing import Optional + + +CENT = Decimal("0.01") + + +class ShippingChargeType(str, Enum): + """Supported shipping charge semantics for tax calculation.""" + + SELLER_BILLED_DELIVERY = "seller_billed_delivery" + USPS_POSTAGE = "usps_postage" + THIRD_PARTY_FREIGHT = "third_party_freight" + + +@dataclass(frozen=True) +class SalesTaxResult: + taxable_base: Decimal + tax_amount: Decimal + shipping_taxable: bool + + +_STATE_ALIASES = { + "INDIANA": "IN", +} + +_SHIPPING_TAXABLE_STATES = { + "IN", +} + + +def _as_decimal(value: Optional[Decimal]) -> Decimal: + if value is None: + return Decimal("0") + return Decimal(str(value)) + + +def _normalize_state(value: Optional[str]) -> Optional[str]: + if not value: + return None + normalized = value.strip().upper() + return _STATE_ALIASES.get(normalized, normalized) + + +def is_shipping_taxable( + *, + ship_to_state: Optional[str], + seller_state: Optional[str] = None, + shipping_charge_type: ShippingChargeType = ShippingChargeType.SELLER_BILLED_DELIVERY, +) -> bool: + """Return whether shipping should be included in the taxable base.""" + if shipping_charge_type != ShippingChargeType.SELLER_BILLED_DELIVERY: + return False + + state = _normalize_state(ship_to_state) or _normalize_state(seller_state) + return state in _SHIPPING_TAXABLE_STATES + + +def calculate_sales_tax( + *, + subtotal: Decimal, + tax_rate: Optional[Decimal], + shipping_cost: Optional[Decimal] = None, + ship_to_state: Optional[str] = None, + seller_state: Optional[str] = None, + shipping_charge_type: ShippingChargeType = ShippingChargeType.SELLER_BILLED_DELIVERY, +) -> SalesTaxResult: + """Calculate tax from line subtotal plus any jurisdiction-taxable shipping.""" + subtotal_amount = _as_decimal(subtotal) + shipping_amount = _as_decimal(shipping_cost) + rate = _as_decimal(tax_rate) + shipping_taxable = is_shipping_taxable( + ship_to_state=ship_to_state, + seller_state=seller_state, + shipping_charge_type=shipping_charge_type, + ) + + taxable_base = subtotal_amount + if shipping_taxable: + taxable_base += shipping_amount + + tax_amount = ( + (taxable_base * rate).quantize(CENT) + if tax_rate is not None + else Decimal("0") + ) + return SalesTaxResult( + taxable_base=taxable_base.quantize(CENT), + tax_amount=tax_amount, + shipping_taxable=shipping_taxable, + ) diff --git a/backend/tests/services/test_quote_service.py b/backend/tests/services/test_quote_service.py index 32fcfe07..f99005aa 100644 --- a/backend/tests/services/test_quote_service.py +++ b/backend/tests/services/test_quote_service.py @@ -28,6 +28,7 @@ from app.models.company_settings import CompanySettings from app.models.user import User from app.models.sales_order import SalesOrder +from app.models.tax_rate import TaxRate # ============================================================================= @@ -95,6 +96,7 @@ def _make_manual_quote_request(**overrides): customer_notes=None, admin_notes=None, lines=None, + shipping_state=None, ) defaults.update(overrides) return SimpleNamespace(**defaults) @@ -248,6 +250,40 @@ def test_applies_tax_when_enabled(self, db): assert quote.tax_amount == Decimal("8.25") assert quote.total_price == Decimal("108.25") + def test_indiana_company_state_taxes_shipping(self, db): + _make_company_settings( + db, + tax_enabled=True, + tax_rate=Decimal("0.07"), + company_state="IN", + ) + request = _make_manual_quote_request( + unit_price=Decimal("100.00"), + quantity=1, + apply_tax=True, + shipping_cost=Decimal("10.00"), + ) + + quote = quote_service.create_quote(db, request, user_id=1) + + assert quote.tax_amount == Decimal("7.70") + assert quote.total_price == Decimal("117.70") + + def test_persists_shipping_state_used_for_tax(self, db): + _make_company_settings(db, tax_enabled=True, tax_rate=Decimal("0.07")) + request = _make_manual_quote_request( + unit_price=Decimal("100.00"), + quantity=1, + apply_tax=True, + shipping_cost=Decimal("10.00"), + shipping_state="IN", + ) + + quote = quote_service.create_quote(db, request, user_id=1) + + assert quote.shipping_state == "IN" + assert quote.tax_amount == Decimal("7.70") + def test_no_tax_when_apply_tax_false(self, db): _make_company_settings(db, tax_enabled=True, tax_rate=Decimal("0.0825")) request = _make_manual_quote_request( @@ -323,6 +359,9 @@ def _make_update_request(self, **overrides): customer_email=None, customer_notes=None, admin_notes=None, + apply_tax=None, + tax_rate_id=None, + shipping_state=None, ) defaults.update(overrides) @@ -373,6 +412,71 @@ def test_recalculates_pricing_on_quantity_change(self, db): assert result.subtotal == Decimal("50.00") + def test_indiana_shipping_update_recalculates_taxable_base(self, db): + q = _make_quote( + db, + quote_number="Q-UPD-IN-SHIP-01", + unit_price=Decimal("100.00"), + subtotal=Decimal("100.00"), + tax_rate=Decimal("0.07"), + tax_amount=Decimal("7.00"), + total_price=Decimal("107.00"), + quantity=1, + shipping_state="IN", + ) + + request = self._make_update_request(shipping_cost=Decimal("10.00")) + result = quote_service.update_quote(db, q.id, request) + + assert result.tax_amount == Decimal("7.70") + assert result.total_price == Decimal("117.70") + + def test_shipping_state_update_recalculates_taxable_base(self, db): + q = _make_quote( + db, + quote_number="Q-UPD-SHIP-STATE-01", + unit_price=Decimal("100.00"), + subtotal=Decimal("100.00"), + tax_rate=Decimal("0.07"), + tax_amount=Decimal("7.00"), + shipping_cost=Decimal("10.00"), + total_price=Decimal("117.00"), + quantity=1, + shipping_state="OH", + ) + + request = self._make_update_request(shipping_state="IN") + result = quote_service.update_quote(db, q.id, request) + + assert result.shipping_state == "IN" + assert result.tax_amount == Decimal("7.70") + assert result.total_price == Decimal("117.70") + + def test_apply_tax_true_uses_default_tax_rate_on_single_item_update(self, db): + db.add(TaxRate( + name="Default Local", + rate=Decimal("0.0500"), + is_default=True, + is_active=True, + )) + db.flush() + q = _make_quote( + db, + quote_number="Q-UPD-DEFAULT-TAX-01", + unit_price=Decimal("100.00"), + subtotal=Decimal("100.00"), + total_price=Decimal("100.00"), + quantity=1, + ) + + request = self._make_update_request(apply_tax=True) + result = quote_service.update_quote(db, q.id, request) + + assert result.tax_rate == Decimal("0.0500") + assert result.tax_name == "Default Local" + assert result.tax_amount == Decimal("5.00") + assert result.total_price == Decimal("105.00") + # ============================================================================= # update_quote_status diff --git a/backend/tests/services/test_sales_order_service.py b/backend/tests/services/test_sales_order_service.py index 67cc3020..bef29319 100644 --- a/backend/tests/services/test_sales_order_service.py +++ b/backend/tests/services/test_sales_order_service.py @@ -67,17 +67,19 @@ def _make_order_line(db, sales_order_id, product_id, quantity=1, unit_price=Deci return line -def _set_company_tax(db, *, tax_enabled=True, tax_rate=Decimal("0.0825")): +def _set_company_tax(db, *, tax_enabled=True, tax_rate=Decimal("0.0825"), company_state=None): """Insert or update company settings row with tax config.""" settings = db.query(CompanySettings).filter(CompanySettings.id == 1).first() if settings: settings.tax_enabled = tax_enabled settings.tax_rate = tax_rate + settings.company_state = company_state else: settings = CompanySettings( id=1, tax_enabled=tax_enabled, tax_rate=tax_rate, + company_state=company_state, ) db.add(settings) db.flush() @@ -507,6 +509,64 @@ def test_creates_order_with_shipping_cost(self, db, make_product): assert order.shipping_cost == Decimal("9.99") assert order.grand_total == Decimal("59.99") + def test_indiana_shipping_is_in_taxable_base(self, db, make_product): + """Indiana seller-billed shipping should be taxed with the order subtotal.""" + product = make_product(selling_price=Decimal("100.00")) + _set_company_tax(db, tax_enabled=True, tax_rate=Decimal("0.07")) + + order = sales_order_service.create_sales_order( + db, + customer_id=None, + lines=[{"product_id": product.id, "quantity": 1}], + shipping_state="IN", + shipping_cost=Decimal("10.00"), + created_by_user_id=1, + ) + + assert order.total_price == Decimal("100.00") + assert order.shipping_cost == Decimal("10.00") + assert order.tax_amount == Decimal("7.70") + assert order.grand_total == Decimal("117.70") + + def test_indiana_company_state_taxes_shipping_without_destination(self, db, make_product): + """Manual local orders fall back to the company state for shipping tax.""" + product = make_product(selling_price=Decimal("100.00")) + _set_company_tax( + db, + tax_enabled=True, + tax_rate=Decimal("0.07"), + company_state="IN", + ) + + order = sales_order_service.create_sales_order( + db, + customer_id=None, + lines=[{"product_id": product.id, "quantity": 1}], + shipping_cost=Decimal("10.00"), + created_by_user_id=1, + ) + + assert order.tax_amount == Decimal("7.70") + assert order.grand_total == Decimal("117.70") + + def test_customer_shipping_state_taxes_shipping_before_address_copy(self, db, make_product): + """Customer destination state should be known before calculating tax.""" + customer = _make_user(db, status="active", shipping_state="IN") + product = make_product(selling_price=Decimal("100.00")) + _set_company_tax(db, tax_enabled=True, tax_rate=Decimal("0.07")) + + order = sales_order_service.create_sales_order( + db, + customer_id=customer.id, + lines=[{"product_id": product.id, "quantity": 1}], + shipping_cost=Decimal("10.00"), + created_by_user_id=1, + ) + + assert order.shipping_state == "IN" + assert order.tax_amount == Decimal("7.70") + assert order.grand_total == Decimal("117.70") + def test_creates_order_with_customer(self, db, make_product): """Order linked to a customer uses customer's user_id.""" customer = _make_user(db, status="active") @@ -978,6 +1038,48 @@ def test_partial_update_preserves_other_fields(self, db, make_sales_order): assert result.shipping_city == "New City" +# ============================================================================= +# edit_sales_order_lines +# ============================================================================= + +class TestEditSalesOrderLines: + def test_indiana_shipping_stays_taxable_after_line_edit(self, db, make_sales_order, make_product): + product = make_product(selling_price=Decimal("100.00")) + order = make_sales_order( + status="pending", + order_type="line_item", + quantity=2, + unit_price=Decimal("100.00"), + tax_rate=Decimal("0.07"), + tax_amount=Decimal("14.70"), + is_taxable=True, + shipping_cost=Decimal("10.00"), + shipping_state="IN", + ) + line = _make_order_line( + db, + order.id, + product.id, + quantity=2, + unit_price=Decimal("100.00"), + ) + + result = sales_order_service.edit_sales_order_lines( + db, + order.id, + [{ + "line_id": line.id, + "new_quantity": 1, + "reason": "Customer reduced quantity", + }], + user_id=1, + ) + + assert result.total_price == Decimal("100.00") + assert result.tax_amount == Decimal("7.70") + assert result.grand_total == Decimal("117.70") + + # ============================================================================= # cancel_sales_order # ============================================================================= @@ -2449,7 +2551,7 @@ def test_copy_routing_creates_production_operations( {"component_id": raw.id, "quantity": Decimal("100"), "unit": "G"}, ]) - routing = self._make_routing(db, fg.id, operations=[ + self._make_routing(db, fg.id, operations=[ {"sequence": 10, "operation_code": "PRINT", "operation_name": "3D Print", "setup_time_minutes": 5, "run_time_minutes": 60}, {"sequence": 20, "operation_code": "QC", "operation_name": "Quality Check", @@ -2477,8 +2579,6 @@ def test_copy_routing_creates_production_operations( def test_copy_routing_direct_call(self, db, make_product, make_bom): """Direct call to copy_routing_to_operations works correctly.""" - from app.models.production_order import ProductionOrderOperation - fg = make_product(selling_price=Decimal("50.00"), has_bom=True) routing = self._make_routing(db, fg.id, operations=[ {"sequence": 10, "operation_code": "PRINT", "operation_name": "3D Print", @@ -2520,7 +2620,7 @@ def test_create_production_orders_with_routing_for_line_items( make_bom(fg.id, lines=[ {"component_id": raw.id, "quantity": Decimal("100"), "unit": "G"}, ]) - routing = self._make_routing(db, fg.id, operations=[ + self._make_routing(db, fg.id, operations=[ {"sequence": 10, "operation_code": "PRINT", "run_time_minutes": 30}, ]) @@ -2553,7 +2653,7 @@ def test_create_production_orders_with_routing_for_quote_based( make_bom(fg.id, lines=[ {"component_id": raw.id, "quantity": Decimal("50"), "unit": "G"}, ]) - routing = self._make_routing(db, fg.id, operations=[ + self._make_routing(db, fg.id, operations=[ {"sequence": 10, "operation_code": "PRINT", "run_time_minutes": 20}, ]) diff --git a/backend/tests/services/test_tax_calculation_service.py b/backend/tests/services/test_tax_calculation_service.py new file mode 100644 index 00000000..add42458 --- /dev/null +++ b/backend/tests/services/test_tax_calculation_service.py @@ -0,0 +1,50 @@ +"""Tests for state-aware sales tax calculation helpers.""" +from decimal import Decimal + +from app.services.tax_calculation_service import ( + ShippingChargeType, + calculate_sales_tax, +) + + +def test_indiana_seller_billed_shipping_is_taxable(): + """Indiana taxes seller-billed delivery charges for taxable goods.""" + result = calculate_sales_tax( + subtotal=Decimal("100.00"), + tax_rate=Decimal("0.07"), + shipping_cost=Decimal("10.00"), + ship_to_state="IN", + ) + + assert result.taxable_base == Decimal("110.00") + assert result.tax_amount == Decimal("7.70") + assert result.shipping_taxable is True + + +def test_non_indiana_shipping_keeps_existing_subtotal_tax_behavior(): + """States without a shipping rule keep shipping outside the taxable base.""" + result = calculate_sales_tax( + subtotal=Decimal("100.00"), + tax_rate=Decimal("0.07"), + shipping_cost=Decimal("10.00"), + ship_to_state="OH", + ) + + assert result.taxable_base == Decimal("100.00") + assert result.tax_amount == Decimal("7.00") + assert result.shipping_taxable is False + + +def test_indiana_separately_stated_usps_postage_is_not_taxable(): + """Indiana separately stated actual USPS postage is not taxable.""" + result = calculate_sales_tax( + subtotal=Decimal("100.00"), + tax_rate=Decimal("0.07"), + shipping_cost=Decimal("10.00"), + ship_to_state="IN", + shipping_charge_type=ShippingChargeType.USPS_POSTAGE, + ) + + assert result.taxable_base == Decimal("100.00") + assert result.tax_amount == Decimal("7.00") + assert result.shipping_taxable is False