Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Billing module for firm-level invoices and fee structure imports."""
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.modules.billing.models import BillingInvoice, BillingInvoiceLine, BillingPayment
|
||||
from app.modules.billing.services import build_invoice_print_context, is_cashfree_ready, is_payumoney_ready, money
|
||||
|
||||
CLIENT_VISIBLE_INVOICE_STATUSES = {"ISSUED", "PARTLY_PAID", "PAID", "OVERDUE"}
|
||||
|
||||
|
||||
def _client_ids(client_row: Any) -> tuple[int, int]:
|
||||
"""Return (tenant_id, client_id) from dict/row/model style client payload."""
|
||||
if isinstance(client_row, dict):
|
||||
return int(client_row.get("tenant_id") or 0), int(client_row.get("id") or 0)
|
||||
return int(getattr(client_row, "tenant_id", 0) or 0), int(getattr(client_row, "id", 0) or 0)
|
||||
|
||||
|
||||
def list_client_portal_invoices(db: Session, client_row: Any, *, q: str = "", include_paid: bool = True, financial_year: str | None = None) -> list[BillingInvoice]:
|
||||
tenant_id, client_id = _client_ids(client_row)
|
||||
stmt = (
|
||||
select(BillingInvoice)
|
||||
.options(
|
||||
selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service),
|
||||
selectinload(BillingInvoice.payments),
|
||||
)
|
||||
.where(
|
||||
BillingInvoice.tenant_id == tenant_id,
|
||||
BillingInvoice.client_id == client_id,
|
||||
BillingInvoice.status.in_(CLIENT_VISIBLE_INVOICE_STATUSES),
|
||||
)
|
||||
)
|
||||
if not include_paid:
|
||||
stmt = stmt.where(BillingInvoice.status != "PAID")
|
||||
if financial_year and financial_year.upper() != "ALL":
|
||||
stmt = stmt.where(BillingInvoice.financial_year == financial_year)
|
||||
if q.strip():
|
||||
term = f"%{q.strip()}%"
|
||||
stmt = stmt.where(or_(BillingInvoice.invoice_no.ilike(term), BillingInvoice.invoice_title.ilike(term)))
|
||||
return db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc())).scalars().unique().all()
|
||||
|
||||
|
||||
def get_client_portal_invoice(db: Session, client_row: Any, invoice_id: int, *, financial_year: str | None = None) -> BillingInvoice | None:
|
||||
tenant_id, client_id = _client_ids(client_row)
|
||||
stmt = (
|
||||
select(BillingInvoice)
|
||||
.options(
|
||||
selectinload(BillingInvoice.client),
|
||||
selectinload(BillingInvoice.lines).selectinload(BillingInvoiceLine.service),
|
||||
selectinload(BillingInvoice.payments),
|
||||
)
|
||||
.where(
|
||||
BillingInvoice.id == invoice_id,
|
||||
BillingInvoice.tenant_id == tenant_id,
|
||||
BillingInvoice.client_id == client_id,
|
||||
BillingInvoice.status.in_(CLIENT_VISIBLE_INVOICE_STATUSES),
|
||||
)
|
||||
)
|
||||
if financial_year and financial_year.upper() != "ALL":
|
||||
stmt = stmt.where(BillingInvoice.financial_year == financial_year)
|
||||
return db.execute(stmt).scalars().unique().one_or_none()
|
||||
|
||||
|
||||
def get_client_portal_payment(db: Session, client_row: Any, payment_id: int, *, financial_year: str | None = None) -> BillingPayment | None:
|
||||
tenant_id, client_id = _client_ids(client_row)
|
||||
stmt = (
|
||||
select(BillingPayment)
|
||||
.options(
|
||||
selectinload(BillingPayment.invoice).selectinload(BillingInvoice.lines),
|
||||
selectinload(BillingPayment.client),
|
||||
)
|
||||
.where(
|
||||
BillingPayment.id == payment_id,
|
||||
BillingPayment.tenant_id == tenant_id,
|
||||
BillingPayment.client_id == client_id,
|
||||
BillingPayment.status == "RECEIVED",
|
||||
)
|
||||
)
|
||||
if financial_year and financial_year.upper() != "ALL":
|
||||
stmt = stmt.where(BillingPayment.financial_year == financial_year)
|
||||
return db.execute(stmt).scalars().unique().one_or_none()
|
||||
|
||||
|
||||
def build_client_billing_summary(db: Session, client_row: Any, *, financial_year: str | None = None) -> dict[str, Any]:
|
||||
invoices = list_client_portal_invoices(db, client_row, include_paid=True, financial_year=financial_year)
|
||||
open_invoices = [row for row in invoices if row.status in {"ISSUED", "PARTLY_PAID", "OVERDUE"} and money(row.balance_amount) > Decimal("0.00")]
|
||||
paid_invoices = [row for row in invoices if row.status == "PAID"]
|
||||
outstanding = sum((money(row.balance_amount) for row in open_invoices), Decimal("0.00"))
|
||||
latest_invoice = invoices[0] if invoices else None
|
||||
latest_due_invoice = open_invoices[0] if open_invoices else None
|
||||
return {
|
||||
"billing_invoices": invoices,
|
||||
"billing_open_invoices": open_invoices,
|
||||
"billing_paid_invoices": paid_invoices,
|
||||
"billing_outstanding_amount": money(outstanding),
|
||||
"billing_latest_invoice": latest_invoice,
|
||||
"billing_latest_due_invoice": latest_due_invoice,
|
||||
"billing_open_count": len(open_invoices),
|
||||
"billing_paid_count": len(paid_invoices),
|
||||
"billing_total_count": len(invoices),
|
||||
}
|
||||
|
||||
|
||||
def build_client_payment_context(db: Session, invoice: BillingInvoice) -> dict[str, Any]:
|
||||
invoice_ctx = build_invoice_print_context(db, invoice)
|
||||
settings = invoice_ctx.get("settings")
|
||||
amount_due = money(invoice.balance_amount)
|
||||
firm_name = invoice_ctx.get("firm_name") or "Audit Firm"
|
||||
upi_id = getattr(settings, "upi_id", None) if settings else None
|
||||
upi_link = None
|
||||
if upi_id and amount_due > Decimal("0.00"):
|
||||
upi_link = (
|
||||
"upi://pay?"
|
||||
f"pa={quote(str(upi_id))}"
|
||||
f"&pn={quote(str(firm_name))}"
|
||||
f"&am={quote(str(amount_due))}"
|
||||
"&cu=INR"
|
||||
f"&tn={quote('Invoice ' + str(invoice.invoice_no))}"
|
||||
)
|
||||
return {
|
||||
"invoice_ctx": invoice_ctx,
|
||||
"amount_due": amount_due,
|
||||
"upi_link": upi_link,
|
||||
"upi_id": upi_id,
|
||||
"bank_name": invoice_ctx.get("bank_name"),
|
||||
"bank_account_name": invoice_ctx.get("bank_account_name"),
|
||||
"bank_account_number": invoice_ctx.get("bank_account_number"),
|
||||
"bank_ifsc": invoice_ctx.get("bank_ifsc"),
|
||||
"payment_instructions": getattr(settings, "bank_details", None) if settings else None,
|
||||
"payumoney_enabled": is_payumoney_ready(settings),
|
||||
"payumoney_mode": getattr(settings, "payumoney_mode", "TEST") if settings else "TEST",
|
||||
"cashfree_enabled": is_cashfree_ready(settings),
|
||||
"cashfree_mode": getattr(settings, "cashfree_mode", "TEST") if settings else "TEST",
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.db.common import CommonBase
|
||||
|
||||
|
||||
class BillingSettings(CommonBase):
|
||||
__tablename__ = "billing_settings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "branch_id", name="uq_billing_settings_tenant_branch"),
|
||||
)
|
||||
|
||||
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)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
invoice_prefix: Mapped[str] = mapped_column(String(40), nullable=False, default="INV")
|
||||
next_invoice_no: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
padding: Mapped[int] = mapped_column(Integer, nullable=False, default=4)
|
||||
default_gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
|
||||
default_tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
|
||||
|
||||
legal_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
pan: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
state_code: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
billing_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
contact_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
contact_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
website_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
invoice_title: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
invoice_number_format: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
default_due_days: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
|
||||
default_sac_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
|
||||
bank_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
bank_account_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
bank_account_number: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
bank_ifsc: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
upi_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
bank_details: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
payumoney_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
payumoney_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
|
||||
payumoney_merchant_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
payumoney_merchant_salt: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
payumoney_merchant_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
payumoney_product_info: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
|
||||
cashfree_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
cashfree_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
|
||||
cashfree_client_id: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
||||
cashfree_client_secret: Mapped[str | None] = mapped_column(String(240), nullable=True)
|
||||
cashfree_api_version: Mapped[str] = mapped_column(String(20), nullable=False, default="2023-08-01")
|
||||
cashfree_order_note: Mapped[str | None] = mapped_column(String(250), nullable=True)
|
||||
|
||||
authorised_signatory_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
declaration: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
footer_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
|
||||
class BillingInvoiceGenerationBatch(CommonBase):
|
||||
__tablename__ = "billing_invoice_generation_batches"
|
||||
|
||||
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)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
billing_period_from: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
billing_period_to: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
frequency: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT_CREATED", index=True)
|
||||
|
||||
selected_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_invoice_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
skipped_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
error_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
generated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
generated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
invoices = relationship("BillingInvoice", back_populates="generation_batch")
|
||||
|
||||
|
||||
class BillingInvoice(CommonBase):
|
||||
__tablename__ = "billing_invoices"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "invoice_no", name="uq_billing_invoices_tenant_invoice_no"),
|
||||
)
|
||||
|
||||
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)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
generation_batch_id: Mapped[int | None] = mapped_column(ForeignKey("billing_invoice_generation_batches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
invoice_no: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
invoice_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
|
||||
billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
|
||||
invoice_title: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
place_of_supply: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
reverse_charge: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
client_legal_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_trade_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
client_gstin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
client_pan: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
client_billing_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
client_state: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
client_state_code: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
client_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
client_mobile: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
|
||||
subtotal: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
round_off: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
total_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
amount_received: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
tds_deducted: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
bank_charges: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
balance_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
amount_in_words: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT", index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
terms: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
approved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
posted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
client = relationship("Client")
|
||||
engagement = relationship("ClientServiceSubscription", foreign_keys=[engagement_id])
|
||||
generation_batch = relationship("BillingInvoiceGenerationBatch", back_populates="invoices")
|
||||
lines = relationship("BillingInvoiceLine", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingInvoiceLine.sort_order.asc()")
|
||||
payments = relationship("BillingPayment", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingPayment.payment_date.desc(), BillingPayment.id.desc()")
|
||||
online_transactions = relationship("BillingOnlinePaymentTransaction", back_populates="invoice", cascade="all, delete-orphan", order_by="BillingOnlinePaymentTransaction.created_at_utc.desc(), BillingOnlinePaymentTransaction.id.desc()")
|
||||
|
||||
|
||||
class BillingPayment(CommonBase):
|
||||
__tablename__ = "billing_payments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "receipt_no", name="uq_billing_payments_tenant_receipt_no"),
|
||||
)
|
||||
|
||||
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)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
|
||||
receipt_no: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
receipt_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
|
||||
payment_date: Mapped[date] = mapped_column(Date, nullable=False, default=date.today, index=True)
|
||||
financial_year: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
amount_received: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
tds_deducted: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
bank_charges: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
mode: Mapped[str] = mapped_column(String(30), nullable=False, default="BANK")
|
||||
reference_no: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
payment_gateway: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
gateway_transaction_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
remarks: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="RECEIVED", index=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
invoice = relationship("BillingInvoice", back_populates="payments")
|
||||
client = relationship("Client")
|
||||
|
||||
|
||||
class BillingOnlinePaymentTransaction(CommonBase):
|
||||
__tablename__ = "billing_online_payment_transactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "txnid", name="uq_billing_online_payment_tenant_txnid"),
|
||||
)
|
||||
|
||||
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)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
|
||||
provider: Mapped[str] = mapped_column(String(40), nullable=False, default="PAYUMONEY", index=True)
|
||||
mode: Mapped[str] = mapped_column(String(20), nullable=False, default="TEST")
|
||||
txnid: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
productinfo: Mapped[str | None] = mapped_column(String(250), nullable=True)
|
||||
firstname: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
|
||||
payu_payment_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
cashfree_order_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
cashfree_cf_order_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
cashfree_payment_session_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
cashfree_payment_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
webhook_event_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
|
||||
bank_ref_num: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
mihpayid: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(30), nullable=False, default="INITIATED", index=True)
|
||||
gateway_status: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
response_hash: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
raw_response: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
receipt_payment_id: Mapped[int | None] = mapped_column(ForeignKey("billing_payments.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
invoice = relationship("BillingInvoice", back_populates="online_transactions")
|
||||
client = relationship("Client")
|
||||
receipt_payment = relationship("BillingPayment", foreign_keys=[receipt_payment_id])
|
||||
|
||||
|
||||
class BillingInvoiceLine(CommonBase):
|
||||
__tablename__ = "billing_invoice_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
invoice_id: Mapped[int] = mapped_column(ForeignKey("billing_invoices.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_id: Mapped[int | None] = mapped_column(ForeignKey("service_catalogues.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
engagement_id: Mapped[int | None] = mapped_column(ForeignKey("client_service_subscriptions.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
fee_group_id: Mapped[int | None] = mapped_column(ForeignKey("billing_fee_groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
sac_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
billing_period_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
billing_period_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
quantity: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("1.00"))
|
||||
rate: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
discount_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
taxable_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
|
||||
cgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
sgst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
igst_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
line_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
invoice = relationship("BillingInvoice", back_populates="lines")
|
||||
service = relationship("ServiceCatalogue")
|
||||
|
||||
|
||||
class BillingFeeGroup(CommonBase):
|
||||
__tablename__ = "billing_fee_groups"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "group_code", name="uq_billing_fee_groups_tenant_code"),
|
||||
)
|
||||
|
||||
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)
|
||||
branch_id: Mapped[int | None] = mapped_column(ForeignKey("branches.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
partner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
group_code: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
group_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
billing_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="PACKAGE")
|
||||
frequency: Mapped[str] = mapped_column(String(20), nullable=False, default="Monthly")
|
||||
fee_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
gst_rate: Mapped[Decimal] = mapped_column(Numeric(5, 2), nullable=False, default=Decimal("18.00"))
|
||||
tax_type: Mapped[str] = mapped_column(String(20), nullable=False, default="CGST_SGST")
|
||||
effective_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
effective_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
auto_generate: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
updated_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||
created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
client = relationship("Client")
|
||||
services = relationship("BillingFeeGroupService", back_populates="fee_group", cascade="all, delete-orphan", order_by="BillingFeeGroupService.sort_order.asc()")
|
||||
|
||||
|
||||
class BillingFeeGroupService(CommonBase):
|
||||
__tablename__ = "billing_fee_group_services"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("fee_group_id", "service_id", name="uq_billing_fee_group_services_group_service"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
fee_group_id: Mapped[int] = mapped_column(ForeignKey("billing_fee_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
service_id: Mapped[int] = mapped_column(ForeignKey("service_catalogues.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||
line_description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
allocation_type: Mapped[str] = mapped_column(String(20), nullable=False, default="Included")
|
||||
line_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False, default=Decimal("0.00"))
|
||||
percentage: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
fee_group = relationship("BillingFeeGroup", back_populates="services")
|
||||
service = relationship("ServiceCatalogue")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900">Invoice {{ invoice.invoice_no }}</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Issued on {{ invoice.invoice_date.strftime('%d-%m-%Y') if invoice.invoice_date else '-' }}{% if invoice.due_date %} • Due {{ invoice.due_date.strftime('%d-%m-%Y') }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/client/billing" class="af-btn af-btn-secondary">Back to Bills</a>
|
||||
<a href="/client/billing/{{ invoice.id }}/print" class="af-btn af-btn-secondary">Print / Save PDF</a>
|
||||
{% if invoice.balance_amount and invoice.balance_amount > 0 %}<a href="/client/billing/{{ invoice.id }}/pay-now" class="af-btn af-btn-primary">Pay Now</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-4">
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Invoice Total</div><div class="mt-2 text-2xl font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Received</div><div class="mt-2 text-2xl font-semibold text-emerald-700">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">TDS</div><div class="mt-2 text-2xl font-semibold text-slate-900">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Balance</div><div class="mt-2 text-2xl font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</div></div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div class="af-card">
|
||||
<div class="flex items-center justify-between gap-3"><h3 class="text-lg font-semibold text-slate-900">Invoice Lines</h3><span class="af-badge {% if invoice.status == 'PAID' %}af-badge-success{% else %}af-badge-warning{% endif %}">{{ invoice.status.replace('_', ' ') }}</span></div>
|
||||
<div class="mt-5 overflow-x-auto rounded-2xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-4 py-3">Description</th><th class="px-4 py-3">SAC</th><th class="px-4 py-3 text-right">Taxable</th><th class="px-4 py-3 text-right">GST</th><th class="px-4 py-3 text-right">Total</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for line in invoice.lines %}
|
||||
<tr><td class="px-4 py-3 font-medium text-slate-900 whitespace-pre-line">{{ line.description }}</td><td class="px-4 py-3 text-slate-600">{{ line.sac_code or '-' }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.taxable_amount or 0) }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format((line.cgst_amount or 0) + (line.sgst_amount or 0) + (line.igst_amount or 0)) }}</td><td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(line.line_total or 0) }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">Payment Status</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between"><span class="text-slate-500">Status</span><span class="font-semibold">{{ invoice.status.replace('_', ' ') }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-500">Due Amount</span><span class="font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</span></div>
|
||||
{% if invoice.balance_amount and invoice.balance_amount > 0 %}<a href="/client/billing/{{ invoice.id }}/pay-now" class="mt-2 w-full justify-center af-btn af-btn-primary">Pay Now</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">Receipts</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
{% for p in invoice.payments %}
|
||||
{% if p.status == 'RECEIVED' %}
|
||||
<a href="/client/billing/receipts/{{ p.id }}" class="block rounded-2xl border border-slate-200 p-3 hover:bg-slate-50"><div class="font-semibold text-brand-700">{{ p.receipt_no }}</div><div class="mt-1 text-xs text-slate-500">{{ p.payment_date.strftime('%d-%m-%Y') if p.payment_date else '-' }} • ₹ {{ '%.2f'|format(p.amount_received or 0) }}</div></a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="rounded-2xl border border-dashed border-slate-300 p-4 text-slate-500">No receipts recorded yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="space-y-6">
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Client Portal</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">My Bills & Payments</h2>
|
||||
<p class="mt-2 max-w-3xl text-sm text-brand-100">View invoices issued by your audit firm, download receipts and use Pay Now for pending bills.</p>
|
||||
<p class="mt-1 text-xs text-brand-100">Active FY: {{ active_financial_year or 'All Years' }}</p>
|
||||
</div>
|
||||
{% if billing_latest_due_invoice %}
|
||||
<a href="/client/billing/{{ billing_latest_due_invoice.id }}/pay-now" class="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-brand-700 hover:bg-brand-50">Pay Latest Due</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-4 md:grid-cols-3">
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Outstanding</div><div class="mt-2 text-3xl font-semibold text-amber-700">₹ {{ '%.2f'|format(billing_outstanding_amount or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ billing_open_count or 0 }} open bill(s)</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Total Invoices</div><div class="mt-2 text-3xl font-semibold text-slate-900">{{ billing_total_count or 0 }}</div><div class="mt-1 text-xs text-slate-500">Issued by firm</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Paid</div><div class="mt-2 text-3xl font-semibold text-emerald-700">{{ billing_paid_count or 0 }}</div><div class="mt-1 text-xs text-slate-500">Completed payments</div></div>
|
||||
</section>
|
||||
|
||||
<div class="af-card">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-900">Invoices</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Draft and cancelled invoices are not shown in the client portal.</p>
|
||||
</div>
|
||||
<form method="get" action="/client/billing" class="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search invoice no" class="rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
<select name="include_paid" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="yes" {% if include_paid != 'no' %}selected{% endif %}>All invoices</option>
|
||||
<option value="no" {% if include_paid == 'no' %}selected{% endif %}>Only pending</option>
|
||||
</select>
|
||||
<button class="af-btn af-btn-secondary" type="submit">Filter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 overflow-x-auto rounded-2xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Invoice</th>
|
||||
<th class="px-4 py-3">Date</th><th class="px-4 py-3">FY</th>
|
||||
<th class="px-4 py-3">Due Date</th>
|
||||
<th class="px-4 py-3 text-right">Total</th>
|
||||
<th class="px-4 py-3 text-right">Balance</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 bg-white">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-semibold text-slate-900"><a class="text-brand-700 hover:underline" href="/client/billing/{{ row.id }}">{{ row.invoice_no }}</a></td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.invoice_date.strftime('%d-%m-%Y') if row.invoice_date else '-' }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.due_date.strftime('%d-%m-%Y') if row.due_date else '-' }}</td>
|
||||
<td class="px-4 py-3 text-right font-medium">₹ {{ '%.2f'|format(row.total_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right font-medium {% if row.balance_amount and row.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(row.balance_amount or 0) }}</td>
|
||||
<td class="px-4 py-3"><span class="af-badge {% if row.status == 'PAID' %}af-badge-success{% elif row.status == 'OVERDUE' %}af-badge-danger{% else %}af-badge-warning{% endif %}">{{ row.status.replace('_', ' ') }}</span></td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% if row.balance_amount and row.balance_amount > 0 %}<a href="/client/billing/{{ row.id }}/pay-now" class="af-btn af-btn-primary">Pay Now</a>{% else %}<a href="/client/billing/{{ row.id }}" class="af-btn af-btn-secondary">View</a>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No invoices found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,94 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<section class="rounded-3xl bg-gradient-to-r from-brand-700 to-slate-900 p-6 text-white shadow-soft">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-brand-100">Pay Now</p>
|
||||
<h2 class="mt-2 text-2xl font-semibold">Invoice {{ invoice.invoice_no }}</h2>
|
||||
<p class="mt-2 text-sm text-brand-100">Pay the outstanding amount using online gateway, UPI or bank transfer. Online gateway receipts are created automatically after successful verification.</p>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div class="af-card">
|
||||
<h3 class="text-lg font-semibold text-slate-900">Payment Options</h3>
|
||||
<div class="mt-5 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div class="font-semibold">Amount payable: ₹ {{ '%.2f'|format(amount_due or 0) }}</div>
|
||||
<div class="mt-1">Invoice balance only is shown here. TDS or bank charges will be adjusted by the firm while recording receipt.</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% if payumoney_enabled %}
|
||||
<div class="mt-5 rounded-2xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-emerald-900">Online Payment Gateway</div>
|
||||
<p class="mt-1 text-sm text-emerald-800">Pay securely through PayUMoney / PayU. Receipt will be created automatically after successful confirmation.</p>
|
||||
{% if payumoney_mode != 'LIVE' %}<p class="mt-1 text-xs font-semibold text-amber-700">Currently running in TEST mode.</p>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/client/billing/{{ invoice.id }}/payumoney/start">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button type="submit" class="af-btn af-btn-primary whitespace-nowrap">Pay Online</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if cashfree_enabled %}
|
||||
<div class="mt-5 rounded-2xl border border-sky-200 bg-sky-50 p-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="text-sm font-semibold text-sky-900">Cashfree Payment Gateway</div>
|
||||
<p class="mt-1 text-sm text-sky-800">Pay securely through Cashfree checkout. Receipt will be created automatically after successful confirmation.</p>
|
||||
{% if cashfree_mode != 'LIVE' %}<p class="mt-1 text-xs font-semibold text-amber-700">Currently running in TEST / Sandbox mode.</p>{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/client/billing/{{ invoice.id }}/cashfree/start">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button type="submit" class="af-btn af-btn-primary whitespace-nowrap">Pay with Cashfree</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if upi_link %}
|
||||
<div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4">
|
||||
<div class="text-sm font-semibold text-brand-800">UPI Payment</div>
|
||||
<div class="mt-2 text-sm text-slate-700">UPI ID: <span class="font-semibold">{{ upi_id }}</span></div>
|
||||
<a href="{{ upi_link }}" class="mt-4 inline-flex af-btn af-btn-primary">Open UPI App</a>
|
||||
<p class="mt-3 text-xs text-slate-500">This opens a UPI app on supported devices. After payment, share the UTR/reference number with the firm if requested.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-5 rounded-2xl border border-slate-200 p-4">
|
||||
<div class="text-sm font-semibold text-slate-900">Bank Transfer</div>
|
||||
<dl class="mt-3 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div><dt class="text-xs uppercase text-slate-500">Bank</dt><dd class="font-medium">{{ bank_name or '-' }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Account Name</dt><dd class="font-medium">{{ bank_account_name or '-' }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Account No.</dt><dd class="font-medium">{{ bank_account_number or '-' }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">IFSC</dt><dd class="font-medium">{{ bank_ifsc or '-' }}</dd></div>
|
||||
</dl>
|
||||
{% if payment_instructions %}<div class="mt-4 whitespace-pre-line rounded-xl bg-slate-50 p-3 text-sm text-slate-700">{{ payment_instructions }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="af-card">
|
||||
<h3 class="text-base font-semibold text-slate-900">Invoice Summary</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex justify-between"><span class="text-slate-500">Invoice Total</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-500">Received</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</span></div>
|
||||
<div class="flex justify-between"><span class="text-slate-500">TDS</span><span class="font-semibold">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</span></div>
|
||||
<div class="border-t border-slate-200 pt-3 flex justify-between"><span class="text-slate-500">Balance</span><span class="font-semibold text-amber-700">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</span></div>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-2">
|
||||
<a href="/client/billing/{{ invoice.id }}" class="af-btn af-btn-secondary justify-center">View Invoice</a>
|
||||
<a href="/client/billing/{{ invoice.id }}/print" class="af-btn af-btn-secondary justify-center">Print / Save PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 text-xs leading-5 text-slate-500 shadow-soft">
|
||||
{% if payumoney_enabled or cashfree_enabled %}Online gateway confirmation is enabled. UPI/bank transfer can still be used when the client prefers manual payment.{% else %}Online gateway is not enabled yet. This page helps the client pay through UPI/bank details and the firm records receipt manually.{% endif %}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
{% include "modules/clients/templates/clients/_client_tabs.html" %}
|
||||
<div class="mx-auto max-w-3xl space-y-6">
|
||||
<section class="af-card p-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] {% if result == 'success' %}text-emerald-700{% else %}text-rose-700{% endif %}">PayUMoney Payment</p>
|
||||
<h1 class="mt-2 text-2xl font-bold text-slate-900">{{ heading }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-600">{{ message }}</p>
|
||||
|
||||
{% if transaction %}
|
||||
<dl class="mt-5 grid gap-3 rounded-2xl bg-slate-50 p-4 text-sm sm:grid-cols-2">
|
||||
<div><dt class="text-xs uppercase text-slate-500">Invoice</dt><dd class="font-semibold">{{ transaction.invoice.invoice_no }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Amount</dt><dd class="font-semibold">₹ {{ '%.2f'|format(transaction.amount or 0) }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Txn ID</dt><dd class="font-mono text-xs font-semibold">{{ transaction.txnid }}</dd></div>
|
||||
<div><dt class="text-xs uppercase text-slate-500">Gateway Status</dt><dd class="font-semibold">{{ transaction.gateway_status or transaction.status }}</dd></div>
|
||||
{% if transaction.bank_ref_num %}<div><dt class="text-xs uppercase text-slate-500">Bank Ref.</dt><dd class="font-semibold">{{ transaction.bank_ref_num }}</dd></div>{% endif %}
|
||||
{% if transaction.mihpayid %}<div><dt class="text-xs uppercase text-slate-500">PayU ID</dt><dd class="font-semibold">{{ transaction.mihpayid }}</dd></div>{% endif %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
{% if transaction %}<a href="/client/billing/{{ transaction.invoice_id }}" class="af-btn af-btn-primary">View Invoice</a>{% endif %}
|
||||
<a href="/client/billing" class="af-btn af-btn-secondary">Back to My Bills</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Create GST Invoice</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Prepare a professional tax invoice with SAC, GST breakup, place of supply and firm billing defaults.</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Invoice will be tagged to active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'Current FY' }}</span></p>
|
||||
</div>
|
||||
<a href="/billing/settings" class="af-btn af-btn-secondary">Billing Settings</a>
|
||||
</div>
|
||||
|
||||
<form method="post" class="space-y-6 af-card">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="af-panel-header">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-900">Invoice Header</h2>
|
||||
<p class="text-xs text-slate-500">Client, date, GST treatment and billing period.</p>
|
||||
</div>
|
||||
<span class="af-badge af-badge-info">{{ settings.invoice_title or 'Tax Invoice' }}</span>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Client</span>
|
||||
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="">Select client</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.client_code }} - {{ client.client_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Invoice Date</span>
|
||||
<input type="date" name="invoice_date" value="{{ today }}" required class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Due Date</span>
|
||||
<input type="date" name="due_date" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Billing Period From</span>
|
||||
<input type="date" name="billing_period_from" value="{{ default_billing_period_from or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Billing Period To</span>
|
||||
<input type="date" name="billing_period_to" value="{{ default_billing_period_to or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Tax Type</span>
|
||||
<select name="tax_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for tax_type in tax_types %}<option value="{{ tax_type }}" {% if settings.default_tax_type == tax_type %}selected{% endif %}>{{ tax_type }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Place of Supply</span>
|
||||
<input name="place_of_supply" placeholder="State / Union Territory" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Client State Code</span>
|
||||
<input name="client_state_code" maxlength="2" placeholder="e.g. 33" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="mt-7 inline-flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="reverse_charge" value="yes" class="rounded border-slate-300" />
|
||||
Reverse charge applicable
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<div class="af-panel-header">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-900">Invoice Lines</h2>
|
||||
<p class="text-xs text-slate-500">SAC defaults to billing settings if left blank. Blank description rows are ignored.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto rounded-xl border border-slate-200">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-3 py-2">Service</th>
|
||||
<th class="px-3 py-2">Description</th>
|
||||
<th class="px-3 py-2">SAC</th>
|
||||
<th class="px-3 py-2">Qty</th>
|
||||
<th class="px-3 py-2">Rate</th>
|
||||
<th class="px-3 py-2">Discount</th>
|
||||
<th class="px-3 py-2">GST %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for i in range(1, 8) %}
|
||||
<tr>
|
||||
<td class="px-3 py-2">
|
||||
<select name="line_service_id" class="w-48 rounded-lg border border-slate-300 px-2 py-1.5">
|
||||
<option value="">No service</option>
|
||||
{% for service in services %}<option value="{{ service.id }}">{{ service.service_code }} - {{ service.service_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="px-3 py-2"><input name="line_description" class="w-80 rounded-lg border border-slate-300 px-2 py-1.5" placeholder="Professional fees / service description" /></td>
|
||||
<td class="px-3 py-2"><input name="line_sac_code" value="{{ settings.default_sac_code or '' }}" class="w-24 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_quantity" value="1" class="w-20 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_rate" value="0" class="w-28 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_discount" value="0" class="w-28 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
<td class="px-3 py-2"><input name="line_gst_rate" value="{{ settings.default_gst_rate or 18 }}" class="w-20 rounded-lg border border-slate-300 px-2 py-1.5" /></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">Declaration / Notes</span><textarea name="notes" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.declaration or '' }}</textarea></label>
|
||||
<label class="block"><span class="text-sm font-medium text-slate-700">Terms & Conditions</span><textarea name="terms" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.terms or '' }}</textarea></label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Cancel</a>
|
||||
<button class="af-btn af-btn-primary">Save Draft Invoice</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">{{ invoice_ctx.invoice_title }} {{ invoice.invoice_no }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ invoice.client_legal_name or (invoice.client.client_name if invoice.client else '') }} • {{ invoice.invoice_date }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if invoice.status == 'DRAFT' %}
|
||||
<form method="post" action="/billing/{{ invoice.id }}/issue">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<button class="af-btn af-btn-primary">Issue Invoice</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if can_record_payment and invoice.status not in ['DRAFT','CANCELLED','PAID'] %}
|
||||
<a href="/billing/{{ invoice.id }}/payments/new" class="af-btn af-btn-primary">Record Payment</a>
|
||||
{% endif %}
|
||||
<a href="/billing/{{ invoice.id }}/print" target="_blank" class="af-btn af-btn-secondary">Print / PDF</a>
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="af-card space-y-6">
|
||||
<div class="grid gap-4 md:grid-cols-4 lg:grid-cols-7">
|
||||
<div><div class="text-xs uppercase text-slate-500">Status</div><div class="font-semibold">{{ invoice.status }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Due Date</div><div class="font-semibold">{{ invoice.due_date or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Place of Supply</div><div class="font-semibold">{{ invoice.place_of_supply or '-' }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Total</div><div class="font-semibold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Amount Received</div><div class="font-semibold text-emerald-700">₹ {{ '%.2f'|format(invoice.amount_received or 0) }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">TDS Deducted</div><div class="font-semibold text-blue-700">₹ {{ '%.2f'|format(invoice.tds_deducted or 0) }}</div></div>
|
||||
<div><div class="text-xs uppercase text-slate-500">Balance</div><div class="font-semibold {% if invoice.balance_amount and invoice.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(invoice.balance_amount or 0) }}</div></div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Supplier</h2>
|
||||
<div class="mt-2 font-semibold text-slate-900">{{ invoice_ctx.firm_name }}</div>
|
||||
<div class="text-sm text-slate-600 whitespace-pre-line">{{ invoice_ctx.firm_address or '-' }}</div>
|
||||
<div class="mt-2 text-sm text-slate-600">GSTIN: {{ invoice_ctx.firm_gstin or '-' }} • PAN: {{ invoice_ctx.firm_pan or '-' }}</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 p-4">
|
||||
<h2 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Bill To</h2>
|
||||
<div class="mt-2 font-semibold text-slate-900">{{ invoice.client_legal_name or '-' }}</div>
|
||||
<div class="text-sm text-slate-600">{{ invoice.client_billing_address or '-' }}</div>
|
||||
<div class="mt-2 text-sm text-slate-600">GSTIN: {{ invoice.client_gstin or '-' }} • PAN: {{ invoice.client_pan or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
|
||||
<tr><th class="px-4 py-3">Description</th><th class="px-4 py-3">SAC</th><th class="px-4 py-3 text-right">Qty</th><th class="px-4 py-3 text-right">Rate</th><th class="px-4 py-3 text-right">Taxable</th><th class="px-4 py-3 text-right">GST</th><th class="px-4 py-3 text-right">Total</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for line in invoice.lines %}
|
||||
<tr>
|
||||
<td class="px-4 py-3"><div class="font-medium text-slate-900">{{ line.description }}</div><div class="text-xs text-slate-500">{{ line.service.service_name if line.service else '' }}</div></td>
|
||||
<td class="px-4 py-3">{{ line.sac_code or '-' }}</td>
|
||||
<td class="px-4 py-3 text-right">{{ line.quantity }}</td>
|
||||
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.rate or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(line.taxable_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right">{{ line.gst_rate }}%</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(line.line_total or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot class="bg-slate-50 text-sm font-semibold">
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">Subtotal</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.subtotal or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">Discount</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.discount_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">Taxable Value</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.taxable_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">CGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.cgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">SGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.sgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right">IGST</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(invoice.igst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="6" class="px-4 py-3 text-right text-base">Grand Total</td><td class="px-4 py-3 text-right text-base">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="af-card space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-slate-900">Payment History</h2>
|
||||
<p class="text-sm text-slate-500">Receipts, TDS deductions and outstanding balance for this invoice.</p>
|
||||
</div>
|
||||
{% if can_record_payment and invoice.status not in ['DRAFT','CANCELLED','PAID'] %}
|
||||
<a href="/billing/{{ invoice.id }}/payments/new" class="af-btn af-btn-primary">Record Payment</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500">
|
||||
<tr><th class="px-4 py-3">Receipt</th><th class="px-4 py-3">Date</th><th class="px-4 py-3">Mode</th><th class="px-4 py-3">Reference</th><th class="px-4 py-3 text-right">Received</th><th class="px-4 py-3 text-right">TDS</th><th class="px-4 py-3"></th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for payment in invoice.payments %}
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ payment.receipt_no }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ payment.payment_date }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ payment.mode }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ payment.reference_no or '-' }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(payment.amount_received or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(payment.tds_deducted or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right"><a href="/billing/payments/{{ payment.id }}/receipt" target="_blank" class="text-brand-600 hover:underline">Receipt</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-6 text-center text-slate-500">No payments recorded yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="af-card"><h2 class="font-semibold text-slate-900">Amount in Words</h2><p class="mt-2 text-sm text-slate-600">{{ invoice.amount_in_words or '-' }}</p></div>
|
||||
<div class="af-card"><h2 class="font-semibold text-slate-900">Bank / UPI Details</h2><p class="mt-2 text-sm text-slate-600 whitespace-pre-line">{% if invoice_ctx.bank_name %}{{ invoice_ctx.bank_name }}{% endif %}{% if invoice_ctx.bank_account_number %}\nA/c: {{ invoice_ctx.bank_account_number }}{% endif %}{% if invoice_ctx.bank_ifsc %}\nIFSC: {{ invoice_ctx.bank_ifsc }}{% endif %}{% if invoice_ctx.upi_id %}\nUPI: {{ invoice_ctx.upi_id }}{% endif %}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Import Fee Structure</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Upload Excel with Fee_Structure and Fee_Services sheets.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Excel Template</a>
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure List</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if result %}
|
||||
<div class="rounded-2xl border {{ 'border-emerald-200 bg-emerald-50 text-emerald-900' if result.success else 'border-red-200 bg-red-50 text-red-900' }} p-4">
|
||||
{% if result.success %}
|
||||
<div class="font-semibold">Import completed</div>
|
||||
<div class="mt-1 text-sm">Created: {{ result.created }} | Updated: {{ result.updated }}</div>
|
||||
{% else %}
|
||||
<div class="font-semibold">Import failed</div>
|
||||
<ul class="mt-2 list-disc pl-5 text-sm">
|
||||
{% for error in result.errors %}<li>{{ error }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium text-slate-700">Excel File</span>
|
||||
<input type="file" name="import_file" accept=".xlsx,.xlsm" required class="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<div class="rounded-xl bg-slate-50 p-4 text-sm text-slate-600">
|
||||
<div class="font-semibold text-slate-800">How to import using template</div>
|
||||
<ol class="mt-2 list-decimal space-y-1 pl-5">
|
||||
<li>Click <strong>Download Excel Template</strong>.</li>
|
||||
<li>Fill <strong>Fee_Structure</strong> for client-wise package/header details.</li>
|
||||
<li>Fill <strong>Fee_Services</strong> for services included in each package.</li>
|
||||
<li>Upload the completed file here. Imported fee structures can then be used in <strong>Generate Bills</strong>.</li>
|
||||
</ol>
|
||||
<div class="mt-4 font-semibold text-slate-800">Required sheets</div>
|
||||
<div class="mt-1">Fee_Structure: client, billing group, mode, frequency, fee and tax details.</div>
|
||||
<div>Fee_Services: services included in each billing group.</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700">Back</a>
|
||||
<button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700">Import Fee Structure</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,63 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Fee Structure</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Client-wise billing packages with multiple services grouped for future invoice generation.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/billing/generate" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Generate Bills</a>
|
||||
<a href="/billing" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Invoices</a>
|
||||
{% if can_import %}
|
||||
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Template</a>
|
||||
<a href="/billing/fee-structures/import" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Import Using Template</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="flex gap-3">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search group, client code or client name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Group Code</th>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3">Package</th>
|
||||
<th class="px-4 py-3">Mode</th>
|
||||
<th class="px-4 py-3">Frequency</th>
|
||||
<th class="px-4 py-3 text-right">Fee</th>
|
||||
<th class="px-4 py-3">Services</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="align-top hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ row.group_code }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.group_name }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ row.billing_mode }}</span></td>
|
||||
<td class="px-4 py-3">{{ row.frequency }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.fee_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
{% for item in row.services %}
|
||||
<div>{{ item.service.service_code if item.service else item.service_id }} - {{ item.line_description or (item.service.service_name if item.service else '') }}</div>
|
||||
{% else %}
|
||||
<span class="text-slate-400">No services mapped</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">No fee structures found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,182 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Generate Draft Invoices</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Create draft GST invoices from fee structures and automatically link matching client service subscriptions / engagements for the selected financial year. Existing invoices for the same fee group and period are skipped by default.</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="/billing" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Invoices</a>
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if result %}
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-soft">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Generation Result</h2>
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-xl bg-emerald-50 p-3 text-sm text-emerald-800"><div class="text-xs uppercase tracking-wide">Draft invoices created</div><div class="mt-1 text-2xl font-bold">{{ result.created|length }}</div></div>
|
||||
<div class="rounded-xl bg-amber-50 p-3 text-sm text-amber-800"><div class="text-xs uppercase tracking-wide">Skipped</div><div class="mt-1 text-2xl font-bold">{{ result.skipped|length }}</div></div>
|
||||
<div class="rounded-xl bg-rose-50 p-3 text-sm text-rose-800"><div class="text-xs uppercase tracking-wide">Errors</div><div class="mt-1 text-2xl font-bold">{{ result.errors|length }}</div></div>
|
||||
</div>
|
||||
|
||||
{% if result.created %}
|
||||
<div class="mt-4">
|
||||
<div class="text-sm font-semibold text-slate-700">Created Draft Invoices</div>
|
||||
<div class="mt-2 overflow-hidden rounded-xl border border-slate-200">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500"><tr><th class="px-3 py-2">Invoice</th><th class="px-3 py-2">Client</th><th class="px-3 py-2 text-right">Amount</th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for invoice in result.created %}
|
||||
<tr>
|
||||
<td class="px-3 py-2"><a href="/billing/{{ invoice.id }}" class="font-semibold text-brand-700 hover:underline">{{ invoice.invoice_no }}</a></td>
|
||||
<td class="px-3 py-2">{{ invoice.client.client_name if invoice.client else invoice.client_id }}</td>
|
||||
<td class="px-3 py-2 text-right">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.skipped %}
|
||||
<div class="mt-4 rounded-xl bg-amber-50 p-3 text-sm text-amber-800">
|
||||
<div class="font-semibold">Skipped rows</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
{% for item in result.skipped %}<li>{{ item }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.errors %}
|
||||
<div class="mt-4 rounded-xl bg-rose-50 p-3 text-sm text-rose-800">
|
||||
<div class="font-semibold">Errors</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
{% for item in result.errors %}<li>{{ item }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900">
|
||||
<div class="font-semibold">Engagement-to-invoice refinement</div>
|
||||
<div class="mt-1">This screen continues to use your existing fee-structure billing logic. During generation, the system checks the client, service and active financial year ({{ active_financial_year or 'current FY' }}) and links the invoice / invoice lines to the matching client service subscription wherever available. No duplicate module is created.</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="grid gap-3 lg:grid-cols-6">
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Frequency</label>
|
||||
<select name="frequency" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none">
|
||||
<option value="">All</option>
|
||||
{% for f in frequencies %}<option value="{{ f }}" {% if frequency == f %}selected{% endif %}>{{ f }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Period From</label>
|
||||
<input type="date" name="billing_period_from" value="{{ billing_period_from }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Period To</label>
|
||||
<input type="date" name="billing_period_to" value="{{ billing_period_to }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Auto Generate</label>
|
||||
<select name="auto_generate_only" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none">
|
||||
<option value="yes" {% if auto_generate_only != 'no' %}selected{% endif %}>Only Yes</option>
|
||||
<option value="no" {% if auto_generate_only == 'no' %}selected{% endif %}>All Active</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-slate-500">Search</label>
|
||||
<div class="mt-1 flex gap-2">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Client / group code / package" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Filter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" class="rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<input type="hidden" name="frequency" value="{{ frequency or '' }}" />
|
||||
<input type="hidden" name="billing_period_from" value="{{ billing_period_from }}" />
|
||||
<input type="hidden" name="billing_period_to" value="{{ billing_period_to }}" />
|
||||
<input type="hidden" name="auto_generate_only" value="{{ auto_generate_only }}" />
|
||||
<input type="hidden" name="q" value="{{ q or '' }}" />
|
||||
|
||||
<div class="flex flex-col gap-3 border-b border-slate-200 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div class="font-semibold text-slate-900">Eligible Fee Structures</div>
|
||||
<div class="text-sm text-slate-500">Select packages and create draft invoices for {{ billing_period_from }} to {{ billing_period_to }}.</div>
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox" name="skip_duplicates" value="yes" checked class="rounded border-slate-300 text-brand-600" />
|
||||
Skip duplicates
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3"><input type="checkbox" onclick="document.querySelectorAll('.fee-check').forEach(cb => cb.checked = this.checked && !cb.disabled)" /></th>
|
||||
<th class="px-4 py-3">Group Code</th>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3">Package</th>
|
||||
<th class="px-4 py-3">Services / Engagement Source</th>
|
||||
<th class="px-4 py-3">Mode</th>
|
||||
<th class="px-4 py-3 text-right">Fee</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
{% set duplicate = duplicate_map.get(row.id) %}
|
||||
<tr class="align-top hover:bg-slate-50">
|
||||
<td class="px-4 py-3"><input class="fee-check rounded border-slate-300 text-brand-600" type="checkbox" name="fee_group_ids" value="{{ row.id }}" {% if duplicate %}disabled{% endif %} /></td>
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ row.group_code }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">
|
||||
<div class="font-medium text-slate-900">{{ row.group_name }}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{{ row.frequency }} billing</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600">
|
||||
{% if row.services %}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{% for item in row.services[:4] %}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">{{ item.service.service_name if item.service else item.service_id }}</span>
|
||||
{% endfor %}
|
||||
{% if row.services|length > 4 %}<span class="rounded-full bg-slate-100 px-2 py-1">+{{ row.services|length - 4 }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-slate-400">Matching active subscriptions are linked during generation.</div>
|
||||
{% else %}
|
||||
<span class="text-slate-400">Package line only</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs">{{ row.billing_mode }}</span></td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.fee_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
{% if duplicate %}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-1 font-medium text-amber-800">Already billed: {{ duplicate.invoice_no }}</span>
|
||||
{% else %}
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-1 font-medium text-emerald-800">Ready</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">No eligible fee structures found for the selected filter.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end border-t border-slate-200 p-4">
|
||||
<button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">Generate Draft Invoices</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ invoice_ctx.invoice_title }} {{ invoice.invoice_no }}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@page { size: A4; margin: 14mm; }
|
||||
@media print { .no-print { display: none !important; } body { background: white !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-100 text-slate-900">
|
||||
<div class="no-print mx-auto my-4 flex max-w-5xl justify-end gap-2">
|
||||
<button onclick="window.print()" class="rounded-lg bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Print / Save PDF</button>
|
||||
<a href="/billing/{{ invoice.id }}" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back</a>
|
||||
</div>
|
||||
<main class="mx-auto max-w-5xl bg-white p-8 shadow print:shadow-none">
|
||||
<header class="border-b-2 border-slate-900 pb-4">
|
||||
<div class="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<div class="text-2xl font-bold">{{ invoice_ctx.firm_name }}</div>
|
||||
<div class="mt-1 whitespace-pre-line text-sm text-slate-600">{{ invoice_ctx.firm_address or '' }}</div>
|
||||
<div class="mt-2 text-sm text-slate-700">GSTIN: <b>{{ invoice_ctx.firm_gstin or '-' }}</b> | PAN: <b>{{ invoice_ctx.firm_pan or '-' }}</b></div>
|
||||
<div class="text-sm text-slate-700">Email: {{ invoice_ctx.firm_contact_email or '-' }} | Mobile: {{ invoice_ctx.firm_contact_mobile or '-' }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold uppercase">{{ invoice_ctx.invoice_title }}</div>
|
||||
<div class="mt-2 text-sm">Invoice No: <b>{{ invoice.invoice_no }}</b></div>
|
||||
<div class="text-sm">Invoice Date: <b>{{ invoice.invoice_date }}</b></div>
|
||||
<div class="text-sm">Due Date: <b>{{ invoice.due_date or '-' }}</b></div>
|
||||
<div class="text-sm">Status: <b>{{ invoice.status }}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="mt-5 grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Bill To</div>
|
||||
<div class="mt-2 text-base font-bold">{{ invoice.client_legal_name or '-' }}</div>
|
||||
<div class="mt-1 text-slate-700">{{ invoice.client_billing_address or '-' }}</div>
|
||||
<div class="mt-2">GSTIN: <b>{{ invoice.client_gstin or '-' }}</b></div>
|
||||
<div>PAN: <b>{{ invoice.client_pan or '-' }}</b></div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">Tax Particulars</div>
|
||||
<div class="mt-2">Place of Supply: <b>{{ invoice.place_of_supply or '-' }}</b></div>
|
||||
<div>Tax Type: <b>{{ invoice.tax_type }}</b></div>
|
||||
<div>Reverse Charge: <b>{{ 'Yes' if invoice.reverse_charge else 'No' }}</b></div>
|
||||
<div>Client State Code: <b>{{ invoice.client_state_code or '-' }}</b></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<table class="mt-5 w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="bg-slate-100">
|
||||
<th class="border border-slate-300 px-2 py-2 text-left">#</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-left">Description</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-left">SAC</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Qty</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Rate</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Taxable</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">GST %</th>
|
||||
<th class="border border-slate-300 px-2 py-2 text-right">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for line in invoice.lines %}
|
||||
<tr>
|
||||
<td class="border border-slate-300 px-2 py-2">{{ loop.index }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2">{{ line.description }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2">{{ line.sac_code or '-' }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ line.quantity }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.rate or 0) }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.taxable_amount or 0) }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ line.gst_rate }}</td>
|
||||
<td class="border border-slate-300 px-2 py-2 text-right">{{ '%.2f'|format(line.line_total or 0) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Subtotal</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.subtotal or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Discount</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.discount_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">Taxable Value</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.taxable_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">CGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.cgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">SGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.sgst_amount or 0) }}</td></tr>
|
||||
<tr><td colspan="7" class="border border-slate-300 px-2 py-2 text-right font-semibold">IGST</td><td class="border border-slate-300 px-2 py-2 text-right font-semibold">{{ '%.2f'|format(invoice.igst_amount or 0) }}</td></tr>
|
||||
<tr class="bg-slate-100"><td colspan="7" class="border border-slate-300 px-2 py-2 text-right text-base font-bold">Grand Total</td><td class="border border-slate-300 px-2 py-2 text-right text-base font-bold">₹ {{ '%.2f'|format(invoice.total_amount or 0) }}</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<section class="mt-5 grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="font-semibold">Amount in Words</div>
|
||||
<div class="mt-1">{{ invoice.amount_in_words or '-' }}</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-slate-300 p-4">
|
||||
<div class="font-semibold">Payment Details</div>
|
||||
<div class="mt-1">Bank: {{ invoice_ctx.bank_name or '-' }}</div>
|
||||
<div>A/c: {{ invoice_ctx.bank_account_number or '-' }}</div>
|
||||
<div>IFSC: {{ invoice_ctx.bank_ifsc or '-' }}</div>
|
||||
<div>UPI: {{ invoice_ctx.upi_id or '-' }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-5 text-sm">
|
||||
{% if invoice_ctx.terms %}<div><b>Terms:</b> {{ invoice_ctx.terms }}</div>{% endif %}
|
||||
{% if invoice_ctx.declaration %}<div class="mt-2"><b>Declaration:</b> {{ invoice_ctx.declaration }}</div>{% endif %}
|
||||
</section>
|
||||
|
||||
<footer class="mt-12 flex items-end justify-between text-sm">
|
||||
<div>{{ invoice_ctx.footer_note or '' }}</div>
|
||||
<div class="text-center">
|
||||
<div class="mb-10">For {{ invoice_ctx.firm_name }}</div>
|
||||
<div class="border-t border-slate-500 px-8 pt-2">{{ invoice_ctx.authorised_signatory_name or 'Authorised Signatory' }}</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Billing Invoices</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Create, issue and print GST-ready client invoices with SAC and tax breakup.</p>
|
||||
<p class="mt-1 text-xs text-slate-400">Showing billing records for active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'All Years' }}</span></p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% if can_generate %}
|
||||
<a href="/billing/generate" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Generate Bills</a>
|
||||
{% endif %}
|
||||
{% if can_view_fee_structure %}
|
||||
<a href="/billing/fee-structures/list" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Fee Structure</a>
|
||||
{% endif %}
|
||||
{% if can_import_fee_structure %}
|
||||
<a href="/billing/fee-structures/template" class="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-800 shadow-sm hover:bg-emerald-100">Download Fee Template</a>
|
||||
<a href="/billing/fee-structures/import" class="rounded-xl bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-emerald-700">Import Fee Excel</a>
|
||||
{% endif %}
|
||||
<a href="/billing/payments" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Payments</a>
|
||||
<a href="/billing/settings" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50">Billing Settings</a>
|
||||
{% if can_create %}
|
||||
<a href="/billing/new" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-soft hover:bg-brand-700">New Invoice</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="rounded-2xl border border-slate-200 bg-white p-4 shadow-soft">
|
||||
<div class="flex gap-3">
|
||||
<input name="q" value="{{ q or '' }}" placeholder="Search invoice no, client code or client name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none" />
|
||||
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
{% if report_summary %}
|
||||
<section class="grid gap-4 md:grid-cols-4">
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Billed</div><div class="mt-2 text-2xl font-semibold text-slate-900">₹ {{ '%.2f'|format(report_summary.total_billed or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ report_summary.invoice_count }} invoice(s)</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Collected + TDS</div><div class="mt-2 text-2xl font-semibold text-emerald-700">₹ {{ '%.2f'|format(report_summary.total_collected_with_tds or 0) }}</div><div class="mt-1 text-xs text-slate-500">{{ report_summary.payment_count }} receipt(s)</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Outstanding</div><div class="mt-2 text-2xl font-semibold text-amber-700">₹ {{ '%.2f'|format(report_summary.outstanding or 0) }}</div><div class="mt-1 text-xs text-slate-500">Active issued bills</div></div>
|
||||
<div class="af-metric-card"><div class="text-xs font-semibold uppercase text-slate-500">Status</div><div class="mt-2 text-sm font-semibold text-slate-800">Draft {{ report_summary.draft_count }} · Open {{ report_summary.issued_count }} · Paid {{ report_summary.paid_count }}</div><div class="mt-1 text-xs text-slate-500">FY-filtered billing report</div></div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Invoice No</th>
|
||||
<th class="px-4 py-3">Date</th>
|
||||
<th class="px-4 py-3">FY</th>
|
||||
<th class="px-4 py-3">Client</th>
|
||||
<th class="px-4 py-3 text-right">Amount</th>
|
||||
<th class="px-4 py-3 text-right">Received/TDS</th>
|
||||
<th class="px-4 py-3 text-right">Balance</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-4 py-3 font-medium text-slate-900">{{ row.invoice_no }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ row.invoice_date }}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-500">{{ row.financial_year or '-' }}</td>
|
||||
<td class="px-4 py-3 text-slate-700">{{ row.client.client_name if row.client else row.client_id }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.total_amount or 0) }}</td>
|
||||
<td class="px-4 py-3 text-right text-slate-700">₹ {{ '%.2f'|format((row.amount_received or 0) + (row.tds_deducted or 0)) }}</td>
|
||||
<td class="px-4 py-3 text-right font-semibold {% if row.balance_amount and row.balance_amount > 0 %}text-amber-700{% else %}text-emerald-700{% endif %}">₹ {{ '%.2f'|format(row.balance_amount or row.total_amount or 0) }}</td>
|
||||
<td class="px-4 py-3"><span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium text-slate-700">{{ row.status }}</span></td>
|
||||
<td class="px-4 py-3 text-right"><div class="flex justify-end gap-3"><a href="/billing/{{ row.id }}" class="text-brand-600 hover:underline">View</a>{% if can_record_payment and row.status not in ['DRAFT','CANCELLED','PAID'] %}<a href="/billing/{{ row.id }}/payments/new" class="text-emerald-700 hover:underline">Payment</a>{% endif %}<a href="/billing/{{ row.id }}/print" target="_blank" class="text-slate-600 hover:underline">Print</a></div></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="px-4 py-8 text-center text-slate-500">No invoices found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><h1 class="text-2xl font-semibold text-slate-900">Payments & Receipts</h1><p class="mt-1 text-sm text-slate-500">Track invoice collections, TDS deductions and receipt printouts.</p><p class="mt-1 text-xs text-slate-400">Showing receipts for active FY: <span class="font-semibold text-slate-600">{{ active_financial_year or 'All Years' }}</span></p></div>
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Invoices</a>
|
||||
</div>
|
||||
<form method="get" class="af-card"><div class="flex gap-3"><input name="q" value="{{ q or '' }}" placeholder="Search receipt, invoice or client" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /><button class="af-btn af-btn-primary">Search</button></div></form>
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-soft">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase text-slate-500"><tr><th class="px-4 py-3">Receipt</th><th class="px-4 py-3">Invoice</th><th class="px-4 py-3">Client</th><th class="px-4 py-3">Date</th><th class="px-4 py-3">FY</th><th class="px-4 py-3">Mode</th><th class="px-4 py-3 text-right">Received</th><th class="px-4 py-3 text-right">TDS</th><th class="px-4 py-3"></th></tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
{% for row in rows %}
|
||||
<tr><td class="px-4 py-3 font-medium">{{ row.receipt_no }}</td><td class="px-4 py-3">{{ row.invoice.invoice_no if row.invoice else row.invoice_id }}</td><td class="px-4 py-3">{{ row.client.client_name if row.client else row.client_id }}</td><td class="px-4 py-3">{{ row.payment_date }}</td><td class="px-4 py-3 text-xs text-slate-500">{{ row.financial_year or '-' }}</td><td class="px-4 py-3">{{ row.mode }}</td><td class="px-4 py-3 text-right font-semibold">₹ {{ '%.2f'|format(row.amount_received or 0) }}</td><td class="px-4 py-3 text-right">₹ {{ '%.2f'|format(row.tds_deducted or 0) }}</td><td class="px-4 py-3 text-right"><a href="/billing/payments/{{ row.id }}/receipt" target="_blank" class="text-brand-600 hover:underline">Receipt</a></td></tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" class="px-4 py-8 text-center text-slate-500">No payments recorded.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">Record Payment</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Invoice {{ invoice.invoice_no }} • Balance ₹ {{ '%.2f'|format(invoice.balance_amount or invoice.total_amount or 0) }}</p>
|
||||
</div>
|
||||
<form method="post" class="af-card space-y-5">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div><label class="text-sm font-medium text-slate-700">Payment date</label><input type="date" name="payment_date" value="{{ today }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" required /></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Amount received</label><input type="number" step="0.01" name="amount_received" value="{{ '%.2f'|format(invoice.balance_amount or invoice.total_amount or 0) }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">TDS deducted</label><input type="number" step="0.01" name="tds_deducted" value="0.00" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<div><label class="text-sm font-medium text-slate-700">Bank charges</label><input type="number" step="0.01" name="bank_charges" value="0.00" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" /></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Mode</label><select name="mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{% for mode in payment_modes %}<option value="{{ mode }}">{{ mode }}</option>{% endfor %}</select></div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Reference no.</label><input name="reference_no" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="UTR / cheque / transaction id" /></div>
|
||||
</div>
|
||||
<div><label class="text-sm font-medium text-slate-700">Remarks</label><textarea name="remarks" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm"></textarea></div>
|
||||
<div class="flex justify-end gap-2"><a href="/billing/{{ invoice.id }}" class="af-btn af-btn-secondary">Cancel</a><button class="af-btn af-btn-primary">Save & Print Receipt</button></div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="mx-auto max-w-4xl bg-white p-8 print:p-0">
|
||||
<div class="mb-4 flex justify-end print:hidden"><button onclick="window.print()" class="af-btn af-btn-primary">Print Receipt</button></div>
|
||||
<div class="rounded-2xl border border-slate-300 p-8">
|
||||
<div class="flex items-start justify-between border-b border-slate-200 pb-5">
|
||||
<div><h1 class="text-2xl font-bold text-slate-900">{{ invoice_ctx.firm_name }}</h1><p class="mt-1 whitespace-pre-line text-sm text-slate-600">{{ invoice_ctx.firm_address or '' }}</p><p class="mt-1 text-sm text-slate-600">GSTIN: {{ invoice_ctx.firm_gstin or '-' }} • PAN: {{ invoice_ctx.firm_pan or '-' }}</p></div>
|
||||
<div class="text-right"><div class="text-xl font-bold text-slate-900">Receipt</div><div class="mt-1 text-sm text-slate-600">{{ payment.receipt_no }}</div><div class="text-sm text-slate-600">{{ payment.receipt_date }}</div></div>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div><div class="text-xs font-semibold uppercase text-slate-500">Received From</div><div class="mt-1 font-semibold text-slate-900">{{ payment.client.client_name if payment.client else invoice.client_legal_name }}</div><div class="text-sm text-slate-600">Invoice: {{ invoice.invoice_no }}</div></div>
|
||||
<div class="rounded-xl bg-slate-50 p-4"><div class="grid gap-2 text-sm"><div class="flex justify-between"><span>Amount Received</span><strong>₹ {{ '%.2f'|format(payment.amount_received or 0) }}</strong></div><div class="flex justify-between"><span>TDS Deducted</span><strong>₹ {{ '%.2f'|format(payment.tds_deducted or 0) }}</strong></div><div class="flex justify-between"><span>Bank Charges</span><strong>₹ {{ '%.2f'|format(payment.bank_charges or 0) }}</strong></div></div></div>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-3 text-sm"><div><span class="text-slate-500">Mode</span><div class="font-semibold">{{ payment.mode }}</div></div><div><span class="text-slate-500">Payment Date</span><div class="font-semibold">{{ payment.payment_date }}</div></div><div><span class="text-slate-500">Reference</span><div class="font-semibold">{{ payment.reference_no or '-' }}</div></div></div>
|
||||
{% if payment.remarks %}<div class="mt-6 rounded-xl border border-slate-200 p-4 text-sm text-slate-600">{{ payment.remarks }}</div>{% endif %}
|
||||
<div class="mt-10 flex justify-end"><div class="text-center"><div class="h-12"></div><div class="border-t border-slate-400 px-8 pt-2 text-sm font-semibold">Authorised Signatory</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,244 @@
|
||||
{% extends "ui/templates/base/layout.html" %}
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-brand-600">Phase 7R.1</p>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Firm Billing Settings</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Configure firm GST, invoice numbering, payment details and invoice footer defaults.</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm shadow-sm">
|
||||
<div class="font-semibold text-slate-900">{{ tenant_name }}</div>
|
||||
<div class="text-xs text-slate-500">{% if branch_name %}Branch: {{ branch_name }}{% else %}Firm-wide default{% endif %}</div>
|
||||
<div class="mt-2 text-xs text-slate-500">Next invoice preview</div>
|
||||
<div class="font-mono text-sm font-semibold text-brand-700">{{ preview_invoice_no }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<form method="post" action="/billing/settings" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
|
||||
|
||||
<section class="af-card p-5">
|
||||
<div class="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-slate-900">Scope</h2>
|
||||
<p class="text-sm text-slate-500">Keep branch-specific settings for branch-wise invoice series, or use firm-wide default if you are working across branches.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="rounded-2xl border border-slate-200 p-4 text-sm">
|
||||
<input type="radio" name="branch_scope" value="active" class="mr-2" {% if branch_scope != 'firm' %}checked{% endif %} />
|
||||
Active branch settings
|
||||
<div class="mt-1 text-xs text-slate-500">Recommended for branch-wise invoice numbering.</div>
|
||||
</label>
|
||||
<label class="rounded-2xl border border-slate-200 p-4 text-sm">
|
||||
<input type="radio" name="branch_scope" value="firm" class="mr-2" {% if branch_scope == 'firm' %}checked{% endif %} />
|
||||
Firm-wide default
|
||||
<div class="mt-1 text-xs text-slate-500">Available when cross-branch billing permission is active.</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Firm GST & Contact Details</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="text-sm font-medium text-slate-700">Legal / Billing Name
|
||||
<input name="legal_name" value="{{ settings.legal_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">GSTIN
|
||||
<input name="gstin" value="{{ settings.gstin or '' }}" maxlength="15" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">PAN
|
||||
<input name="pan" value="{{ settings.pan or '' }}" maxlength="10" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">State Code
|
||||
<input name="state_code" value="{{ settings.state_code or '' }}" maxlength="2" placeholder="33" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Contact Email
|
||||
<input name="contact_email" value="{{ settings.contact_email or '' }}" type="email" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Contact Mobile
|
||||
<input name="contact_mobile" value="{{ settings.contact_mobile or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Website
|
||||
<input name="website_url" value="{{ settings.website_url or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Billing Address
|
||||
<textarea name="billing_address" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.billing_address or '' }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Invoice Numbering & Tax Defaults</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<label class="text-sm font-medium text-slate-700">Invoice Title
|
||||
<input name="invoice_title" value="{{ settings.invoice_title or 'Tax Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Prefix
|
||||
<input name="invoice_prefix" value="{{ settings.invoice_prefix or 'INV' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Next Number
|
||||
<input name="next_invoice_no" value="{{ settings.next_invoice_no or 1 }}" type="number" min="1" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Padding
|
||||
<input name="padding" value="{{ settings.padding or 4 }}" type="number" min="1" max="10" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Number Format
|
||||
<input name="invoice_number_format" value="{{ settings.invoice_number_format or '{prefix}/{fy}/{number}' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 font-mono text-sm" />
|
||||
<span class="mt-1 block text-xs text-slate-500">Tokens: {prefix}, {fy}, {number}, {branch_id}</span>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default Due Days
|
||||
<input name="default_due_days" value="{{ settings.default_due_days or 15 }}" type="number" min="0" max="365" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default GST Rate %
|
||||
<input name="default_gst_rate" value="{{ settings.default_gst_rate or '18.00' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default Tax Type
|
||||
<select name="default_tax_type" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
{% for tax in tax_types %}<option value="{{ tax }}" {% if settings.default_tax_type == tax %}selected{% endif %}>{{ tax }}</option>{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Default SAC Code
|
||||
<input name="default_sac_code" value="{{ settings.default_sac_code or '' }}" placeholder="9982" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Bank, UPI & Payment Details</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="text-sm font-medium text-slate-700">Bank Name
|
||||
<input name="bank_name" value="{{ settings.bank_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Account Name
|
||||
<input name="bank_account_name" value="{{ settings.bank_account_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Account Number
|
||||
<input name="bank_account_number" value="{{ settings.bank_account_number or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">IFSC
|
||||
<input name="bank_ifsc" value="{{ settings.bank_ifsc or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm uppercase" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">UPI ID
|
||||
<input name="upi_id" value="{{ settings.upi_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Additional Bank Details / Payment Instructions
|
||||
<textarea name="bank_details" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.bank_details or '' }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">PayUMoney / PayU Online Payment Gateway</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Enable this only after entering valid PayU/PayUMoney merchant credentials. Test mode posts to PayU test checkout.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 text-sm font-medium text-slate-700 md:col-span-2">
|
||||
<input type="checkbox" name="payumoney_enabled" value="1" {% if settings.payumoney_enabled %}checked{% endif %} />
|
||||
Enable PayUMoney / PayU Pay Now for client portal
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Mode
|
||||
<select name="payumoney_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="TEST" {% if settings.payumoney_mode != 'LIVE' %}selected{% endif %}>TEST / Sandbox</option>
|
||||
<option value="LIVE" {% if settings.payumoney_mode == 'LIVE' %}selected{% endif %}>LIVE / Production</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Merchant ID, optional
|
||||
<input name="payumoney_merchant_id" value="{{ settings.payumoney_merchant_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Merchant Key
|
||||
<input name="payumoney_merchant_key" value="{{ settings.payumoney_merchant_key or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Merchant Salt
|
||||
<input name="payumoney_merchant_salt" value="{{ settings.payumoney_merchant_salt or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Product Info Label
|
||||
<input name="payumoney_product_info" value="{{ settings.payumoney_product_info or 'Professional Services Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-xs leading-5 text-amber-900">
|
||||
Store separate test and live credentials carefully. Do not enable LIVE until callback testing is completed from an accessible public URL.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Cashfree Online Payment Gateway</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Enable Cashfree only after adding valid Cashfree PG credentials. Sandbox mode uses Cashfree sandbox APIs.</p>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label class="flex items-center gap-3 rounded-2xl border border-slate-200 p-4 text-sm font-medium text-slate-700 md:col-span-2">
|
||||
<input type="checkbox" name="cashfree_enabled" value="1" {% if settings.cashfree_enabled %}checked{% endif %} />
|
||||
Enable Cashfree Pay Now for client portal
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Mode
|
||||
<select name="cashfree_mode" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">
|
||||
<option value="TEST" {% if settings.cashfree_mode != 'LIVE' %}selected{% endif %}>TEST / Sandbox</option>
|
||||
<option value="LIVE" {% if settings.cashfree_mode == 'LIVE' %}selected{% endif %}>LIVE / Production</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">API Version
|
||||
<input name="cashfree_api_version" value="{{ settings.cashfree_api_version or '2023-08-01' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Client ID / App ID
|
||||
<input name="cashfree_client_id" value="{{ settings.cashfree_client_id or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Client Secret
|
||||
<input name="cashfree_client_secret" value="{{ settings.cashfree_client_secret or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" autocomplete="off" />
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700 md:col-span-2">Order Note
|
||||
<input name="cashfree_order_note" value="{{ settings.cashfree_order_note or 'Professional Services Invoice' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 rounded-2xl border border-sky-200 bg-sky-50 p-4 text-xs leading-5 text-sky-900">
|
||||
Cashfree checkout creates an order from the server and uses payment_session_id for hosted checkout. Webhook URL: <span class="font-mono">/client/billing/cashfree/webhook</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="af-card p-5">
|
||||
<h2 class="text-lg font-semibold text-slate-900">Invoice Notes, Terms & Signatory</h2>
|
||||
<div class="mt-4 grid gap-4">
|
||||
<label class="text-sm font-medium text-slate-700">Default Terms
|
||||
<textarea name="terms" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.terms or '' }}</textarea>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Declaration
|
||||
<textarea name="declaration" rows="3" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.declaration or '' }}</textarea>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Invoice Footer Note
|
||||
<textarea name="footer_note" rows="2" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm">{{ settings.footer_note or '' }}</textarea>
|
||||
</label>
|
||||
<label class="text-sm font-medium text-slate-700">Authorised Signatory Name
|
||||
<input name="authorised_signatory_name" value="{{ settings.authorised_signatory_name or '' }}" class="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="/billing" class="af-btn af-btn-secondary">Back to Invoices</a>
|
||||
{% if can_edit_settings %}
|
||||
<button type="submit" class="af-btn af-btn-primary">Save Billing Settings</button>
|
||||
{% else %}
|
||||
<span class="text-sm text-slate-500">View-only access</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<aside class="space-y-4">
|
||||
<div class="af-card p-5">
|
||||
<h3 class="font-semibold text-slate-900">Why this matters</h3>
|
||||
<ul class="mt-3 space-y-2 text-sm text-slate-600">
|
||||
<li>• GST invoice format will use these details in Phase 7R.2.</li>
|
||||
<li>• Payment and receipt tracking will use bank/UPI details in Phase 7R.4.</li>
|
||||
<li>• Client portal Pay Now uses UPI, PayUMoney and Cashfree settings from Phase 7R.5 / 7R.6 / 7R.6A.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="af-card p-5">
|
||||
<h3 class="font-semibold text-slate-900">Recommended invoice format</h3>
|
||||
<p class="mt-2 rounded-xl bg-slate-50 px-3 py-2 font-mono text-sm text-slate-700">{prefix}/{fy}/{number}</p>
|
||||
<p class="mt-2 text-xs text-slate-500">Example: INV/2026-27/0001</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,880 @@
|
||||
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():
|
||||
return RedirectResponse(url="/system-settings", status_code=303)
|
||||
|
||||
|
||||
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=financial_year,
|
||||
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("/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=financial_year,
|
||||
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=financial_year,
|
||||
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=financial_year,
|
||||
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=financial_year,
|
||||
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()
|
||||
Reference in New Issue
Block a user