diff --git a/alembic/versions/20260822_sales_income_foundation_phase14.py b/alembic/versions/20260822_sales_income_foundation_phase14.py new file mode 100644 index 0000000..1a916bb --- /dev/null +++ b/alembic/versions/20260822_sales_income_foundation_phase14.py @@ -0,0 +1,77 @@ +"""Phase 14 Sales & Income foundation. + +Revision ID: 20260822_sales_income_foundation_p14 +Revises: 20260822_internal_accounting_model_p13 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "20260822_sales_income_foundation_p14" +down_revision = "20260822_internal_accounting_model_p13" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "accounting_sales_income_transactions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("source_type", sa.String(30), nullable=False, server_default="outward_import"), + sa.Column("source_file_name", sa.String(255), nullable=False, server_default=""), + sa.Column("fingerprint", sa.String(80), nullable=False), + sa.Column("return_period", sa.String(20), nullable=False, server_default=""), + sa.Column("document_type", sa.String(40), nullable=False, server_default="Invoice"), + sa.Column("invoice_number", sa.String(120), nullable=False, server_default=""), + sa.Column("invoice_date", sa.String(20), nullable=False, server_default=""), + sa.Column("customer_name", sa.String(255), nullable=False, server_default=""), + sa.Column("customer_gstin", sa.String(20), nullable=False, server_default=""), + sa.Column("place_of_supply", sa.String(120), nullable=False, server_default=""), + sa.Column("supply_type", sa.String(60), nullable=False, server_default=""), + sa.Column("export_type", sa.String(60), nullable=False, server_default=""), + sa.Column("hsn_code", sa.String(20), nullable=False, server_default=""), + sa.Column("description_text", sa.Text(), nullable=False, server_default=""), + sa.Column("taxable_value", sa.Float(), nullable=False, server_default="0"), + sa.Column("cgst", sa.Float(), nullable=False, server_default="0"), + sa.Column("sgst", sa.Float(), nullable=False, server_default="0"), + sa.Column("igst", sa.Float(), nullable=False, server_default="0"), + sa.Column("cess", sa.Float(), nullable=False, server_default="0"), + sa.Column("invoice_value", sa.Float(), nullable=False, server_default="0"), + sa.Column("suggested_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True), + sa.Column("suggested_ledger_name", sa.String(255), nullable=False, server_default=""), + sa.Column("suggested_confidence", sa.Integer(), nullable=False, server_default="0"), + sa.Column("suggestion_reason", sa.Text(), nullable=True), + sa.Column("review_status", sa.String(30), nullable=False, server_default="pending"), + sa.Column("final_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True), + sa.Column("final_ledger_name", sa.String(255), nullable=False, server_default=""), + sa.Column("review_note", sa.Text(), nullable=True), + sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint( + "tenant_id", + "client_id", + "fingerprint", + name="uq_accounting_sales_income_fingerprint", + ), + ) + + for name in ( + "tenant_id", "client_id", "source_type", "fingerprint", "return_period", + "document_type", "invoice_number", "invoice_date", "customer_name", + "customer_gstin", "hsn_code", "suggested_nature_id", "review_status", + "final_nature_id", "created_at_utc", + ): + op.create_index( + f"ix_accounting_sales_income_transactions_{name}", + "accounting_sales_income_transactions", + [name], + ) + + +def downgrade(): + op.drop_table("accounting_sales_income_transactions") diff --git a/app/modules/accounting/sales_models.py b/app/modules/accounting/sales_models.py new file mode 100644 index 0000000..d8b7c5f --- /dev/null +++ b/app/modules/accounting/sales_models.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class AccountingSalesIncomeTransaction(CommonBase): + __tablename__ = "accounting_sales_income_transactions" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "client_id", + "fingerprint", + name="uq_accounting_sales_income_fingerprint", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column( + ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True + ) + client_id: Mapped[int] = mapped_column( + ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True + ) + + source_type: Mapped[str] = mapped_column(String(30), nullable=False, default="outward_import", index=True) + source_file_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + fingerprint: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + + return_period: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True) + document_type: Mapped[str] = mapped_column(String(40), nullable=False, default="Invoice", index=True) + invoice_number: Mapped[str] = mapped_column(String(120), nullable=False, default="", index=True) + invoice_date: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True) + + customer_name: Mapped[str] = mapped_column(String(255), nullable=False, default="", index=True) + customer_gstin: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True) + place_of_supply: Mapped[str] = mapped_column(String(120), nullable=False, default="") + supply_type: Mapped[str] = mapped_column(String(60), nullable=False, default="") + export_type: Mapped[str] = mapped_column(String(60), nullable=False, default="") + + hsn_code: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True) + description_text: Mapped[str] = mapped_column(Text, nullable=False, default="") + + taxable_value: Mapped[float] = mapped_column(Float, nullable=False, default=0) + cgst: Mapped[float] = mapped_column(Float, nullable=False, default=0) + sgst: Mapped[float] = mapped_column(Float, nullable=False, default=0) + igst: Mapped[float] = mapped_column(Float, nullable=False, default=0) + cess: Mapped[float] = mapped_column(Float, nullable=False, default=0) + invoice_value: Mapped[float] = mapped_column(Float, nullable=False, default=0) + + suggested_nature_id: Mapped[int | None] = mapped_column( + ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True + ) + suggested_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + suggested_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + suggestion_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + + review_status: Mapped[str] = mapped_column(String(30), nullable=False, default="pending", index=True) + final_nature_id: Mapped[int | None] = mapped_column( + ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True, index=True + ) + final_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + review_note: Mapped[str | None] = mapped_column(Text, nullable=True) + reviewed_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + created_by_user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), index=True + ) + updated_at_utc: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/app/modules/accounting/sales_service.py b/app/modules/accounting/sales_service.py new file mode 100644 index 0000000..a5ff338 --- /dev/null +++ b/app/modules/accounting/sales_service.py @@ -0,0 +1,424 @@ +from __future__ import annotations + +import hashlib +import io +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pandas as pd +from sqlalchemy import func, select + +from app.modules.accounting.sales_models import AccountingSalesIncomeTransaction +from app.modules.accounting.sales_taxonomy import ensure_sales_income_taxonomy, sales_income_natures +from app.modules.accounting.taxonomy_models import AccountingNature + + +COLUMN_ALIASES = { + "invoice_number": [ + "invoice number", "invoice no", "invoice no.", "inv no", "document number", + "document no", "voucher number", "bill no", "bill number", + ], + "invoice_date": ["invoice date", "document date", "date", "voucher date", "bill date"], + "customer_name": [ + "customer name", "recipient name", "party name", "buyer name", "customer", + "recipient", "party", + ], + "customer_gstin": [ + "customer gstin", "recipient gstin", "gstin", "gstin/uin", "gstin uin", + "buyer gstin", + ], + "document_type": ["document type", "doc type", "invoice type", "voucher type"], + "return_period": ["return period", "tax period", "period", "month"], + "place_of_supply": ["place of supply", "pos", "state"], + "supply_type": ["supply type", "nature of supply", "transaction type"], + "export_type": ["export type", "exp type"], + "hsn_code": ["hsn", "hsn code", "hsn/sac", "sac", "sac code"], + "description_text": [ + "description", "item description", "product description", "particulars", + "narration", "item name", "service description", + ], + "taxable_value": ["taxable value", "taxable amount", "assessable value", "basic amount"], + "cgst": ["cgst", "cgst amount"], + "sgst": ["sgst", "sgst amount", "utgst", "utgst amount"], + "igst": ["igst", "igst amount"], + "cess": ["cess", "cess amount"], + "invoice_value": [ + "invoice value", "invoice amount", "total invoice value", "total amount", + "gross amount", "bill amount", "voucher amount", + ], +} + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _key(value: Any) -> str: + return re.sub(r"[^a-z0-9]+", " ", str(value or "").strip().lower()).strip() + + +def _s(value: Any) -> str: + if value is None: + return "" + try: + if pd.isna(value): + return "" + except Exception: + pass + return str(value).strip() + + +def _f(value: Any) -> float: + if value is None: + return 0.0 + try: + if pd.isna(value): + return 0.0 + except Exception: + pass + text = str(value).replace(",", "").replace("₹", "").strip() + if text.startswith("(") and text.endswith(")"): + text = "-" + text[1:-1] + try: + return round(float(text or 0), 2) + except Exception: + return 0.0 + + +def _date(value: Any) -> str: + dt = pd.to_datetime(value, errors="coerce", dayfirst=True) + if pd.isna(dt): + return _s(value)[:20] + return dt.strftime("%Y-%m-%d") + + +def _columns(frame: pd.DataFrame) -> dict[str, str]: + keyed = {_key(column): column for column in frame.columns} + result = {} + for target, aliases in COLUMN_ALIASES.items(): + for alias in aliases: + if _key(alias) in keyed: + result[target] = keyed[_key(alias)] + break + return result + + +def _read_upload(filename: str, payload: bytes) -> pd.DataFrame: + suffix = Path(filename or "").suffix.lower() + if suffix == ".csv": + return pd.read_csv(io.BytesIO(payload)) + if suffix in {".xlsx", ".xlsm", ".xls"}: + book = pd.ExcelFile(io.BytesIO(payload)) + candidates = [] + for sheet in book.sheet_names: + frame = pd.read_excel(book, sheet_name=sheet) + mapping = _columns(frame) + score = len(mapping) + if "invoice_number" in mapping: + score += 3 + if "customer_name" in mapping or "customer_gstin" in mapping: + score += 2 + if "taxable_value" in mapping or "invoice_value" in mapping: + score += 2 + candidates.append((score, sheet, frame)) + candidates.sort(key=lambda item: (-item[0], item[1])) + if not candidates or candidates[0][0] < 3: + raise ValueError( + "No usable outward-sales sheet was found. Expected invoice/customer/value columns." + ) + return candidates[0][2] + raise ValueError("Sales & Income import supports CSV, XLSX, XLSM and XLS files.") + + +def _nature_map(db, tenant_id: int): + ensure_sales_income_taxonomy(db, tenant_id) + rows = sales_income_natures(db, tenant_id) + return {row.code: row for row in rows} + + +def _classify(data: dict[str, Any], nature_by_code: dict[str, AccountingNature]): + haystack = " ".join([ + _s(data.get("document_type")), + _s(data.get("supply_type")), + _s(data.get("export_type")), + _s(data.get("description_text")), + _s(data.get("customer_name")), + ]).upper() + hsn = re.sub(r"\D", "", _s(data.get("hsn_code"))) + + def result(code, confidence, reason): + nature = nature_by_code.get(code) or nature_by_code["SALES_REVIEW_REQUIRED"] + return nature, confidence, reason + + if any(token in haystack for token in ("EXPORT", "SEZ", "WITH PAYMENT", "WITHOUT PAYMENT")): + return result("EXPORT_SALES", 92, "Export/SEZ wording in outward transaction.") + if any(token in haystack for token in ("EXEMPT", "NIL RATED", "NIL-RATED", "NON GST", "NON-GST")): + return result("EXEMPT_NIL_SALES", 92, "Exempt/nil/non-GST supply wording.") + if any(token in haystack for token in ("SCRAP", "WASTE", "OLD NEWSPAPER", "METAL WASTE")): + return result("SCRAP_SALES", 90, "Scrap/waste sale wording.") + if any(token in haystack for token in ("COMMISSION", "BROKERAGE")): + return result("COMMISSION_INCOME", 88, "Commission/brokerage income wording.") + if any(token in haystack for token in ("RENTAL", "RENT ", "HIRE CHARGES", "LEASE INCOME")): + return result("RENTAL_INCOME", 86, "Rental/hire/lease income wording.") + if any(token in haystack for token in ("INTEREST", "FD INTEREST", "LOAN INTEREST")): + return result("INTEREST_INCOME", 90, "Interest income wording.") + if any(token in haystack for token in ("JOB WORK", "JOBWORK", "PROCESSING CHARGES", "CONVERSION CHARGES")): + return result("JOB_WORK_INCOME", 88, "Job-work/processing income wording.") + if any(token in haystack for token in ( + "PROFESSIONAL", "CONSULTANCY", "SERVICE CHARGES", "SERVICE FEE", + "AUDIT FEE", "ACCOUNTING FEE", "MANAGEMENT FEE", "LABOUR CHARGES", + )): + return result("SERVICE_INCOME", 86, "Service/professional income wording.") + if any(token in haystack for token in ( + "SALE OF VEHICLE", "SALE OF MACHINERY", "SALE OF ASSET", + "OLD VEHICLE SALE", "OLD MACHINERY SALE", + )): + return result("ASSET_SALE", 90, "Capital-asset disposal wording.") + + # SAC/service HSN chapters commonly begin 99. This is only a first-pass signal. + if hsn.startswith("99"): + return result("SERVICE_INCOME", 78, "SAC/HSN 99xx indicates service supply.") + + # Generic outward invoice with goods HSN is treated conservatively as trading sales. + if hsn and not hsn.startswith("99"): + return result( + "TRADING_SALES", + 68, + "Goods HSN present; trading/manufacturing distinction requires business context or review.", + ) + + if _s(data.get("description_text")): + return result( + "OTHER_OPERATING_INCOME", + 55, + "Outward transaction has description but insufficient evidence for a narrower nature.", + ) + return result("SALES_REVIEW_REQUIRED", 30, "Insufficient evidence for reliable income classification.") + + +def _fingerprint(client_id: int, data: dict[str, Any]) -> str: + raw = "|".join([ + str(client_id), + _s(data.get("invoice_number")).upper(), + _s(data.get("invoice_date")), + _s(data.get("customer_gstin")).upper(), + _s(data.get("customer_name")).upper(), + f"{_f(data.get('taxable_value')):.2f}", + f"{_f(data.get('invoice_value')):.2f}", + _s(data.get("document_type")).upper(), + ]) + return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest() + + +def import_sales_file( + db, + *, + tenant_id: int, + client_id: int, + filename: str, + payload: bytes, + user_id: int, + source_type: str = "outward_import", +): + frame = _read_upload(filename, payload) + mapping = _columns(frame) + + required = {"invoice_number"} + if not required.issubset(mapping): + raise ValueError("Could not identify an Invoice Number column in the uploaded file.") + if not ({"customer_name", "customer_gstin"} & set(mapping)): + raise ValueError("Could not identify Customer Name or Customer GSTIN.") + if not ({"taxable_value", "invoice_value"} & set(mapping)): + raise ValueError("Could not identify Taxable Value or Invoice Value.") + + nature_by_code = _nature_map(db, tenant_id) + inserted = 0 + duplicates = 0 + skipped_blank = 0 + + for _, row in frame.iterrows(): + data = { + target: row[column] + for target, column in mapping.items() + } + data = { + "invoice_number": _s(data.get("invoice_number")), + "invoice_date": _date(data.get("invoice_date")), + "customer_name": _s(data.get("customer_name")), + "customer_gstin": re.sub(r"\s+", "", _s(data.get("customer_gstin")).upper()), + "document_type": _s(data.get("document_type")) or "Invoice", + "return_period": _s(data.get("return_period")), + "place_of_supply": _s(data.get("place_of_supply")), + "supply_type": _s(data.get("supply_type")), + "export_type": _s(data.get("export_type")), + "hsn_code": re.sub(r"\.0$", "", _s(data.get("hsn_code"))), + "description_text": _s(data.get("description_text")), + "taxable_value": _f(data.get("taxable_value")), + "cgst": _f(data.get("cgst")), + "sgst": _f(data.get("sgst")), + "igst": _f(data.get("igst")), + "cess": _f(data.get("cess")), + "invoice_value": _f(data.get("invoice_value")), + } + + if not data["invoice_number"] and not data["customer_name"] and not data["customer_gstin"]: + skipped_blank += 1 + continue + if data["invoice_value"] == 0: + data["invoice_value"] = round( + data["taxable_value"] + data["cgst"] + data["sgst"] + data["igst"] + data["cess"], + 2, + ) + + fp = _fingerprint(client_id, data) + exists = db.execute( + select(AccountingSalesIncomeTransaction.id).where( + AccountingSalesIncomeTransaction.tenant_id == int(tenant_id), + AccountingSalesIncomeTransaction.client_id == int(client_id), + AccountingSalesIncomeTransaction.fingerprint == fp, + ) + ).scalar_one_or_none() + if exists: + duplicates += 1 + continue + + nature, confidence, reason = _classify(data, nature_by_code) + record = AccountingSalesIncomeTransaction( + tenant_id=int(tenant_id), + client_id=int(client_id), + source_type=source_type, + source_file_name=Path(filename or "").name[:255], + fingerprint=fp, + return_period=data["return_period"], + document_type=data["document_type"], + invoice_number=data["invoice_number"], + invoice_date=data["invoice_date"], + customer_name=data["customer_name"], + customer_gstin=data["customer_gstin"], + place_of_supply=data["place_of_supply"], + supply_type=data["supply_type"], + export_type=data["export_type"], + hsn_code=data["hsn_code"], + description_text=data["description_text"], + taxable_value=data["taxable_value"], + cgst=data["cgst"], + sgst=data["sgst"], + igst=data["igst"], + cess=data["cess"], + invoice_value=data["invoice_value"], + suggested_nature_id=nature.id, + suggested_confidence=int(confidence), + suggestion_reason=reason, + review_status="suggested" if confidence >= 60 else "review_required", + created_by_user_id=user_id, + ) + db.add(record) + inserted += 1 + + db.commit() + return { + "inserted": inserted, + "duplicates": duplicates, + "skipped_blank": skipped_blank, + "source_rows": len(frame), + } + + +def list_sales( + db, + *, + tenant_id: int, + client_id: int, + status: str = "", + customer: str = "", + period: str = "", + page: int = 1, + per_page: int = 25, +): + where = [ + AccountingSalesIncomeTransaction.tenant_id == int(tenant_id), + AccountingSalesIncomeTransaction.client_id == int(client_id), + ] + if status and status != "all": + where.append(AccountingSalesIncomeTransaction.review_status == status) + if customer.strip(): + term = f"%{customer.strip()}%" + where.append( + ( + AccountingSalesIncomeTransaction.customer_name.ilike(term) + | AccountingSalesIncomeTransaction.customer_gstin.ilike(term) + ) + ) + if period.strip(): + where.append(AccountingSalesIncomeTransaction.return_period == period.strip()) + + total = int( + db.scalar( + select(func.count(AccountingSalesIncomeTransaction.id)).where(*where) + ) + or 0 + ) + per_page = max(10, min(100, int(per_page or 25))) + pages = max(1, (total + per_page - 1) // per_page) + page = max(1, min(int(page or 1), pages)) + + rows = list( + db.execute( + select(AccountingSalesIncomeTransaction) + .where(*where) + .order_by( + AccountingSalesIncomeTransaction.invoice_date.desc(), + AccountingSalesIncomeTransaction.id.desc(), + ) + .offset((page - 1) * per_page) + .limit(per_page) + ).scalars().all() + ) + return rows, total, page, pages + + +def review_sales( + db, + *, + tenant_id: int, + client_id: int, + row_id: int, + final_nature_id: int, + final_ledger_name: str, + review_note: str, + user_id: int, +): + row = db.execute( + select(AccountingSalesIncomeTransaction).where( + AccountingSalesIncomeTransaction.id == int(row_id), + AccountingSalesIncomeTransaction.tenant_id == int(tenant_id), + AccountingSalesIncomeTransaction.client_id == int(client_id), + ) + ).scalar_one_or_none() + if not row: + raise ValueError("Sales / income transaction was not found.") + + nature = db.execute( + select(AccountingNature).where( + AccountingNature.id == int(final_nature_id), + AccountingNature.tenant_id == int(tenant_id), + AccountingNature.classification_group == "income", + AccountingNature.is_posting_nature.is_(True), + AccountingNature.is_active.is_(True), + ) + ).scalar_one_or_none() + if not nature: + raise ValueError("Select an active Sales & Income accounting nature.") + + row.final_nature_id = nature.id + row.final_ledger_name = (final_ledger_name or "").strip() + row.review_note = (review_note or "").strip() or None + row.review_status = "reviewed" + row.reviewed_by_user_id = user_id + row.reviewed_at_utc = _utcnow() + db.add(row) + db.commit() + db.refresh(row) + return row diff --git a/app/modules/accounting/sales_taxonomy.py b/app/modules/accounting/sales_taxonomy.py new file mode 100644 index 0000000..a718a00 --- /dev/null +++ b/app/modules/accounting/sales_taxonomy.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from sqlalchemy import select + +from app.modules.accounting.taxonomy_models import AccountingNature + + +SALES_INCOME_NATURES = [ + # code, name, parent, group, cap/revenue, posting, sort, description + ("SALES_INCOME", "Sales & Income", None, "income", "revenue", False, 1100, "Sales and income classification root."), + ("TRADING_SALES", "Trading / Stock Sales", "SALES_INCOME", "income", "revenue", True, 1110, "Sale of goods principally held for resale."), + ("MANUFACTURED_GOODS_SALES", "Manufactured Goods Sales", "SALES_INCOME", "income", "revenue", True, 1120, "Sale of goods manufactured or produced by the entity."), + ("SERVICE_INCOME", "Service / Professional Income", "SALES_INCOME", "income", "revenue", True, 1130, "Revenue from services, professional work or contractual services."), + ("EXPORT_SALES", "Export Sales / Services", "SALES_INCOME", "income", "revenue", True, 1140, "Export of goods or services."), + ("EXEMPT_NIL_SALES", "Exempt / Nil-Rated Sales", "SALES_INCOME", "income", "revenue", True, 1150, "Sales or supplies treated as exempt or nil-rated."), + ("SCRAP_SALES", "Scrap / Waste Sales", "SALES_INCOME", "income", "revenue", True, 1160, "Sale of scrap, waste or residual materials."), + ("JOB_WORK_INCOME", "Job Work / Processing Income", "SALES_INCOME", "income", "revenue", True, 1170, "Income from job work, processing or conversion activity."), + ("COMMISSION_INCOME", "Commission / Brokerage Income", "SALES_INCOME", "income", "revenue", True, 1180, "Commission, brokerage or intermediary income."), + ("RENTAL_INCOME", "Rental / Hire Income", "SALES_INCOME", "income", "revenue", True, 1190, "Business rental, lease or hire income."), + ("INTEREST_INCOME", "Interest Income", "SALES_INCOME", "income", "revenue", True, 1200, "Interest earned and accounted as income."), + ("OTHER_OPERATING_INCOME", "Other Operating Income", "SALES_INCOME", "income", "revenue", True, 1210, "Other income arising from normal operations."), + ("OTHER_INCOME", "Other Income", "SALES_INCOME", "income", "revenue", True, 1220, "Non-operating income not represented by a more specific nature."), + ("ASSET_SALE", "Sale of Capital Asset", "SALES_INCOME", "income", "capital", True, 1230, "Disposal proceeds relating to capital assets; reviewer must determine final accounting treatment."), + ("SALES_REVIEW_REQUIRED", "Sales / Income Review Required", "SALES_INCOME", "income", "review", True, 1290, "Fallback for outward transactions with insufficient evidence."), +] + + +def ensure_sales_income_taxonomy(db, tenant_id: int, actor_user_id: int | None = None) -> int: + existing = set( + db.execute( + select(AccountingNature.code).where(AccountingNature.tenant_id == int(tenant_id)) + ).scalars().all() + ) + inserted = 0 + for code, name, parent_code, group, cap_rev, posting, sort_order, description in SALES_INCOME_NATURES: + if code in existing: + continue + db.add( + AccountingNature( + tenant_id=int(tenant_id), + code=code, + name=name, + parent_code=parent_code, + classification_group=group, + capital_revenue=cap_rev, + description=description, + sort_order=sort_order, + is_posting_nature=bool(posting), + is_active=True, + is_system_defined=True, + created_by_user_id=actor_user_id, + updated_by_user_id=actor_user_id, + ) + ) + inserted += 1 + if inserted: + db.commit() + return inserted + + +def sales_income_natures(db, tenant_id: int): + ensure_sales_income_taxonomy(db, tenant_id) + return list( + db.execute( + select(AccountingNature).where( + AccountingNature.tenant_id == int(tenant_id), + AccountingNature.classification_group == "income", + AccountingNature.is_posting_nature.is_(True), + AccountingNature.is_active.is_(True), + ).order_by(AccountingNature.sort_order, AccountingNature.name) + ).scalars().all() + ) diff --git a/app/modules/accounting/sales_ui.py b/app/modules/accounting/sales_ui.py new file mode 100644 index 0000000..1ebda00 --- /dev/null +++ b/app/modules/accounting/sales_ui.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from urllib.parse import urlencode + +from fastapi import APIRouter, File, Form, Request, UploadFile +from fastapi.responses import RedirectResponse + +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token, validate_csrf +from app.core.templating import templates +from app.modules.accounting.sales_service import import_sales_file, list_sales, review_sales +from app.modules.accounting.sales_taxonomy import sales_income_natures +from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles + +router = APIRouter( + prefix="/tools/accounting/sales-income", + tags=["accounting-sales-income-ui"], +) + + +def _go(client_id=0, message="", error="", **filters): + query = {} + if client_id: + query["client_id"] = client_id + if message: + query["message"] = message[:300] + if error: + query["error"] = error[:300] + for key, value in filters.items(): + if value not in (None, "", "all"): + query[key] = value + return RedirectResponse( + "/tools/accounting/sales-income" + ("?" + urlencode(query) if query else ""), + status_code=303, + ) + + +@router.get("") +def page( + request: Request, + client_id: int | None = None, + status: str = "all", + customer: str = "", + period: str = "", + page: int = 1, + per_page: int = 25, + message: str = "", + error: str = "", +): + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.learning.view") + if denied: + return denied + + clients, scope = _visible_clients(db, request, user) + selected = next( + (client for client in clients if client_id and int(client.id) == int(client_id)), + None, + ) + + rows = [] + total = 0 + pages = 1 + if selected: + rows, total, page, pages = list_sales( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + status=status, + customer=customer, + period=period, + page=page, + per_page=per_page, + ) + + return templates.TemplateResponse( + "modules/accounting/templates/accounting/sales_income.html", + { + "request": request, + "current_user": user, + "current_user_roles": get_user_roles(db, user.id), + "current_user_permissions": get_user_permissions(db, user.id), + "csrf_token": get_or_create_csrf_token(request), + "title": "Sales & Income", + "clients": clients, + "selected_client": selected, + "rows": rows, + "total": total, + "page": page, + "pages": pages, + "per_page": per_page, + "status_filter": status, + "customer_filter": customer, + "period_filter": period, + "natures": sales_income_natures(db, scope.tenant_id), + "message": message, + "error": error, + }, + ) + finally: + db.close() + + +@router.post("/import") +async def import_file( + request: Request, + client_id: int = Form(...), + source_type: str = Form("outward_import"), + csrf_token: str = Form(...), + file: UploadFile = File(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.learning.manage") + if denied: + return denied + client, _, scope = _find_visible_client(db, request, user, client_id) + if not client: + return _go(error="Client is not visible.") + + payload = await file.read() + if not payload: + raise ValueError("Uploaded Sales & Income file is empty.") + if len(payload) > 25 * 1024 * 1024: + raise ValueError("Sales & Income import file exceeds the 25 MB limit.") + + result = import_sales_file( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + filename=file.filename or "sales.xlsx", + payload=payload, + user_id=user.id, + source_type=(source_type or "outward_import").strip()[:30], + ) + return _go( + client.id, + message=( + f"Imported {result['inserted']} sales/income row(s); " + f"{result['duplicates']} duplicate(s) skipped." + ), + ) + except Exception as exc: + db.rollback() + return _go(client_id, error=str(exc)) + finally: + db.close() + + +@router.post("/{row_id}/review") +def review( + request: Request, + row_id: int, + client_id: int = Form(...), + final_nature_id: int = Form(...), + final_ledger_name: str = Form(""), + review_note: str = Form(""), + status: str = Form("all"), + customer: str = Form(""), + period: str = Form(""), + page: int = Form(1), + per_page: int = Form(25), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.learning.manage") + if denied: + return denied + client, _, scope = _find_visible_client(db, request, user, client_id) + if not client: + return _go(error="Client is not visible.") + + review_sales( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + row_id=row_id, + final_nature_id=final_nature_id, + final_ledger_name=final_ledger_name, + review_note=review_note, + user_id=user.id, + ) + return _go( + client.id, + message="Sales / income accounting treatment reviewed.", + status=status, + customer=customer, + period=period, + page=page, + per_page=per_page, + ) + except Exception as exc: + db.rollback() + return _go( + client_id, + error=str(exc), + status=status, + customer=customer, + period=period, + page=page, + per_page=per_page, + ) + finally: + db.close() diff --git a/app/modules/accounting/templates/accounting/sales_income.html b/app/modules/accounting/templates/accounting/sales_income.html new file mode 100644 index 0000000..6be2507 --- /dev/null +++ b/app/modules/accounting/templates/accounting/sales_income.html @@ -0,0 +1,156 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
Accounting · Sales & Income · Phase 14
+Normalized outward transaction register for GSTR-1/exported sales data, customer/GSTIN identity, income-nature classification and reviewer-confirmed sales ledger mapping. Phase 14 is review-only; Tally sales posting is not enabled yet.
+Upload CSV/XLS/XLSX exported from GSTR-1 working papers, billing software or another outward-sales source. Flexible common column names are detected automatically. Duplicate invoice fingerprints are skipped.
+ +{{ total }} normalized outward transaction(s).
+Tools · Accounting
Company mapping, master sync and transaction sync remain available. Phase 5 adds review-only accounting tools without Tally write-back.
+Company mapping, master sync and transaction sync remain available. Accounting tools are now grouped into Purchases & Expenses and Sales & Income without changing existing routes or controls.