Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
+1
View File
@@ -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",
}
+320
View File
@@ -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 %}
+880
View File
@@ -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()