Add Phase 14 sales and income foundation

This commit is contained in:
A R R R Associates
2026-08-22 22:12:23 +05:30
parent c814a774d9
commit efc12e20d3
8 changed files with 1061 additions and 14 deletions
@@ -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")
+84
View File
@@ -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),
)
+424
View File
@@ -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
+72
View File
@@ -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()
)
+209
View File
@@ -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()
@@ -0,0 +1,156 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Sales & Income · Phase 14</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Sales & Income Foundation</h1>
<p class="mt-1 max-w-4xl text-sm text-slate-500">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.</p>
</div>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Back to Accounting</a>
</div>
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" class="grid gap-4 md:grid-cols-[1fr_auto]">
<label class="text-sm font-semibold">ERP Client
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="">Select client</option>
{% for c in clients %}
<option value="{{ c.id }}" {% if selected_client and c.id == selected_client.id %}selected{% endif %}>{{ c.client_name }}</option>
{% endfor %}
</select>
</label>
<div class="flex items-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Open Sales & Income</button></div>
</form>
</section>
{% if selected_client %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<h2 class="font-semibold text-slate-900">Import Outward Sales / Income</h2>
<p class="mt-1 text-sm text-slate-500">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.</p>
<form method="post" action="/tools/accounting/sales-income/import" enctype="multipart/form-data" class="mt-4 grid gap-4 md:grid-cols-[220px_1fr_auto] md:items-end">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<label class="text-sm font-semibold">Source
<select name="source_type" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="outward_import">Outward Sales Import</option>
<option value="gstr1">GSTR-1</option>
<option value="einvoice_outward">E-Invoice Outward</option>
<option value="ewaybill_outward">E-Way Bill Outward</option>
</select>
</label>
<label class="text-sm font-semibold">CSV / Excel file
<input type="file" name="file" accept=".csv,.xlsx,.xls,.xlsm" required class="mt-1 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">
</label>
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Import</button>
</form>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" class="grid gap-3 md:grid-cols-5">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<label class="text-xs font-semibold">Status
<select name="status" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
{% for value,label in [('all','All'),('suggested','Suggested'),('review_required','Review Required'),('reviewed','Reviewed')] %}
<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="text-xs font-semibold md:col-span-2">Customer / GSTIN
<input name="customer" value="{{ customer_filter }}" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
</label>
<label class="text-xs font-semibold">Return Period
<input name="period" value="{{ period_filter }}" placeholder="2026-07" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
</label>
<div class="flex items-end"><button class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Filter</button></div>
</form>
</section>
<section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4">
<h2 class="font-semibold">{{ selected_client.client_name }} · Sales & Income Register</h2>
<p class="text-sm text-slate-500">{{ total }} normalized outward transaction(s).</p>
</div>
<div class="divide-y divide-slate-100">
{% for row in rows %}
<article class="p-5">
<div class="grid gap-4 xl:grid-cols-12">
<div class="xl:col-span-3">
<div class="font-semibold">{{ row.invoice_date or 'No date' }} · {{ row.invoice_number or 'No invoice no.' }}</div>
<div class="mt-1 text-sm">{{ row.customer_name or 'Unnamed customer' }}</div>
<div class="text-xs text-slate-500">{{ row.customer_gstin or 'No GSTIN' }}</div>
<div class="mt-2 text-sm">{{ row.description_text or '—' }}</div>
<div class="mt-2 font-semibold">₹{{ '%.2f'|format(row.invoice_value) }}</div>
</div>
<div class="xl:col-span-3">
<div class="text-xs uppercase text-slate-500">First-pass suggestion</div>
<div class="mt-1 font-semibold">
{% for n in natures %}{% if n.id == row.suggested_nature_id %}{{ n.name }}{% endif %}{% endfor %}
</div>
<div class="text-sm">{{ row.suggested_confidence }}% confidence</div>
<div class="mt-2 text-xs text-slate-500">{{ row.suggestion_reason or '—' }}</div>
<div class="mt-2 text-xs text-slate-500">{{ row.source_type }} · {{ row.hsn_code or 'No HSN/SAC' }}</div>
</div>
<div class="xl:col-span-6">
{% if row.review_status != 'reviewed' %}
<form method="post" action="/tools/accounting/sales-income/{{ row.id }}/review" class="grid gap-2 md:grid-cols-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="status" value="{{ status_filter }}">
<input type="hidden" name="customer" value="{{ customer_filter }}">
<input type="hidden" name="period" value="{{ period_filter }}">
<input type="hidden" name="page" value="{{ page }}">
<input type="hidden" name="per_page" value="{{ per_page }}">
<label class="text-xs font-semibold">Sales / Income Nature
<select name="final_nature_id" required class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
{% for n in natures %}
<option value="{{ n.id }}" {% if n.id == row.suggested_nature_id %}selected{% endif %}>{{ n.name }}</option>
{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Client Tally Sales / Income Ledger
<input name="final_ledger_name" value="{{ row.suggested_ledger_name }}" placeholder="Reviewer maps existing Tally ledger" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
</label>
<label class="text-xs font-semibold md:col-span-2">Review note
<input name="review_note" class="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2">
</label>
<button class="rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-white md:col-span-2">Confirm Sales / Income Treatment</button>
</form>
{% else %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">
<strong>Reviewed.</strong>
{% for n in natures %}{% if n.id == row.final_nature_id %}{{ n.name }}{% endif %}{% endfor %}
{% if row.final_ledger_name %} · {{ row.final_ledger_name }}{% endif %}
</div>
{% endif %}
</div>
</div>
</article>
{% else %}
<div class="p-10 text-center text-sm text-slate-500">No Sales & Income records yet. Import an outward-sales CSV/Excel file above.</div>
{% endfor %}
</div>
{% if pages > 1 %}
<div class="flex items-center justify-between border-t border-slate-200 p-4 text-sm">
<span>Page {{ page }} of {{ pages }}</span>
<div class="flex gap-2">
{% if page > 1 %}
<a class="rounded-lg border border-slate-300 px-3 py-1" href="?client_id={{ selected_client.id }}&status={{ status_filter }}&customer={{ customer_filter }}&period={{ period_filter }}&page={{ page-1 }}&per_page={{ per_page }}">Previous</a>
{% endif %}
{% if page < pages %}
<a class="rounded-lg border border-slate-300 px-3 py-1" href="?client_id={{ selected_client.id }}&status={{ status_filter }}&customer={{ customer_filter }}&period={{ period_filter }}&page={{ page+1 }}&per_page={{ per_page }}">Next</a>
{% endif %}
</div>
</div>
{% endif %}
</section>
{% endif %}
</div>
{% endblock %}
@@ -10,23 +10,46 @@
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Tally Accounting</h1>
<p class="mt-1 text-sm text-slate-500">Company mapping, master sync and transaction sync remain available. Phase 5 adds review-only accounting tools without Tally write-back.</p>
<p class="mt-1 text-sm text-slate-500">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.</p>
</div>
<div class="w-full space-y-3 xl:max-w-5xl">
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-amber-700">Purchases & Expenses</div>
<div class="text-xs text-slate-500">Existing Phase 3–13 tools are grouped here; their URLs and behavior are unchanged.</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/taxonomy" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Accounting Taxonomy</a>
{% if selected_client %}<a href="/tools/accounting/historical-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Historical Learning</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/ledger-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Ledger Learning</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/gstr2b?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">GSTR-2B Intelligence</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/purchase-enrichment?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">E-Invoice / E-Way Bill</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Purchase Review</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/purchase-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Post to Tally</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Bank → Tally</a>{% endif %}
<a href="/tools/accounting/ai{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-indigo-50 px-4 py-2 text-sm font-semibold text-indigo-800 hover:bg-indigo-100">Accounting AI</a>
<a href="/tools/accounting/internal-model" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-800 hover:bg-emerald-100">Internal Model</a>
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Depreciation (IT)</a>{% endif %}
<a href="/tools/accounting/taxonomy" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Accounting Taxonomy</a>
{% if selected_client %}<a href="/tools/accounting/historical-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Historical Learning</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/ledger-learning?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Ledger Learning</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/gstr2b?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">GSTR-2B Intelligence</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/purchase-enrichment?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">E-Invoice / E-Way Bill</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/purchase-review?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Purchase Review</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/purchase-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Purchase → Tally</a>{% endif %}
{% if selected_client %}<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Bank Expenses / Payments</a>{% endif %}
{% if selected_client %}<a href="/tools/tally/depreciation?client_id={{ selected_client.id }}" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-semibold">Depreciation (IT)</a>{% endif %}
<a href="/tools/accounting/ai{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-indigo-300 bg-indigo-50 px-3 py-2 text-sm font-semibold text-indigo-800">Accounting AI</a>
<a href="/tools/accounting/internal-model" class="rounded-xl border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800">Internal Model</a>
</div>
</div>
<div class="rounded-2xl border border-emerald-200 bg-emerald-50/40 p-4">
<div class="mb-3">
<div class="text-xs font-semibold uppercase tracking-[0.14em] text-emerald-700">Sales & Income</div>
<div class="text-xs text-slate-500">Phase 14 starts the outward-sales/customer/income workflow. Existing accounting infrastructure is reused.</div>
</div>
<div class="flex flex-wrap gap-2">
{% if selected_client %}<a href="/tools/accounting/sales-income?client_id={{ selected_client.id }}" class="rounded-xl bg-emerald-700 px-3 py-2 text-sm font-semibold text-white">Sales & Income Foundation</a>{% else %}<a href="/tools/accounting/sales-income" class="rounded-xl bg-emerald-700 px-3 py-2 text-sm font-semibold text-white">Sales & Income Foundation</a>{% endif %}
</div>
</div>
<div class="flex flex-wrap justify-end gap-2">
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}&refresh=1{% else %}?refresh=1{% endif %}" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Refresh Tally Companies</a>
</div>
</div>
</div>
{% if initialized %}
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">Client accounting storage was initialized successfully.</div>
+2
View File
@@ -47,6 +47,7 @@ from app.modules.accounting.purchase_posting_ui import router as accounting_purc
from app.modules.accounting.bank_ui import router as accounting_bank_ui_router
from app.modules.accounting.ai_ui import router as accounting_ai_ui_router
from app.modules.accounting.internal_model_ui import router as accounting_internal_model_ui_router
from app.modules.accounting.sales_ui import router as accounting_sales_income_ui_router
from app.modules.registrations.ui import router as registrations_ui_router
from app.modules.credential_vault.ui import router as credential_vault_ui_router
from app.modules.client_identity.ui import router as client_identity_ui_router
@@ -80,6 +81,7 @@ def mount_ui(app: FastAPI) -> None:
app.include_router(accounting_bank_ui_router)
app.include_router(accounting_ai_ui_router)
app.include_router(accounting_internal_model_ui_router)
app.include_router(accounting_sales_income_ui_router)
app.include_router(work_tracker_ui_router)
app.include_router(billing_ui_router)
app.include_router(platform_billing_ui_router)