883 lines
36 KiB
Python
883 lines
36 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
from decimal import Decimal
|
|
|
|
from fastapi import APIRouter, File, Form, Request, UploadFile
|
|
from fastapi.responses import RedirectResponse, StreamingResponse
|
|
from sqlalchemy import select
|
|
|
|
from app.core.db.common import CommonSessionLocal
|
|
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
|
|
from app.core.security.session_auth import get_current_user
|
|
from app.core.templating import templates
|
|
from app.modules.billing.models import BillingFeeGroup, BillingSettings
|
|
from app.modules.billing.services import (
|
|
BILLING_MODES,
|
|
FREQUENCIES,
|
|
PAYMENT_MODES,
|
|
TAX_TYPES,
|
|
build_fee_structure_template,
|
|
build_invoice_print_context,
|
|
build_billing_report_summary,
|
|
billing_financial_year,
|
|
create_invoice,
|
|
fee_group_already_billed,
|
|
generate_draft_invoices_from_fee_groups,
|
|
get_invoice,
|
|
import_fee_structure_excel,
|
|
issue_invoice,
|
|
list_clients_for_billing,
|
|
list_fee_groups,
|
|
list_fee_groups_for_generation,
|
|
list_invoices,
|
|
list_payments,
|
|
list_services_for_billing,
|
|
parse_date,
|
|
preview_invoice_number,
|
|
record_invoice_payment,
|
|
get_payment,
|
|
)
|
|
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
|
|
from app.modules.core.rbac.permission_guard import require_permission
|
|
from app.modules.core.tenancy.models import Branch, Tenant
|
|
from app.modules.core.tenancy.year_control import redirect_if_financial_year_locked, is_row_financial_year_locked
|
|
|
|
router = APIRouter(prefix="/billing", tags=["billing-ui"])
|
|
|
|
|
|
def _base_ctx(request: Request, user, db, **ctx):
|
|
base = {
|
|
"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),
|
|
"tax_types": TAX_TYPES,
|
|
"billing_modes": BILLING_MODES,
|
|
"frequencies": FREQUENCIES,
|
|
"payment_modes": PAYMENT_MODES,
|
|
}
|
|
base.update(ctx)
|
|
return base
|
|
|
|
|
|
def _render(request: Request, template: str, db, user, **ctx):
|
|
return templates.TemplateResponse(template, _base_ctx(request, user, db, **ctx))
|
|
|
|
|
|
def _redirect_denied():
|
|
from app.core.http_responses import ui_access_denied
|
|
return ui_access_denied()
|
|
|
|
|
|
def _has_perm(db, user, code: str) -> bool:
|
|
try:
|
|
require_permission(db, user, code)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _role_names(db, user) -> set[str]:
|
|
return {str(r or "").strip() for r in get_user_roles(db, user.id)}
|
|
|
|
|
|
def _can_manage_billing_settings(db, user) -> bool:
|
|
roles = _role_names(db, user)
|
|
return bool({"System Admin", "Firm Admin", "Partner"}.intersection(roles)) or _has_perm(db, user, "billing.edit")
|
|
|
|
|
|
def _get_or_create_billing_settings(db, *, tenant_id: int, branch_id: int | None) -> BillingSettings:
|
|
row = db.execute(
|
|
select(BillingSettings).where(BillingSettings.tenant_id == tenant_id, BillingSettings.branch_id == branch_id)
|
|
).scalar_one_or_none()
|
|
if row:
|
|
return row
|
|
row = BillingSettings(tenant_id=tenant_id, branch_id=branch_id)
|
|
db.add(row)
|
|
db.flush()
|
|
return row
|
|
|
|
|
|
def _decimal_form(value: str | None, default: str = "0.00") -> Decimal:
|
|
try:
|
|
return Decimal(str(value or default)).quantize(Decimal("0.01"))
|
|
except Exception:
|
|
return Decimal(default).quantize(Decimal("0.01"))
|
|
|
|
|
|
def _int_form(value: str | int | None, default: int, minimum: int | None = None, maximum: int | None = None) -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except Exception:
|
|
parsed = default
|
|
if minimum is not None:
|
|
parsed = max(minimum, parsed)
|
|
if maximum is not None:
|
|
parsed = min(maximum, parsed)
|
|
return parsed
|
|
|
|
|
|
def _billing_context_names(db, *, tenant_id: int, branch_id: int | None) -> tuple[str, str | None]:
|
|
tenant = db.get(Tenant, tenant_id)
|
|
branch = db.get(Branch, branch_id) if branch_id else None
|
|
return (getattr(tenant, "name", None) or f"Audit Firm {tenant_id}", getattr(branch, "name", None) if branch else None)
|
|
|
|
|
|
def _active_tenant_id(request: Request, user) -> int:
|
|
return int(request.session.get("active_tenant_id") or request.session.get("selected_tenant_id") or request.session.get("tenant_id") or user.tenant_id)
|
|
|
|
|
|
def _active_branch_id(request: Request, user, db) -> int | None:
|
|
value = request.session.get("active_branch_id")
|
|
if value in (None, "", 0, "0"):
|
|
if _has_perm(db, user, "billing.cross_branch"):
|
|
return None
|
|
return int(getattr(user, "branch_id", 0) or 0) or None
|
|
return int(value)
|
|
|
|
|
|
def _active_financial_year(request: Request) -> str | None:
|
|
value = request.session.get("active_financial_year") or getattr(request.state, "year_code", None)
|
|
value = (value or "").strip()
|
|
if not value or value.upper() == "ALL":
|
|
return None
|
|
return value
|
|
|
|
|
|
def _period_start_for_fy(financial_year: str | None) -> date:
|
|
try:
|
|
start_year = int(str(financial_year or "").split("-")[0])
|
|
return date(start_year, 4, 1)
|
|
except Exception:
|
|
today = date.today()
|
|
return date(today.year if today.month >= 4 else today.year - 1, 4, 1)
|
|
|
|
|
|
def _period_end_for_fy(financial_year: str | None) -> date:
|
|
start = _period_start_for_fy(financial_year)
|
|
return date(start.year + 1, 3, 31)
|
|
|
|
|
|
def _locked_partner_id(db, user) -> int | None:
|
|
return int(user.id) if _has_perm(db, user, "billing.view_own") else None
|
|
|
|
|
|
def _require_billing_user(request: Request, db, permission_code: str):
|
|
user = get_current_user(request, db=db)
|
|
if not user:
|
|
return None, RedirectResponse(url="/login", status_code=303)
|
|
try:
|
|
require_permission(db, user, permission_code)
|
|
except Exception:
|
|
return user, _redirect_denied()
|
|
return user, None
|
|
|
|
|
|
@router.get("")
|
|
def invoice_list(request: Request, q: str = ""):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.view")
|
|
if response:
|
|
return response
|
|
tenant_id = _active_tenant_id(request, user)
|
|
branch_id = _active_branch_id(request, user, db)
|
|
partner_id = _locked_partner_id(db, user)
|
|
financial_year = _active_financial_year(request)
|
|
rows = list_invoices(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year, q=q)
|
|
report_summary = build_billing_report_summary(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year)
|
|
return _render(
|
|
request,
|
|
"modules/billing/templates/billing/list.html",
|
|
db,
|
|
user,
|
|
title="Billing - Invoices",
|
|
q=q,
|
|
active_financial_year=_active_financial_year(request),
|
|
rows=rows,
|
|
report_summary=report_summary,
|
|
can_create=_has_perm(db, user, "billing.create"),
|
|
can_import_fee_structure=_has_perm(db, user, "billing_fee_structure.import"),
|
|
can_generate=_has_perm(db, user, "billing_invoice.generate"),
|
|
can_view_fee_structure=_has_perm(db, user, "billing_fee_structure.view"),
|
|
can_record_payment=_has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create"),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/payments")
|
|
def payment_list(request: Request, q: str = ""):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.view")
|
|
if response:
|
|
return response
|
|
financial_year = _active_financial_year(request)
|
|
rows = list_payments(
|
|
db,
|
|
tenant_id=_active_tenant_id(request, user),
|
|
branch_id=_active_branch_id(request, user, db),
|
|
partner_id=_locked_partner_id(db, user),
|
|
financial_year=financial_year,
|
|
q=q,
|
|
)
|
|
return _render(request, "modules/billing/templates/billing/payments/list.html", db, user, title="Payments & Receipts", rows=rows, q=q, active_financial_year=financial_year)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/payments/{payment_id}/receipt")
|
|
def payment_receipt_print(request: Request, payment_id: int):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.view")
|
|
if response:
|
|
return response
|
|
payment = get_payment(db, payment_id=payment_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
|
if not payment:
|
|
return _redirect_denied()
|
|
invoice_ctx = build_invoice_print_context(db, payment.invoice)
|
|
return _render(request, "modules/billing/templates/billing/payments/receipt_print.html", db, user, title=f"Receipt {payment.receipt_no}", payment=payment, invoice=payment.invoice, invoice_ctx=invoice_ctx)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/settings")
|
|
def billing_settings_page(request: Request, branch_scope: str = "active"):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.view")
|
|
if response:
|
|
return response
|
|
tenant_id = _active_tenant_id(request, user)
|
|
active_branch_id = _active_branch_id(request, user, db)
|
|
branch_id = None if branch_scope == "firm" and _has_perm(db, user, "billing.cross_branch") else active_branch_id
|
|
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
|
|
tenant_name, branch_name = _billing_context_names(db, tenant_id=tenant_id, branch_id=branch_id)
|
|
return _render(
|
|
request,
|
|
"modules/billing/templates/billing/settings.html",
|
|
db,
|
|
user,
|
|
title="Billing Settings",
|
|
settings=settings,
|
|
preview_invoice_no=preview_invoice_number(settings, branch_id=branch_id, financial_year=_active_financial_year(request)),
|
|
tenant_name=tenant_name,
|
|
branch_name=branch_name,
|
|
branch_scope="firm" if branch_id is None else "active",
|
|
can_edit_settings=_can_manage_billing_settings(db, user),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/settings")
|
|
def billing_settings_submit(
|
|
request: Request,
|
|
branch_scope: str = Form("active"),
|
|
legal_name: str | None = Form(None),
|
|
gstin: str | None = Form(None),
|
|
pan: str | None = Form(None),
|
|
state_code: str | None = Form(None),
|
|
billing_address: str | None = Form(None),
|
|
contact_email: str | None = Form(None),
|
|
contact_mobile: str | None = Form(None),
|
|
website_url: str | None = Form(None),
|
|
invoice_title: str | None = Form(None),
|
|
invoice_prefix: str = Form("INV"),
|
|
invoice_number_format: str | None = Form("{prefix}/{fy}/{number}"),
|
|
next_invoice_no: int = Form(1),
|
|
padding: int = Form(4),
|
|
default_due_days: int = Form(15),
|
|
default_gst_rate: str = Form("18.00"),
|
|
default_tax_type: str = Form("CGST_SGST"),
|
|
default_sac_code: str | None = Form(None),
|
|
bank_name: str | None = Form(None),
|
|
bank_account_name: str | None = Form(None),
|
|
bank_account_number: str | None = Form(None),
|
|
bank_ifsc: str | None = Form(None),
|
|
upi_id: str | None = Form(None),
|
|
bank_details: str | None = Form(None),
|
|
terms: str | None = Form(None),
|
|
footer_note: str | None = Form(None),
|
|
declaration: str | None = Form(None),
|
|
authorised_signatory_name: str | None = Form(None),
|
|
payumoney_enabled: str | None = Form(None),
|
|
payumoney_mode: str = Form("TEST"),
|
|
payumoney_merchant_key: str | None = Form(None),
|
|
payumoney_merchant_salt: str | None = Form(None),
|
|
payumoney_merchant_id: str | None = Form(None),
|
|
payumoney_product_info: str | None = Form(None),
|
|
cashfree_enabled: str | None = Form(None),
|
|
cashfree_mode: str = Form("TEST"),
|
|
cashfree_client_id: str | None = Form(None),
|
|
cashfree_client_secret: str | None = Form(None),
|
|
cashfree_api_version: str | None = Form("2023-08-01"),
|
|
cashfree_order_note: str | None = Form(None),
|
|
csrf_token: str = Form(...),
|
|
):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.view")
|
|
if response:
|
|
return response
|
|
if not _can_manage_billing_settings(db, user):
|
|
return _redirect_denied()
|
|
tenant_id = _active_tenant_id(request, user)
|
|
active_branch_id = _active_branch_id(request, user, db)
|
|
branch_id = None if branch_scope == "firm" and _has_perm(db, user, "billing.cross_branch") else active_branch_id
|
|
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
|
|
|
|
settings.legal_name = (legal_name or "").strip() or None
|
|
settings.gstin = (gstin or "").strip().upper() or None
|
|
settings.pan = (pan or "").strip().upper() or None
|
|
settings.state_code = (state_code or "").strip()[:2] or None
|
|
settings.billing_address = (billing_address or "").strip() or None
|
|
settings.contact_email = (contact_email or "").strip() or None
|
|
settings.contact_mobile = (contact_mobile or "").strip() or None
|
|
settings.website_url = (website_url or "").strip() or None
|
|
|
|
settings.invoice_title = (invoice_title or "").strip() or None
|
|
settings.invoice_prefix = (invoice_prefix or "INV").strip().upper()[:40] or "INV"
|
|
settings.invoice_number_format = (invoice_number_format or "{prefix}/{fy}/{number}").strip()[:120] or "{prefix}/{fy}/{number}"
|
|
settings.next_invoice_no = _int_form(next_invoice_no, 1, minimum=1)
|
|
settings.padding = _int_form(padding, 4, minimum=1, maximum=10)
|
|
settings.default_due_days = _int_form(default_due_days, 15, minimum=0, maximum=365)
|
|
settings.default_gst_rate = _decimal_form(default_gst_rate, "18.00")
|
|
settings.default_tax_type = default_tax_type if default_tax_type in TAX_TYPES else "CGST_SGST"
|
|
settings.default_sac_code = (default_sac_code or "").strip()[:20] or None
|
|
|
|
settings.bank_name = (bank_name or "").strip() or None
|
|
settings.bank_account_name = (bank_account_name or "").strip() or None
|
|
settings.bank_account_number = (bank_account_number or "").strip() or None
|
|
settings.bank_ifsc = (bank_ifsc or "").strip().upper() or None
|
|
settings.upi_id = (upi_id or "").strip() or None
|
|
settings.bank_details = (bank_details or "").strip() or None
|
|
settings.terms = (terms or "").strip() or None
|
|
settings.footer_note = (footer_note or "").strip() or None
|
|
settings.declaration = (declaration or "").strip() or None
|
|
settings.authorised_signatory_name = (authorised_signatory_name or "").strip() or None
|
|
|
|
settings.payumoney_enabled = bool(payumoney_enabled)
|
|
settings.payumoney_mode = (payumoney_mode or "TEST").strip().upper() if (payumoney_mode or "TEST").strip().upper() in {"TEST", "LIVE"} else "TEST"
|
|
settings.payumoney_merchant_key = (payumoney_merchant_key or "").strip() or None
|
|
settings.payumoney_merchant_salt = (payumoney_merchant_salt or "").strip() or None
|
|
settings.payumoney_merchant_id = (payumoney_merchant_id or "").strip() or None
|
|
settings.payumoney_product_info = (payumoney_product_info or "").strip() or None
|
|
db.commit()
|
|
suffix = "?branch_scope=firm" if branch_id is None else ""
|
|
return RedirectResponse(url=f"/billing/settings{suffix}", status_code=303)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/invoices/new")
|
|
@router.get("/new")
|
|
def invoice_create_page(request: Request):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.create")
|
|
if response:
|
|
return response
|
|
tenant_id = _active_tenant_id(request, user)
|
|
branch_id = _active_branch_id(request, user, db)
|
|
partner_id = _locked_partner_id(db, user)
|
|
clients = list_clients_for_billing(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id)
|
|
services = list_services_for_billing(db)
|
|
settings = _get_or_create_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
|
|
return _render(
|
|
request,
|
|
"modules/billing/templates/billing/create.html",
|
|
db,
|
|
user,
|
|
title="Create Invoice",
|
|
active_financial_year=_active_financial_year(request),
|
|
default_billing_period_from=_period_start_for_fy(_active_financial_year(request)).isoformat(),
|
|
default_billing_period_to=_period_end_for_fy(_active_financial_year(request)).isoformat(),
|
|
clients=clients,
|
|
services=services,
|
|
settings=settings,
|
|
today=date.today().isoformat(),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/new")
|
|
def invoice_create_submit(
|
|
request: Request,
|
|
client_id: int = Form(...),
|
|
invoice_date: str = Form(...),
|
|
due_date: str | None = Form(None),
|
|
billing_period_from: str | None = Form(None),
|
|
billing_period_to: str | None = Form(None),
|
|
tax_type: str = Form("CGST_SGST"),
|
|
place_of_supply: str | None = Form(None),
|
|
client_state_code: str | None = Form(None),
|
|
reverse_charge: str | None = Form(None),
|
|
notes: str | None = Form(None),
|
|
terms: str | None = Form(None),
|
|
line_description: list[str] = Form(default=[]),
|
|
line_service_id: list[str] = Form(default=[]),
|
|
line_quantity: list[str] = Form(default=[]),
|
|
line_rate: list[str] = Form(default=[]),
|
|
line_discount: list[str] = Form(default=[]),
|
|
line_gst_rate: list[str] = Form(default=[]),
|
|
line_sac_code: list[str] = Form(default=[]),
|
|
csrf_token: str = Form(...),
|
|
):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.create")
|
|
if response:
|
|
return response
|
|
tenant_id = _active_tenant_id(request, user)
|
|
branch_id = _active_branch_id(request, user, db)
|
|
partner_id = _locked_partner_id(db, user)
|
|
financial_year = _active_financial_year(request)
|
|
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing?financial_year={financial_year or ''}")
|
|
if locked_response:
|
|
return locked_response
|
|
|
|
allowed_clients = {c.id for c in list_clients_for_billing(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id)}
|
|
if client_id not in allowed_clients:
|
|
return _redirect_denied()
|
|
|
|
raw_lines = []
|
|
max_len = max(len(line_description), len(line_service_id), len(line_quantity), len(line_rate), len(line_discount), len(line_gst_rate), len(line_sac_code), 0)
|
|
for idx in range(max_len):
|
|
raw_lines.append({
|
|
"description": line_description[idx] if idx < len(line_description) else "",
|
|
"service_id": line_service_id[idx] if idx < len(line_service_id) else "",
|
|
"quantity": line_quantity[idx] if idx < len(line_quantity) else "1",
|
|
"rate": line_rate[idx] if idx < len(line_rate) else "0",
|
|
"discount_amount": line_discount[idx] if idx < len(line_discount) else "0",
|
|
"gst_rate": line_gst_rate[idx] if idx < len(line_gst_rate) else "18",
|
|
"sac_code": line_sac_code[idx] if idx < len(line_sac_code) else "",
|
|
})
|
|
|
|
invoice = create_invoice(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
branch_id=branch_id,
|
|
client_id=client_id,
|
|
invoice_date=parse_date(invoice_date) or date.today(),
|
|
due_date=parse_date(due_date),
|
|
billing_period_from=parse_date(billing_period_from),
|
|
billing_period_to=parse_date(billing_period_to),
|
|
tax_type=tax_type,
|
|
notes=notes,
|
|
terms=terms,
|
|
place_of_supply=place_of_supply,
|
|
client_state_code=client_state_code,
|
|
reverse_charge=(reverse_charge == "yes"),
|
|
created_by_user_id=user.id,
|
|
raw_lines=raw_lines,
|
|
financial_year=_active_financial_year(request),
|
|
)
|
|
db.commit()
|
|
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
|
|
except ValueError:
|
|
db.rollback()
|
|
return RedirectResponse(url="/billing/new", status_code=303)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/fee-structures/list")
|
|
def fee_structure_list(request: Request, q: str = ""):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing_fee_structure.view")
|
|
if response:
|
|
return response
|
|
rows = list_fee_groups(
|
|
db,
|
|
tenant_id=_active_tenant_id(request, user),
|
|
branch_id=_active_branch_id(request, user, db),
|
|
partner_id=_locked_partner_id(db, user),
|
|
q=q,
|
|
)
|
|
return _render(
|
|
request,
|
|
"modules/billing/templates/billing/fee_structures/list.html",
|
|
db,
|
|
user,
|
|
title="Fee Structure",
|
|
q=q,
|
|
active_financial_year=_active_financial_year(request),
|
|
rows=rows,
|
|
can_import=_has_perm(db, user, "billing_fee_structure.import"),
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/fee-structures/import")
|
|
def fee_structure_import_page(request: Request):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
|
|
if response:
|
|
return response
|
|
return _render(request, "modules/billing/templates/billing/fee_structures/import.html", db, user, title="Import Fee Structure", result=None)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/fee-structures/template")
|
|
def fee_structure_template_download(request: Request):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
|
|
if response:
|
|
return response
|
|
data = build_fee_structure_template()
|
|
return StreamingResponse(
|
|
iter([data]),
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": "attachment; filename=billing_fee_structure_template.xlsx"},
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/fee-structures/import")
|
|
async def fee_structure_import_submit(request: Request, import_file: UploadFile = File(...), csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing_fee_structure.import")
|
|
if response:
|
|
return response
|
|
filename = (import_file.filename or "").lower()
|
|
if not filename.endswith((".xlsx", ".xlsm")):
|
|
result = {"success": False, "created": 0, "updated": 0, "errors": ["Please upload an .xlsx file."]}
|
|
else:
|
|
content = await import_file.read()
|
|
if len(content) > 5 * 1024 * 1024:
|
|
result = {"success": False, "created": 0, "updated": 0, "errors": ["File size must be 5 MB or less."]}
|
|
else:
|
|
result = import_fee_structure_excel(
|
|
db,
|
|
tenant_id=_active_tenant_id(request, user),
|
|
branch_id=_active_branch_id(request, user, db),
|
|
created_by_user_id=user.id,
|
|
file_bytes=content,
|
|
)
|
|
return _render(request, "modules/billing/templates/billing/fee_structures/import.html", db, user, title="Import Fee Structure", result=result)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/generate")
|
|
def generate_invoices_page(
|
|
request: Request,
|
|
frequency: str = "Monthly",
|
|
billing_period_from: str | None = None,
|
|
billing_period_to: str | None = None,
|
|
auto_generate_only: str = "yes",
|
|
q: str = "",
|
|
):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing_invoice.generate")
|
|
if response:
|
|
return response
|
|
tenant_id = _active_tenant_id(request, user)
|
|
branch_id = _active_branch_id(request, user, db)
|
|
partner_id = _locked_partner_id(db, user)
|
|
financial_year = _active_financial_year(request)
|
|
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing?financial_year={financial_year or ''}")
|
|
if locked_response:
|
|
return locked_response
|
|
period_from = parse_date(billing_period_from) or _period_start_for_fy(financial_year)
|
|
period_to = parse_date(billing_period_to) or _period_end_for_fy(financial_year)
|
|
rows = list_fee_groups_for_generation(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
branch_id=branch_id,
|
|
partner_id=partner_id,
|
|
frequency=frequency or None,
|
|
auto_generate_only=(auto_generate_only != "no"),
|
|
q=q,
|
|
)
|
|
duplicate_map = {
|
|
row.id: fee_group_already_billed(db, tenant_id=tenant_id, fee_group_id=row.id, period_from=period_from, period_to=period_to)
|
|
for row in rows
|
|
}
|
|
return _render(
|
|
request,
|
|
"modules/billing/templates/billing/generate.html",
|
|
db,
|
|
user,
|
|
title="Generate Draft Invoices",
|
|
rows=rows,
|
|
duplicate_map=duplicate_map,
|
|
frequencies=FREQUENCIES,
|
|
frequency=frequency,
|
|
billing_period_from=period_from.isoformat(),
|
|
billing_period_to=period_to.isoformat(),
|
|
auto_generate_only=auto_generate_only,
|
|
q=q,
|
|
active_financial_year=_active_financial_year(request),
|
|
result=None,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/generate")
|
|
def generate_invoices_submit(
|
|
request: Request,
|
|
frequency: str = Form("Monthly"),
|
|
billing_period_from: str = Form(...),
|
|
billing_period_to: str = Form(...),
|
|
auto_generate_only: str = Form("yes"),
|
|
q: str = Form(""),
|
|
fee_group_ids: list[int] = Form(default=[]),
|
|
skip_duplicates: str = Form("yes"),
|
|
csrf_token: str = Form(...),
|
|
):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing_invoice.generate")
|
|
if response:
|
|
return response
|
|
tenant_id = _active_tenant_id(request, user)
|
|
branch_id = _active_branch_id(request, user, db)
|
|
partner_id = _locked_partner_id(db, user)
|
|
financial_year = _active_financial_year(request)
|
|
locked_response = redirect_if_financial_year_locked(db, tenant_id=tenant_id, year_code=financial_year, redirect_url=f"/billing/generate?year_locked=1")
|
|
if locked_response:
|
|
return locked_response
|
|
period_from = parse_date(billing_period_from)
|
|
period_to = parse_date(billing_period_to)
|
|
if financial_year and period_from and billing_financial_year(billing_period_from=period_from) != financial_year:
|
|
result = {"created": [], "skipped": [], "errors": [f"Billing period must fall within active FY {financial_year}."], "batch": None}
|
|
elif period_from is None or period_to is None:
|
|
result = {"created": [], "skipped": [], "errors": ["Billing period From and To are required."], "batch": None}
|
|
else:
|
|
result = generate_draft_invoices_from_fee_groups(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
branch_id=branch_id,
|
|
partner_id=partner_id,
|
|
generated_by_user_id=user.id,
|
|
billing_period_from=period_from,
|
|
billing_period_to=period_to,
|
|
frequency=frequency or None,
|
|
fee_group_ids=fee_group_ids,
|
|
skip_duplicates=(skip_duplicates != "no"),
|
|
)
|
|
db.commit()
|
|
rows = list_fee_groups_for_generation(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
branch_id=branch_id,
|
|
partner_id=partner_id,
|
|
frequency=frequency or None,
|
|
auto_generate_only=(auto_generate_only != "no"),
|
|
q=q,
|
|
)
|
|
duplicate_map = {
|
|
row.id: fee_group_already_billed(db, tenant_id=tenant_id, fee_group_id=row.id, period_from=period_from or date.today(), period_to=period_to or date.today())
|
|
for row in rows
|
|
}
|
|
return _render(
|
|
request,
|
|
"modules/billing/templates/billing/generate.html",
|
|
db,
|
|
user,
|
|
title="Generate Draft Invoices",
|
|
rows=rows,
|
|
duplicate_map=duplicate_map,
|
|
frequencies=FREQUENCIES,
|
|
frequency=frequency,
|
|
billing_period_from=(period_from or date.today()).isoformat(),
|
|
billing_period_to=(period_to or date.today()).isoformat(),
|
|
auto_generate_only=auto_generate_only,
|
|
q=q,
|
|
active_financial_year=_active_financial_year(request),
|
|
result=result,
|
|
)
|
|
except ValueError as exc:
|
|
db.rollback()
|
|
rows = []
|
|
result = {"created": [], "skipped": [], "errors": [str(exc)], "batch": None}
|
|
return _render(
|
|
request,
|
|
"modules/billing/templates/billing/generate.html",
|
|
db,
|
|
user,
|
|
title="Generate Draft Invoices",
|
|
rows=rows,
|
|
duplicate_map={},
|
|
frequencies=FREQUENCIES,
|
|
frequency=frequency,
|
|
billing_period_from=billing_period_from,
|
|
billing_period_to=billing_period_to,
|
|
auto_generate_only=auto_generate_only,
|
|
q=q,
|
|
active_financial_year=_active_financial_year(request),
|
|
result=result,
|
|
)
|
|
except Exception as exc:
|
|
db.rollback()
|
|
result = {"created": [], "skipped": [], "errors": [f"Generation failed: {exc}"], "batch": None}
|
|
return _render(
|
|
request,
|
|
"modules/billing/templates/billing/generate.html",
|
|
db,
|
|
user,
|
|
title="Generate Draft Invoices",
|
|
rows=[],
|
|
duplicate_map={},
|
|
frequencies=FREQUENCIES,
|
|
frequency=frequency,
|
|
billing_period_from=billing_period_from,
|
|
billing_period_to=billing_period_to,
|
|
auto_generate_only=auto_generate_only,
|
|
q=q,
|
|
active_financial_year=_active_financial_year(request),
|
|
result=result,
|
|
)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/{invoice_id}/payments/new")
|
|
def invoice_payment_page(request: Request, invoice_id: int):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.view")
|
|
if response:
|
|
return response
|
|
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
|
if not invoice:
|
|
return _redirect_denied()
|
|
if invoice.status in {"DRAFT", "CANCELLED"}:
|
|
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
|
|
can_record = _has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create")
|
|
if not can_record:
|
|
return _redirect_denied()
|
|
return _render(request, "modules/billing/templates/billing/payments/new.html", db, user, title=f"Record Payment - {invoice.invoice_no}", invoice=invoice, today=date.today().isoformat())
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{invoice_id}/payments/new")
|
|
def invoice_payment_submit(
|
|
request: Request,
|
|
invoice_id: int,
|
|
payment_date: str = Form(...),
|
|
amount_received: str = Form("0.00"),
|
|
tds_deducted: str = Form("0.00"),
|
|
bank_charges: str = Form("0.00"),
|
|
mode: str = Form("BANK"),
|
|
reference_no: str | None = Form(None),
|
|
remarks: str | None = Form(None),
|
|
csrf_token: str = Form(...),
|
|
):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.view")
|
|
if response:
|
|
return response
|
|
can_record = _has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create")
|
|
if not can_record:
|
|
return _redirect_denied()
|
|
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
|
if not invoice:
|
|
return _redirect_denied()
|
|
if is_row_financial_year_locked(db, invoice):
|
|
return RedirectResponse(url=f"/billing/{invoice.id}?year_locked=1", status_code=303)
|
|
payment = record_invoice_payment(
|
|
db,
|
|
invoice=invoice,
|
|
payment_date=parse_date(payment_date) or date.today(),
|
|
amount_received=_decimal_form(amount_received, "0.00"),
|
|
tds_deducted=_decimal_form(tds_deducted, "0.00"),
|
|
bank_charges=_decimal_form(bank_charges, "0.00"),
|
|
mode=mode,
|
|
reference_no=reference_no,
|
|
remarks=remarks,
|
|
created_by_user_id=user.id,
|
|
)
|
|
db.commit()
|
|
return RedirectResponse(url=f"/billing/payments/{payment.id}/receipt", status_code=303)
|
|
except ValueError:
|
|
db.rollback()
|
|
return RedirectResponse(url=f"/billing/{invoice_id}", status_code=303)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/{invoice_id}/print")
|
|
def invoice_print(request: Request, invoice_id: int):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.view")
|
|
if response:
|
|
return response
|
|
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
|
if not invoice:
|
|
return _redirect_denied()
|
|
invoice_ctx = build_invoice_print_context(db, invoice)
|
|
return _render(request, "modules/billing/templates/billing/invoice_print.html", db, user, title=f"Print Invoice {invoice.invoice_no}", invoice=invoice, invoice_ctx=invoice_ctx)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.post("/{invoice_id}/issue")
|
|
def invoice_issue_submit(request: Request, invoice_id: int, csrf_token: str = Form(...)):
|
|
validate_csrf(request, csrf_token)
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.create")
|
|
if response:
|
|
return response
|
|
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
|
if not invoice:
|
|
return _redirect_denied()
|
|
if is_row_financial_year_locked(db, invoice):
|
|
return RedirectResponse(url=f"/billing/{invoice.id}?year_locked=1", status_code=303)
|
|
issue_invoice(db, invoice, user_id=user.id)
|
|
db.commit()
|
|
return RedirectResponse(url=f"/billing/{invoice.id}", status_code=303)
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/{invoice_id}")
|
|
def invoice_detail(request: Request, invoice_id: int):
|
|
db = CommonSessionLocal()
|
|
try:
|
|
user, response = _require_billing_user(request, db, "billing.view")
|
|
if response:
|
|
return response
|
|
invoice = get_invoice(db, invoice_id=invoice_id, tenant_id=_active_tenant_id(request, user), partner_id=_locked_partner_id(db, user), financial_year=_active_financial_year(request))
|
|
if not invoice:
|
|
return _redirect_denied()
|
|
invoice_ctx = build_invoice_print_context(db, invoice)
|
|
return _render(request, "modules/billing/templates/billing/detail.html", db, user, title=f"Invoice {invoice.invoice_no}", invoice=invoice, invoice_ctx=invoice_ctx, can_record_payment=_has_perm(db, user, "billing_payment.record") or _has_perm(db, user, "billing.create"))
|
|
finally:
|
|
db.close()
|