Skip to content
Merged
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
72 changes: 9 additions & 63 deletions backend/src/api/routes/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,11 @@
_build_multi_copy_invoice_html,
_copy_label,
)
from src.services.gst_tax_service import _money, is_interstate_supply, assign_item_tax_split, TaxCalculator

router = APIRouter()


def _is_interstate_supply(company_gst: str | None, ledger_gst: str | None) -> bool:
"""Check if the invoice is for an interstate supply based on GST state codes."""
if not company_gst or not ledger_gst or len(company_gst) < 2 or len(ledger_gst) < 2:
return False
return company_gst[:2] != ledger_gst[:2]


def _money(value: Decimal) -> Decimal:
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)




def _generate_next_number(
Expand Down Expand Up @@ -98,40 +88,6 @@ def _change_inventory_quantity(
raise HTTPException(status_code=400, detail=f"Insufficient inventory while {context}")


def _assign_item_tax_split(
items: list[InvoiceItem],
*,
interstate_supply: bool,
) -> None:
if not items:
return

if interstate_supply:
for item in items:
item_tax_amount = _money(Decimal(str(item.tax_amount or 0)))
item_igst_amount = item_tax_amount
taxable_amount = _money(Decimal(str(item.taxable_amount or 0)))

item.tax_amount = float(item_igst_amount)
item.line_total = float(_money(taxable_amount + item_igst_amount))
item.cgst_amount = 0.0
item.sgst_amount = 0.0
item.igst_amount = float(item_igst_amount)
return

for item in items:
item_tax_amount = _money(Decimal(str(item.tax_amount or 0)))
item_half_tax_amount = _money(item_tax_amount / Decimal("2"))
item_cgst_amount = item_half_tax_amount
item_sgst_amount = item_half_tax_amount
item_total_tax_amount = _money(item_cgst_amount + item_sgst_amount)
taxable_amount = _money(Decimal(str(item.taxable_amount or 0)))

item.tax_amount = float(item_total_tax_amount)
item.line_total = float(_money(taxable_amount + item_total_tax_amount))
item.cgst_amount = float(item_cgst_amount)
item.sgst_amount = float(item_sgst_amount)
item.igst_amount = 0.0


def _reverse_existing_invoice_inventory(db: Session, invoice: Invoice) -> None:
Expand Down Expand Up @@ -262,7 +218,7 @@ def _apply_payload_to_invoice(
if not payload.items:
raise HTTPException(status_code=400, detail="Invoice must have at least one line item")

interstate_supply = _is_interstate_supply(invoice.company_gst, invoice.ledger_gst)
interstate_supply = is_interstate_supply(invoice.company_gst, invoice.ledger_gst)

taxable_total = Decimal("0")
tax_total = Decimal("0")
Expand Down Expand Up @@ -338,27 +294,17 @@ def _apply_payload_to_invoice(
taxable_total = _money(taxable_total)
invoice.taxable_amount = float(taxable_total)

_assign_item_tax_split(
assign_item_tax_split(
created_items,
interstate_supply=interstate_supply,
)

cgst_total = _money(sum((_money(Decimal(str(item.cgst_amount or 0))) for item in created_items), Decimal("0")))
sgst_total = _money(sum((_money(Decimal(str(item.sgst_amount or 0))) for item in created_items), Decimal("0")))
igst_total = _money(sum((_money(Decimal(str(item.igst_amount or 0))) for item in created_items), Decimal("0")))

if interstate_supply:
invoice.cgst_amount = 0.0
invoice.sgst_amount = 0.0
invoice.igst_amount = float(igst_total)
else:
invoice.cgst_amount = float(cgst_total)
invoice.sgst_amount = float(sgst_total)
invoice.igst_amount = 0.0

tax_total = _money(cgst_total + sgst_total + igst_total)
invoice.total_tax_amount = float(tax_total)
raw_total = _money(taxable_total + tax_total)
TaxCalculator.assign_invoice_tax_totals(
invoice,
created_items,
interstate_supply=interstate_supply,
)
raw_total = _money(taxable_total + Decimal(str(invoice.total_tax_amount)))
if invoice.apply_round_off:
rounded_total = raw_total.quantize(Decimal("1"), rounding=ROUND_HALF_UP)
round_off_amount = _money(rounded_total - raw_total)
Expand Down
153 changes: 153 additions & 0 deletions backend/src/services/gst_tax_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""
GST and tax calculation service module.

Handles extraction of GST state codes, tax splitting logic for CGST/SGST/IGST,
and invoice-level tax aggregation.
"""

from decimal import Decimal, ROUND_HALF_UP

from src.models.invoice import Invoice, InvoiceItem


def _money(value: Decimal) -> Decimal:
"""
Round a Decimal value to 2 decimal places using ROUND_HALF_UP.

Args:
value: The Decimal value to round for currency calculations.

Returns:
The value rounded to 2 decimal places.
"""
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def is_interstate_supply(company_gst: str | None, ledger_gst: str | None) -> bool:
"""
Check if the invoice is for an interstate supply based on GST state codes.

Compares the first 2 characters of the GST IN numbers (state codes) to determine
if the supply is between different states (interstate) or within the same state (intrastate).

Args:
company_gst: The GST IN of the company (seller). None or insufficient length returns False.
ledger_gst: The GST IN of the buyer/ledger (buyer). None or insufficient length returns False.

Returns:
True if the supply is interstate (different state codes), False otherwise.
"""
if not company_gst or not ledger_gst or len(company_gst) < 2 or len(ledger_gst) < 2:
return False
return company_gst[:2] != ledger_gst[:2]


def assign_item_tax_split(
items: list[InvoiceItem],
*,
interstate_supply: bool,
) -> None:
"""
Assign tax split (CGST, SGST, IGST) to invoice items based on supply type.

For interstate supplies, 100% of tax goes to IGST with CGST and SGST set to 0.
For intrastate supplies, tax is split equally between CGST and SGST with IGST set to 0.

Modifies the items in-place, updating:
- cgst_amount, sgst_amount, igst_amount
- tax_amount, line_total

Args:
items: List of InvoiceItem objects to assign tax splits to.
interstate_supply: True if this is an interstate supply, False for intrastate.
"""
if not items:
return

if interstate_supply:
for item in items:
item_tax_amount = _money(Decimal(str(item.tax_amount or 0)))
item_igst_amount = item_tax_amount
taxable_amount = _money(Decimal(str(item.taxable_amount or 0)))

item.tax_amount = float(item_igst_amount)
item.line_total = float(_money(taxable_amount + item_igst_amount))
item.cgst_amount = 0.0
item.sgst_amount = 0.0
item.igst_amount = float(item_igst_amount)
return

for item in items:
item_tax_amount = _money(Decimal(str(item.tax_amount or 0)))
item_half_tax_amount = _money(item_tax_amount / Decimal("2"))
item_cgst_amount = item_half_tax_amount
item_sgst_amount = item_half_tax_amount
item_total_tax_amount = _money(item_cgst_amount + item_sgst_amount)
taxable_amount = _money(Decimal(str(item.taxable_amount or 0)))

item.tax_amount = float(item_total_tax_amount)
item.line_total = float(_money(taxable_amount + item_total_tax_amount))
item.cgst_amount = float(item_cgst_amount)
item.sgst_amount = float(item_sgst_amount)
item.igst_amount = 0.0


class TaxCalculator:
"""
Encapsulates tax calculation operations for invoices.

Provides methods to calculate and assign tax totals at the invoice level based on
aggregated item-level taxes.
"""

@staticmethod
def calculate_tax_totals(items: list[InvoiceItem]) -> tuple[Decimal, Decimal, Decimal]:
"""
Calculate total CGST, SGST, and IGST from invoice items.

Args:
items: List of InvoiceItem objects with tax amounts assigned.

Returns:
A tuple of (cgst_total, sgst_total, igst_total) as Decimal values rounded to 2 places.
"""
cgst_total = _money(sum((_money(Decimal(str(item.cgst_amount or 0))) for item in items), Decimal("0")))
sgst_total = _money(sum((_money(Decimal(str(item.sgst_amount or 0))) for item in items), Decimal("0")))
igst_total = _money(sum((_money(Decimal(str(item.igst_amount or 0))) for item in items), Decimal("0")))
return cgst_total, sgst_total, igst_total

@staticmethod
def assign_invoice_tax_totals(
invoice: Invoice,
items: list[InvoiceItem],
*,
interstate_supply: bool,
) -> None:
"""
Assign tax totals to invoice based on item taxes and supply type.

For interstate supplies, sets cgst_amount and sgst_amount to 0, igst_amount to total.
For intrastate supplies, aggregates cgst and sgst totals, sets igst_amount to 0.

Modifies the invoice in-place, updating:
- cgst_amount, sgst_amount, igst_amount
- total_tax_amount

Args:
invoice: The Invoice object to assign tax totals to.
items: List of InvoiceItem objects with item-level taxes already calculated.
interstate_supply: True if this is an interstate supply, False for intrastate.
"""
cgst_total, sgst_total, igst_total = TaxCalculator.calculate_tax_totals(items)

if interstate_supply:
invoice.cgst_amount = 0.0
invoice.sgst_amount = 0.0
invoice.igst_amount = float(igst_total)
else:
invoice.cgst_amount = float(cgst_total)
invoice.sgst_amount = float(sgst_total)
invoice.igst_amount = 0.0

tax_total = _money(cgst_total + sgst_total + igst_total)
invoice.total_tax_amount = float(tax_total)
Loading