Files
arrr-erp/app/modules/billing/services.py
T
2026-06-20 15:01:44 +05:30

1373 lines
63 KiB
Python

from __future__ import annotations
from datetime import date, datetime, timezone
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from io import BytesIO
from typing import Any
from openpyxl import Workbook, load_workbook
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session, selectinload
from app.modules.billing.models import (
BillingFeeGroup,
BillingFeeGroupService,
BillingInvoice,
BillingInvoiceLine,
BillingInvoiceGenerationBatch,
BillingPayment,
BillingSettings,
BillingOnlinePaymentTransaction,
)
from app.modules.clients.models import Client
from app.modules.email_integration.event_service import send_invoice_issued_email, send_payment_receipt_email
from app.modules.services.models import ClientServiceSubscription, ServiceCatalogue
TAX_TYPES = ["CGST_SGST", "IGST", "NO_GST"]
BILLING_MODES = ["PACKAGE", "SERVICE_WISE"]
FREQUENCIES = ["Monthly", "Quarterly", "Half-Yearly", "Yearly", "One-time"]
INVOICE_STATUSES = ["DRAFT", "ISSUED", "PARTLY_PAID", "PAID", "OVERDUE", "CANCELLED", "WRITTEN_OFF"]
PAYMENT_MODES = ["CASH", "BANK", "UPI", "CHEQUE", "ONLINE", "ADJUSTMENT"]
PAYMENT_STATUSES = ["RECEIVED", "CANCELLED", "REFUNDED"]
def money(value: Any) -> Decimal:
try:
if value in (None, ""):
return Decimal("0.00")
return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
except (InvalidOperation, ValueError):
return Decimal("0.00")
def parse_date(value: Any) -> date | None:
if value in (None, ""):
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
value = str(value).strip()
if not value:
return None
return date.fromisoformat(value)
def normalize_code(value: Any) -> str:
return str(value or "").strip().upper().replace(" ", "-")
def normalize_yes_no(value: Any) -> bool:
return str(value or "").strip().lower() in {"yes", "y", "true", "1", "active"}
def get_or_create_settings(db: Session, *, tenant_id: int, branch_id: int | None = 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 _financial_year_label(value: date | None = None) -> str:
value = value or date.today()
start_year = value.year if value.month >= 4 else value.year - 1
return f"{start_year}-{str(start_year + 1)[-2:]}"
def billing_financial_year(*, billing_period_from: date | None = None, invoice_date: date | None = None, fallback: date | None = None) -> str:
return _financial_year_label(billing_period_from or invoice_date or fallback or date.today())
def next_invoice_number(db: Session, *, tenant_id: int, branch_id: int | None = None, financial_year: str | None = None) -> str:
settings = get_or_create_settings(db, tenant_id=tenant_id, branch_id=branch_id)
serial = str(settings.next_invoice_no).zfill(settings.padding or 4)
fy = financial_year or _financial_year_label()
fmt = (getattr(settings, "invoice_number_format", None) or "{prefix}/{fy}/{number}").strip()
try:
number = fmt.format(prefix=settings.invoice_prefix or "INV", fy=fy, number=serial, branch_id=branch_id or "")
except Exception:
number = f"{settings.invoice_prefix or 'INV'}/{fy}/{serial}"
settings.next_invoice_no += 1
settings.updated_at_utc = datetime.now(timezone.utc)
return number
def preview_invoice_number(settings: BillingSettings, *, branch_id: int | None = None, financial_year: str | None = None) -> str:
serial = str((settings.next_invoice_no or 1)).zfill(settings.padding or 4)
fy = financial_year or _financial_year_label()
fmt = (getattr(settings, "invoice_number_format", None) or "{prefix}/{fy}/{number}").strip()
try:
return fmt.format(prefix=settings.invoice_prefix or "INV", fy=fy, number=serial, branch_id=branch_id or "")
except Exception:
return f"{settings.invoice_prefix or 'INV'}/{fy}/{serial}"
_ONES = ["", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"]
_TENS = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]
def _words_below_1000(n: int) -> str:
parts: list[str] = []
if n >= 100:
parts.append(_ONES[n // 100] + " Hundred")
n %= 100
if n >= 20:
parts.append(_TENS[n // 10])
n %= 10
if n > 0:
parts.append(_ONES[n])
return " ".join(parts)
def amount_to_indian_words(value: Any) -> str:
amount = money(value)
rupees = int(amount)
paise = int((amount - Decimal(rupees)) * 100)
if rupees == 0:
words = "Zero"
else:
parts: list[str] = []
crore, rupees = divmod(rupees, 10000000)
lakh, rupees = divmod(rupees, 100000)
thousand, rupees = divmod(rupees, 1000)
if crore:
parts.append(_words_below_1000(crore) + " Crore")
if lakh:
parts.append(_words_below_1000(lakh) + " Lakh")
if thousand:
parts.append(_words_below_1000(thousand) + " Thousand")
if rupees:
parts.append(_words_below_1000(rupees))
words = " ".join(parts)
result = f"Rupees {words} Only"
if paise:
result = f"Rupees {words} and Paise {_words_below_1000(paise)} Only"
return result
def _client_address_snapshot(client: Client) -> str | None:
parts = [
getattr(client, "address_line_1", None),
getattr(client, "address_line_2", None),
getattr(client, "city", None),
getattr(client, "state", None),
getattr(client, "pincode", None),
getattr(client, "country", None),
]
return ", ".join([str(p).strip() for p in parts if str(p or "").strip()]) or None
def _state_code_from_gstin(gstin: str | None) -> str | None:
value = str(gstin or "").strip()
if len(value) >= 2 and value[:2].isdigit():
return value[:2]
return None
def get_effective_billing_settings(db: Session, *, tenant_id: int, branch_id: int | None = None) -> BillingSettings:
if branch_id:
row = db.execute(select(BillingSettings).where(BillingSettings.tenant_id == tenant_id, BillingSettings.branch_id == branch_id)).scalar_one_or_none()
if row:
return row
return get_or_create_settings(db, tenant_id=tenant_id, branch_id=None)
def build_invoice_print_context(db: Session, invoice: BillingInvoice) -> dict[str, Any]:
settings = get_effective_billing_settings(db, tenant_id=invoice.tenant_id, branch_id=invoice.branch_id)
return {
"invoice": invoice,
"settings": settings,
"invoice_title": invoice.invoice_title or settings.invoice_title or "Tax Invoice",
"firm_name": settings.legal_name or "Audit Firm",
"firm_address": settings.billing_address,
"firm_gstin": settings.gstin,
"firm_pan": settings.pan,
"firm_state_code": settings.state_code,
"firm_contact_email": settings.contact_email,
"firm_contact_mobile": settings.contact_mobile,
"firm_website": settings.website_url,
"bank_name": settings.bank_name,
"bank_account_name": settings.bank_account_name,
"bank_account_number": settings.bank_account_number,
"bank_ifsc": settings.bank_ifsc,
"upi_id": settings.upi_id,
"bank_details": settings.bank_details,
"declaration": invoice.notes or settings.declaration,
"terms": invoice.terms or settings.terms,
"footer_note": settings.footer_note,
"authorised_signatory_name": settings.authorised_signatory_name,
}
def calculate_line(*, quantity: Decimal, rate: Decimal, discount: Decimal, gst_rate: Decimal, tax_type: str) -> dict[str, Decimal]:
taxable = (quantity * rate - discount).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if taxable < 0:
taxable = Decimal("0.00")
cgst = sgst = igst = Decimal("0.00")
if tax_type == "IGST":
igst = (taxable * gst_rate / Decimal("100.00")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
elif tax_type == "CGST_SGST":
half = (taxable * gst_rate / Decimal("200.00")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
cgst = half
sgst = half
total = taxable + cgst + sgst + igst
return {"taxable": taxable, "cgst": cgst, "sgst": sgst, "igst": igst, "total": total}
def recalc_invoice(invoice: BillingInvoice) -> None:
subtotal = Decimal("0.00")
discount = Decimal("0.00")
taxable = Decimal("0.00")
cgst = Decimal("0.00")
sgst = Decimal("0.00")
igst = Decimal("0.00")
total = Decimal("0.00")
for line in invoice.lines:
subtotal += money(line.quantity) * money(line.rate)
discount += money(line.discount_amount)
taxable += money(line.taxable_amount)
cgst += money(line.cgst_amount)
sgst += money(line.sgst_amount)
igst += money(line.igst_amount)
total += money(line.line_total)
invoice.subtotal = money(subtotal)
invoice.discount_amount = money(discount)
invoice.taxable_amount = money(taxable)
invoice.cgst_amount = money(cgst)
invoice.sgst_amount = money(sgst)
invoice.igst_amount = money(igst)
invoice.total_amount = money(total + money(invoice.round_off))
update_invoice_payment_totals(invoice)
def update_invoice_payment_totals(invoice: BillingInvoice) -> None:
paid = Decimal("0.00")
tds = Decimal("0.00")
charges = Decimal("0.00")
for payment in getattr(invoice, "payments", []) or []:
if getattr(payment, "status", "RECEIVED") != "CANCELLED":
paid += money(payment.amount_received)
tds += money(payment.tds_deducted)
charges += money(payment.bank_charges)
invoice.amount_received = money(paid)
invoice.tds_deducted = money(tds)
invoice.bank_charges = money(charges)
invoice.balance_amount = money(money(invoice.total_amount) - paid - tds)
if invoice.balance_amount < Decimal("0.00"):
invoice.balance_amount = Decimal("0.00")
if invoice.status not in {"DRAFT", "CANCELLED", "WRITTEN_OFF"}:
if invoice.balance_amount <= Decimal("0.00") and money(invoice.total_amount) > Decimal("0.00"):
invoice.status = "PAID"
elif paid > Decimal("0.00") or tds > Decimal("0.00"):
invoice.status = "PARTLY_PAID"
elif invoice.status == "PAID":
invoice.status = "ISSUED"
def next_receipt_number(db: Session, *, tenant_id: int, branch_id: int | None = None, financial_year: str | None = None) -> str:
fy = financial_year or _financial_year_label()
prefix = "RCT"
like_prefix = f"{prefix}/{fy}/%"
last = db.execute(
select(BillingPayment.receipt_no)
.where(BillingPayment.tenant_id == tenant_id, BillingPayment.receipt_no.ilike(like_prefix))
.order_by(BillingPayment.id.desc())
).scalar_one_or_none()
next_no = 1
if last:
try:
next_no = int(str(last).split("/")[-1]) + 1
except Exception:
next_no = 1
return f"{prefix}/{fy}/{str(next_no).zfill(4)}"
def record_invoice_payment(
db: Session,
*,
invoice: BillingInvoice,
payment_date: date,
amount_received: Decimal,
tds_deducted: Decimal = Decimal("0.00"),
bank_charges: Decimal = Decimal("0.00"),
mode: str = "BANK",
reference_no: str | None = None,
remarks: str | None = None,
created_by_user_id: int | None = None,
payment_gateway: str | None = None,
gateway_transaction_id: str | None = None,
) -> BillingPayment:
if invoice.status in {"DRAFT", "CANCELLED"}:
raise ValueError("Payment can be recorded only after invoice is issued.")
mode = mode if mode in PAYMENT_MODES else "BANK"
payment = BillingPayment(
tenant_id=invoice.tenant_id,
branch_id=invoice.branch_id,
invoice_id=invoice.id,
client_id=invoice.client_id,
receipt_no=next_receipt_number(db, tenant_id=invoice.tenant_id, branch_id=invoice.branch_id, financial_year=getattr(invoice, "financial_year", None)),
receipt_date=payment_date,
payment_date=payment_date,
financial_year=getattr(invoice, "financial_year", None) or billing_financial_year(invoice_date=payment_date),
amount_received=money(amount_received),
tds_deducted=money(tds_deducted),
bank_charges=money(bank_charges),
mode=mode,
reference_no=(reference_no or "").strip() or None,
payment_gateway=(payment_gateway or "").strip() or None,
gateway_transaction_id=(gateway_transaction_id or "").strip() or None,
remarks=(remarks or "").strip() or None,
created_by_user_id=created_by_user_id,
status="RECEIVED",
)
db.add(payment)
db.flush()
if payment not in invoice.payments:
invoice.payments.append(payment)
update_invoice_payment_totals(invoice)
invoice.updated_at_utc = datetime.now(timezone.utc)
db.flush()
try:
send_payment_receipt_email(db, payment)
except Exception:
# Email failure should not block payment posting or receipt generation.
pass
return payment
def list_payments(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None, financial_year: str | None = None, q: str = ""):
stmt = select(BillingPayment).options(selectinload(BillingPayment.invoice), selectinload(BillingPayment.client)).where(BillingPayment.tenant_id == tenant_id)
if branch_id:
stmt = stmt.where(BillingPayment.branch_id == branch_id)
if partner_id:
stmt = stmt.join(Client, Client.id == BillingPayment.client_id).where(Client.partner_id == partner_id)
if financial_year and financial_year.upper() != "ALL":
stmt = stmt.where(BillingPayment.financial_year == financial_year)
if q.strip():
term = f"%{q.strip()}%"
stmt = stmt.join(BillingInvoice, BillingInvoice.id == BillingPayment.invoice_id).join(Client, Client.id == BillingPayment.client_id).where(or_(BillingPayment.receipt_no.ilike(term), BillingInvoice.invoice_no.ilike(term), Client.client_name.ilike(term), Client.client_code.ilike(term)))
return db.execute(stmt.order_by(BillingPayment.payment_date.desc(), BillingPayment.id.desc())).scalars().unique().all()
def get_payment(db: Session, *, payment_id: int, tenant_id: int, partner_id: int | None = None, financial_year: str | None = None) -> BillingPayment | None:
stmt = select(BillingPayment).options(selectinload(BillingPayment.invoice).selectinload(BillingInvoice.lines), selectinload(BillingPayment.client)).where(BillingPayment.id == payment_id, BillingPayment.tenant_id == tenant_id)
if partner_id:
stmt = stmt.join(Client, Client.id == BillingPayment.client_id).where(Client.partner_id == partner_id)
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 list_clients_for_billing(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None, q: str = ""):
stmt = select(Client).where(Client.tenant_id == tenant_id, Client.is_archived.is_(False))
if branch_id:
stmt = stmt.where(Client.branch_id == branch_id)
if partner_id:
stmt = stmt.where(Client.partner_id == partner_id)
if q.strip():
term = f"%{q.strip()}%"
stmt = stmt.where(or_(Client.client_name.ilike(term), Client.client_code.ilike(term), Client.pan.ilike(term), Client.gstin.ilike(term)))
return db.execute(stmt.order_by(Client.client_name.asc())).scalars().all()
def list_services_for_billing(db: Session):
return db.execute(
select(ServiceCatalogue).where(ServiceCatalogue.is_active.is_(True)).order_by(ServiceCatalogue.service_name.asc())
).scalars().all()
def list_invoices(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None, financial_year: str | None = None, q: str = ""):
stmt = select(BillingInvoice).options(selectinload(BillingInvoice.client), selectinload(BillingInvoice.lines), selectinload(BillingInvoice.payments)).where(BillingInvoice.tenant_id == tenant_id)
if branch_id:
stmt = stmt.where(BillingInvoice.branch_id == branch_id)
if partner_id:
stmt = stmt.join(Client, Client.id == BillingInvoice.client_id).where(Client.partner_id == partner_id)
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.join(Client, Client.id == BillingInvoice.client_id).where(or_(BillingInvoice.invoice_no.ilike(term), Client.client_name.ilike(term), Client.client_code.ilike(term)))
return db.execute(stmt.order_by(BillingInvoice.invoice_date.desc(), BillingInvoice.id.desc())).scalars().unique().all()
def build_billing_report_summary(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None, financial_year: str | None = None) -> dict[str, Any]:
invoices = list_invoices(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year)
payments = list_payments(db, tenant_id=tenant_id, branch_id=branch_id, partner_id=partner_id, financial_year=financial_year)
total_billed = sum((money(row.total_amount) for row in invoices if row.status != "CANCELLED"), Decimal("0.00"))
total_received = sum((money(row.amount_received) for row in payments if row.status == "RECEIVED"), Decimal("0.00"))
total_tds = sum((money(row.tds_deducted) for row in payments if row.status == "RECEIVED"), Decimal("0.00"))
outstanding = sum((money(row.balance_amount) for row in invoices if row.status in {"ISSUED", "PARTLY_PAID", "OVERDUE"}), Decimal("0.00"))
draft_count = sum(1 for row in invoices if row.status == "DRAFT")
issued_count = sum(1 for row in invoices if row.status in {"ISSUED", "PARTLY_PAID", "OVERDUE"})
paid_count = sum(1 for row in invoices if row.status == "PAID")
return {
"invoice_count": len(invoices),
"draft_count": draft_count,
"issued_count": issued_count,
"paid_count": paid_count,
"payment_count": len(payments),
"total_billed": money(total_billed),
"total_received": money(total_received),
"total_tds": money(total_tds),
"total_collected_with_tds": money(total_received + total_tds),
"outstanding": money(outstanding),
}
def get_invoice(db: Session, *, invoice_id: int, tenant_id: int, partner_id: int | None = None, financial_year: str | None = None) -> BillingInvoice | None:
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)
if partner_id:
stmt = stmt.join(Client, Client.id == BillingInvoice.client_id).where(Client.partner_id == partner_id)
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 create_invoice(
db: Session,
*,
tenant_id: int,
branch_id: int | None,
client_id: int,
invoice_date: date,
due_date: date | None,
billing_period_from: date | None,
billing_period_to: date | None,
tax_type: str,
notes: str | None,
terms: str | None,
place_of_supply: str | None = None,
client_state_code: str | None = None,
reverse_charge: bool = False,
created_by_user_id: int,
raw_lines: list[dict[str, Any]],
generation_batch_id: int | None = None,
engagement_id: int | None = None,
financial_year: str | None = None,
) -> BillingInvoice:
settings = get_effective_billing_settings(db, tenant_id=tenant_id, branch_id=branch_id)
client = db.get(Client, client_id)
if client is None:
raise ValueError("Client not found.")
snapshot_state_code = (client_state_code or _state_code_from_gstin(getattr(client, "gstin", None)) or "").strip()[:2] or None
invoice_financial_year = financial_year or billing_financial_year(billing_period_from=billing_period_from, invoice_date=invoice_date)
invoice = BillingInvoice(
tenant_id=tenant_id,
branch_id=branch_id,
client_id=client_id,
engagement_id=engagement_id,
invoice_no=next_invoice_number(db, tenant_id=tenant_id, branch_id=branch_id, financial_year=invoice_financial_year),
invoice_date=invoice_date,
due_date=due_date,
billing_period_from=billing_period_from,
billing_period_to=billing_period_to,
financial_year=invoice_financial_year,
invoice_title=settings.invoice_title or "Tax Invoice",
place_of_supply=(place_of_supply or getattr(client, "state", None) or "").strip() or None,
reverse_charge=bool(reverse_charge),
client_legal_name=getattr(client, "client_name", None),
client_trade_name=getattr(client, "trade_name", None),
client_gstin=(getattr(client, "gstin", None) or "").strip().upper() or None,
client_pan=(getattr(client, "pan", None) or "").strip().upper() or None,
client_billing_address=_client_address_snapshot(client),
client_state=getattr(client, "state", None),
client_state_code=snapshot_state_code,
client_email=getattr(client, "email", None),
client_mobile=getattr(client, "mobile", None),
tax_type=tax_type if tax_type in TAX_TYPES else (settings.default_tax_type if settings.default_tax_type in TAX_TYPES else "CGST_SGST"),
notes=(notes or "").strip() or None,
terms=(terms or settings.terms or "").strip() or None,
created_by_user_id=created_by_user_id,
generation_batch_id=generation_batch_id,
status="DRAFT",
balance_amount=Decimal("0.00"),
)
db.add(invoice)
db.flush()
sort_order = 1
for raw in raw_lines:
description = str(raw.get("description") or "").strip()
if not description:
continue
qty = money(raw.get("quantity") or 1)
if qty <= 0:
qty = Decimal("1.00")
rate = money(raw.get("rate"))
disc = money(raw.get("discount_amount"))
gst_rate = money(raw.get("gst_rate") or settings.default_gst_rate)
sac_code = str(raw.get("sac_code") or settings.default_sac_code or "").strip()[:20] or None
calc = calculate_line(quantity=qty, rate=rate, discount=disc, gst_rate=gst_rate, tax_type=invoice.tax_type)
service_id = raw.get("service_id") or None
fee_group_id = raw.get("fee_group_id") or None
raw_engagement_id = raw.get("engagement_id") or None
line = BillingInvoiceLine(
invoice_id=invoice.id,
service_id=int(service_id) if service_id else None,
fee_group_id=int(fee_group_id) if fee_group_id else None,
engagement_id=int(raw_engagement_id) if raw_engagement_id else None,
description=description,
sac_code=sac_code,
billing_period_from=billing_period_from,
billing_period_to=billing_period_to,
quantity=qty,
rate=rate,
discount_amount=disc,
taxable_amount=calc["taxable"],
gst_rate=gst_rate,
cgst_amount=calc["cgst"],
sgst_amount=calc["sgst"],
igst_amount=calc["igst"],
line_total=calc["total"],
sort_order=sort_order,
)
db.add(line)
invoice.lines.append(line)
sort_order += 1
if sort_order == 1:
raise ValueError("At least one invoice line with description is required.")
recalc_invoice(invoice)
invoice.amount_in_words = amount_to_indian_words(invoice.total_amount)
db.flush()
return invoice
def issue_invoice(db: Session, invoice: BillingInvoice, *, user_id: int | None = None) -> BillingInvoice:
issued_now = False
if invoice.status == "DRAFT":
invoice.status = "ISSUED"
invoice.approved_by_user_id = user_id
invoice.posted_at_utc = datetime.now(timezone.utc)
update_invoice_payment_totals(invoice)
invoice.updated_at_utc = datetime.now(timezone.utc)
issued_now = True
if issued_now:
db.flush()
try:
send_invoice_issued_email(db, invoice)
except Exception:
# Email failure should be recorded in email logs and must not block invoice issue.
pass
return invoice
def list_fee_groups(db: Session, *, tenant_id: int, branch_id: int | None = None, partner_id: int | None = None, q: str = ""):
stmt = select(BillingFeeGroup).options(selectinload(BillingFeeGroup.client), selectinload(BillingFeeGroup.services).selectinload(BillingFeeGroupService.service)).where(BillingFeeGroup.tenant_id == tenant_id)
if branch_id:
stmt = stmt.where(BillingFeeGroup.branch_id == branch_id)
if partner_id:
stmt = stmt.where(BillingFeeGroup.partner_id == partner_id)
if q.strip():
term = f"%{q.strip()}%"
stmt = stmt.join(Client, Client.id == BillingFeeGroup.client_id).where(or_(BillingFeeGroup.group_code.ilike(term), BillingFeeGroup.group_name.ilike(term), Client.client_name.ilike(term), Client.client_code.ilike(term)))
return db.execute(stmt.order_by(BillingFeeGroup.group_code.asc())).scalars().unique().all()
def period_label(period_from: date, period_to: date) -> str:
if period_from.year == period_to.year and period_from.month == period_to.month:
return period_from.strftime("%B %Y")
return f"{period_from.isoformat()} to {period_to.isoformat()}"
def list_fee_groups_for_generation(
db: Session,
*,
tenant_id: int,
branch_id: int | None = None,
partner_id: int | None = None,
frequency: str | None = None,
auto_generate_only: bool = True,
q: str = "",
):
stmt = (
select(BillingFeeGroup)
.options(
selectinload(BillingFeeGroup.client),
selectinload(BillingFeeGroup.services).selectinload(BillingFeeGroupService.service),
)
.where(BillingFeeGroup.tenant_id == tenant_id, BillingFeeGroup.is_active.is_(True))
)
if branch_id:
stmt = stmt.where(BillingFeeGroup.branch_id == branch_id)
if partner_id:
stmt = stmt.where(BillingFeeGroup.partner_id == partner_id)
if frequency:
stmt = stmt.where(BillingFeeGroup.frequency == frequency)
if auto_generate_only:
stmt = stmt.where(BillingFeeGroup.auto_generate.is_(True))
if q.strip():
term = f"%{q.strip()}%"
stmt = stmt.join(Client, Client.id == BillingFeeGroup.client_id).where(
or_(BillingFeeGroup.group_code.ilike(term), BillingFeeGroup.group_name.ilike(term), Client.client_name.ilike(term), Client.client_code.ilike(term))
)
return db.execute(stmt.order_by(BillingFeeGroup.group_code.asc())).scalars().unique().all()
def fee_group_already_billed(db: Session, *, tenant_id: int, fee_group_id: int, period_from: date, period_to: date) -> BillingInvoice | None:
stmt = (
select(BillingInvoice)
.join(BillingInvoiceLine, BillingInvoiceLine.invoice_id == BillingInvoice.id)
.where(
BillingInvoice.tenant_id == tenant_id,
BillingInvoice.status != "CANCELLED",
BillingInvoice.billing_period_from == period_from,
BillingInvoice.billing_period_to == period_to,
BillingInvoiceLine.fee_group_id == fee_group_id,
)
.order_by(BillingInvoice.id.desc())
)
return db.execute(stmt).scalars().first()
def _financial_year_from_period(period_from: date) -> str:
return _financial_year_label(period_from)
def _billing_engagement_lookup(
db: Session,
*,
tenant_id: int,
branch_id: int | None,
client_ids: set[int],
service_ids: set[int],
financial_year: str,
) -> dict[tuple[int, int], ClientServiceSubscription]:
if not client_ids or not service_ids:
return {}
stmt = (
select(ClientServiceSubscription)
.options(selectinload(ClientServiceSubscription.client), selectinload(ClientServiceSubscription.catalogue))
.where(
ClientServiceSubscription.tenant_id == tenant_id,
ClientServiceSubscription.client_id.in_(client_ids),
ClientServiceSubscription.service_catalogue_id.in_(service_ids),
ClientServiceSubscription.financial_year == financial_year,
ClientServiceSubscription.is_active.is_(True),
)
)
if branch_id:
stmt = stmt.where(ClientServiceSubscription.branch_id == branch_id)
rows = db.execute(stmt.order_by(ClientServiceSubscription.id.desc())).scalars().unique().all()
lookup: dict[tuple[int, int], ClientServiceSubscription] = {}
for row in rows:
lookup.setdefault((row.client_id, row.service_catalogue_id), row)
return lookup
def _annotate_invoice_lines_with_engagements(
raw_lines: list[dict[str, Any]],
*,
client_id: int,
lookup: dict[tuple[int, int], ClientServiceSubscription],
) -> tuple[list[dict[str, Any]], int | None, list[ClientServiceSubscription]]:
engagement_ids: set[int] = set()
linked: list[ClientServiceSubscription] = []
for line in raw_lines:
service_id = line.get("service_id")
if not service_id:
continue
subscription = lookup.get((client_id, int(service_id)))
if not subscription:
continue
line["engagement_id"] = subscription.id
engagement_ids.add(subscription.id)
linked.append(subscription)
invoice_engagement_id = next(iter(engagement_ids)) if len(engagement_ids) == 1 else None
return raw_lines, invoice_engagement_id, linked
def _fee_group_invoice_lines(fee_group: BillingFeeGroup, *, period_from: date, period_to: date) -> list[dict[str, Any]]:
label = period_label(period_from, period_to)
if fee_group.billing_mode == "SERVICE_WISE":
lines: list[dict[str, Any]] = []
for item in sorted(fee_group.services, key=lambda x: x.sort_order or 100):
rate = money(item.line_amount)
if rate <= 0 and item.percentage is not None:
rate = (money(fee_group.fee_amount) * money(item.percentage) / Decimal("100.00")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
description = item.line_description or (item.service.service_name if item.service else fee_group.group_name)
lines.append({
"description": f"{description} - {label}"[:500],
"service_id": item.service_id,
"fee_group_id": fee_group.id,
"quantity": "1",
"rate": rate,
"discount_amount": "0",
"gst_rate": fee_group.gst_rate,
})
if lines:
return lines
included = []
for item in sorted(fee_group.services, key=lambda x: x.sort_order or 100):
if item.service:
included.append(item.line_description or item.service.service_name)
desc = f"{fee_group.group_name} - {label}"
if included:
desc = desc + "\nIncluded services: " + ", ".join(included)
return [{
"description": desc[:500],
"service_id": None,
"fee_group_id": fee_group.id,
"quantity": "1",
"rate": fee_group.fee_amount,
"discount_amount": "0",
"gst_rate": fee_group.gst_rate,
}]
def generate_draft_invoices_from_fee_groups(
db: Session,
*,
tenant_id: int,
branch_id: int | None,
partner_id: int | None,
generated_by_user_id: int,
billing_period_from: date,
billing_period_to: date,
frequency: str | None,
fee_group_ids: list[int],
skip_duplicates: bool = True,
) -> dict[str, Any]:
if billing_period_to < billing_period_from:
raise ValueError("Billing Period To cannot be earlier than Billing Period From.")
if not fee_group_ids:
raise ValueError("Select at least one fee structure to generate invoices.")
stmt = (
select(BillingFeeGroup)
.options(selectinload(BillingFeeGroup.client), selectinload(BillingFeeGroup.services).selectinload(BillingFeeGroupService.service))
.where(BillingFeeGroup.tenant_id == tenant_id, BillingFeeGroup.id.in_(fee_group_ids), BillingFeeGroup.is_active.is_(True))
)
if branch_id:
stmt = stmt.where(BillingFeeGroup.branch_id == branch_id)
if partner_id:
stmt = stmt.where(BillingFeeGroup.partner_id == partner_id)
if frequency:
stmt = stmt.where(BillingFeeGroup.frequency == frequency)
fee_groups = db.execute(stmt.order_by(BillingFeeGroup.group_code.asc())).scalars().unique().all()
batch = BillingInvoiceGenerationBatch(
tenant_id=tenant_id,
branch_id=branch_id,
billing_period_from=billing_period_from,
billing_period_to=billing_period_to,
financial_year=billing_financial_year(billing_period_from=billing_period_from, invoice_date=billing_period_to),
frequency=frequency or None,
selected_count=len(fee_group_ids),
generated_by_user_id=generated_by_user_id,
status="DRAFT_CREATED",
)
db.add(batch)
db.flush()
created: list[BillingInvoice] = []
skipped: list[str] = []
errors: list[str] = []
found_ids = {g.id for g in fee_groups}
financial_year = _financial_year_from_period(billing_period_from)
client_ids = {int(g.client_id) for g in fee_groups if g.client_id}
service_ids = {int(item.service_id) for g in fee_groups for item in (g.services or []) if item.service_id}
engagement_lookup = _billing_engagement_lookup(
db,
tenant_id=tenant_id,
branch_id=branch_id,
client_ids=client_ids,
service_ids=service_ids,
financial_year=financial_year,
)
for missing_id in sorted(set(fee_group_ids) - found_ids):
skipped.append(f"Fee structure ID {missing_id} is not available in the active Audit Firm/Branch context.")
for fee_group in fee_groups:
try:
existing = fee_group_already_billed(
db,
tenant_id=tenant_id,
fee_group_id=fee_group.id,
period_from=billing_period_from,
period_to=billing_period_to,
)
if existing and skip_duplicates:
skipped.append(f"{fee_group.group_code}: already billed in invoice {existing.invoice_no}.")
continue
raw_lines = _fee_group_invoice_lines(fee_group, period_from=billing_period_from, period_to=billing_period_to)
raw_lines, invoice_engagement_id, linked_subscriptions = _annotate_invoice_lines_with_engagements(
raw_lines,
client_id=fee_group.client_id,
lookup=engagement_lookup,
)
linked_note = ""
if linked_subscriptions:
linked_labels = []
for sub in linked_subscriptions:
service_name = sub.catalogue.service_name if getattr(sub, "catalogue", None) else f"Service {sub.service_catalogue_id}"
linked_labels.append(f"{service_name} / {sub.financial_year}")
linked_note = " Linked service subscriptions: " + "; ".join(linked_labels[:5]) + "."
invoice = create_invoice(
db,
tenant_id=tenant_id,
branch_id=fee_group.branch_id or branch_id,
client_id=fee_group.client_id,
invoice_date=date.today(),
due_date=None,
billing_period_from=billing_period_from,
billing_period_to=billing_period_to,
tax_type=fee_group.tax_type if fee_group.tax_type in TAX_TYPES else "CGST_SGST",
notes=f"Draft generated from fee structure {fee_group.group_code}.{linked_note}",
terms=None,
created_by_user_id=generated_by_user_id,
raw_lines=raw_lines,
generation_batch_id=batch.id,
engagement_id=invoice_engagement_id,
financial_year=financial_year,
)
created.append(invoice)
except Exception as exc: # keep batch generation resilient per client/package
errors.append(f"{fee_group.group_code}: {exc}")
batch.created_invoice_count = len(created)
batch.skipped_count = len(skipped)
batch.error_count = len(errors)
if errors and created:
batch.status = "PARTIAL"
elif errors and not created:
batch.status = "FAILED"
batch.remarks = "\n".join(skipped + errors) or None
db.flush()
return {"batch": batch, "created": created, "skipped": skipped, "errors": errors}
def build_fee_structure_template() -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "Fee_Structure"
ws.append([
"Client Code", "Billing Group Code", "Billing Group Name", "Billing Mode", "Frequency", "Fee Amount",
"GST Rate", "Tax Type", "Effective From", "Effective To", "Auto Generate", "Notes"
])
ws.append(["ABC001", "ABC-GST-MONTHLY", "Monthly GST Compliance", "PACKAGE", "Monthly", 2500, 18, "CGST_SGST", "2026-04-01", "", "Yes", "GSTR-1 and GSTR-3B package"])
ws2 = wb.create_sheet("Fee_Services")
ws2.append(["Billing Group Code", "Service Code", "Line Description", "Allocation Type", "Line Amount", "Percentage", "Sort Order"])
ws2.append(["ABC-GST-MONTHLY", "GSTR1", "GSTR-1 Filing", "Included", 0, "", 1])
ws2.append(["ABC-GST-MONTHLY", "GSTR3B", "GSTR-3B Filing", "Included", 0, "", 2])
bio = BytesIO()
wb.save(bio)
return bio.getvalue()
def import_fee_structure_excel(db: Session, *, tenant_id: int, branch_id: int | None, created_by_user_id: int, file_bytes: bytes) -> dict[str, Any]:
wb = load_workbook(BytesIO(file_bytes), data_only=True)
if "Fee_Structure" not in wb.sheetnames or "Fee_Services" not in wb.sheetnames:
raise ValueError("Excel must contain Fee_Structure and Fee_Services sheets.")
clients = {c.client_code.strip().upper(): c for c in db.execute(select(Client).where(Client.tenant_id == tenant_id)).scalars().all()}
services = {s.service_code.strip().upper(): s for s in db.execute(select(ServiceCatalogue)).scalars().all()}
ws = wb["Fee_Structure"]
header = [str(c.value or "").strip() for c in ws[1]]
rows = []
errors: list[str] = []
for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True), start=2):
data = dict(zip(header, row))
if not any(data.values()):
continue
client_code = normalize_code(data.get("Client Code"))
group_code = normalize_code(data.get("Billing Group Code"))
if not client_code or client_code not in clients:
errors.append(f"Fee_Structure row {idx}: Client Code not found: {client_code}")
continue
if not group_code:
errors.append(f"Fee_Structure row {idx}: Billing Group Code is required")
continue
mode = str(data.get("Billing Mode") or "PACKAGE").strip().upper().replace(" ", "_")
if mode not in BILLING_MODES:
errors.append(f"Fee_Structure row {idx}: Billing Mode must be PACKAGE or SERVICE_WISE")
continue
frequency = str(data.get("Frequency") or "Monthly").strip() or "Monthly"
rows.append({
"client": clients[client_code],
"group_code": group_code,
"group_name": str(data.get("Billing Group Name") or group_code).strip(),
"billing_mode": mode,
"frequency": frequency,
"fee_amount": money(data.get("Fee Amount")),
"gst_rate": money(data.get("GST Rate") or 18),
"tax_type": str(data.get("Tax Type") or "CGST_SGST").strip().upper() if str(data.get("Tax Type") or "").strip().upper() in TAX_TYPES else "CGST_SGST",
"effective_from": parse_date(data.get("Effective From")),
"effective_to": parse_date(data.get("Effective To")),
"auto_generate": normalize_yes_no(data.get("Auto Generate")),
"notes": str(data.get("Notes") or "").strip() or None,
})
service_rows_by_group: dict[str, list[dict[str, Any]]] = {}
ws2 = wb["Fee_Services"]
header2 = [str(c.value or "").strip() for c in ws2[1]]
for idx, row in enumerate(ws2.iter_rows(min_row=2, values_only=True), start=2):
data = dict(zip(header2, row))
if not any(data.values()):
continue
group_code = normalize_code(data.get("Billing Group Code"))
service_code = normalize_code(data.get("Service Code"))
if not group_code:
errors.append(f"Fee_Services row {idx}: Billing Group Code is required")
continue
if not service_code or service_code not in services:
errors.append(f"Fee_Services row {idx}: Service Code not found: {service_code}")
continue
service_rows_by_group.setdefault(group_code, []).append({
"service": services[service_code],
"line_description": str(data.get("Line Description") or services[service_code].service_name).strip(),
"allocation_type": str(data.get("Allocation Type") or "Included").strip() or "Included",
"line_amount": money(data.get("Line Amount")),
"percentage": money(data.get("Percentage")) if data.get("Percentage") not in (None, "") else None,
"sort_order": int(data.get("Sort Order") or 100),
})
if errors:
return {"success": False, "created": 0, "updated": 0, "errors": errors}
created = updated = 0
for row in rows:
existing = db.execute(select(BillingFeeGroup).where(BillingFeeGroup.tenant_id == tenant_id, BillingFeeGroup.group_code == row["group_code"])).scalar_one_or_none()
if existing:
fee_group = existing
updated += 1
else:
fee_group = BillingFeeGroup(tenant_id=tenant_id, group_code=row["group_code"], created_by_user_id=created_by_user_id)
db.add(fee_group)
created += 1
fee_group.branch_id = branch_id or row["client"].branch_id
fee_group.client_id = row["client"].id
fee_group.partner_id = row["client"].partner_id
fee_group.group_name = row["group_name"]
fee_group.billing_mode = row["billing_mode"]
fee_group.frequency = row["frequency"]
fee_group.fee_amount = row["fee_amount"]
fee_group.gst_rate = row["gst_rate"]
fee_group.tax_type = row["tax_type"]
fee_group.effective_from = row["effective_from"]
fee_group.effective_to = row["effective_to"]
fee_group.auto_generate = row["auto_generate"]
fee_group.notes = row["notes"]
fee_group.updated_by_user_id = created_by_user_id
db.flush()
for old in list(fee_group.services):
db.delete(old)
db.flush()
for service_row in service_rows_by_group.get(row["group_code"], []):
db.add(BillingFeeGroupService(
fee_group_id=fee_group.id,
service_id=service_row["service"].id,
line_description=service_row["line_description"],
allocation_type=service_row["allocation_type"],
line_amount=service_row["line_amount"],
percentage=service_row["percentage"],
sort_order=service_row["sort_order"],
))
db.commit()
return {"success": True, "created": created, "updated": updated, "errors": []}
# ---------------------------------------------------------------------------
# Phase 7R.6 - PayUMoney / PayU redirect integration helpers
# ---------------------------------------------------------------------------
import base64
import hashlib
import hmac
import json
from urllib.parse import urlencode
from urllib.request import Request as UrlRequest, urlopen
from urllib.error import HTTPError, URLError
from app.modules.billing.models import BillingOnlinePaymentTransaction
PAYUMONEY_PROVIDER = "PAYUMONEY"
PAYUMONEY_TEST_URL = "https://test.payu.in/_payment"
PAYUMONEY_PROD_URL = "https://secure.payu.in/_payment"
PAYUMONEY_MODES = ["TEST", "LIVE"]
def payumoney_checkout_url(settings: BillingSettings) -> str:
return PAYUMONEY_PROD_URL if str(getattr(settings, "payumoney_mode", "TEST")).upper() == "LIVE" else PAYUMONEY_TEST_URL
def is_payumoney_ready(settings: BillingSettings | None) -> bool:
return bool(
settings
and getattr(settings, "payumoney_enabled", False)
and (getattr(settings, "payumoney_merchant_key", None) or "").strip()
and (getattr(settings, "payumoney_merchant_salt", None) or "").strip()
)
def generate_payumoney_hash(*, key: str, txnid: str, amount: str, productinfo: str, firstname: str, email: str, salt: str, udf1: str = "", udf2: str = "", udf3: str = "", udf4: str = "", udf5: str = "") -> str:
hash_string = f"{key}|{txnid}|{amount}|{productinfo}|{firstname}|{email}|{udf1}|{udf2}|{udf3}|{udf4}|{udf5}||||||{salt}"
return hashlib.sha512(hash_string.encode("utf-8")).hexdigest().lower()
def verify_payumoney_response_hash(*, response_data: dict[str, Any], salt: str, key: str) -> bool:
# PayU redirect response hash: salt|status||||||udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|txnid|key
received_hash = str(response_data.get("hash") or "").strip().lower()
if not received_hash:
return False
status = str(response_data.get("status") or "")
txnid = str(response_data.get("txnid") or "")
amount = str(response_data.get("amount") or "")
productinfo = str(response_data.get("productinfo") or "")
firstname = str(response_data.get("firstname") or "")
email = str(response_data.get("email") or "")
udf1 = str(response_data.get("udf1") or "")
udf2 = str(response_data.get("udf2") or "")
udf3 = str(response_data.get("udf3") or "")
udf4 = str(response_data.get("udf4") or "")
udf5 = str(response_data.get("udf5") or "")
reverse = f"{salt}|{status}||||||{udf5}|{udf4}|{udf3}|{udf2}|{udf1}|{email}|{firstname}|{productinfo}|{amount}|{txnid}|{key}"
expected = hashlib.sha512(reverse.encode("utf-8")).hexdigest().lower()
return expected == received_hash
def create_payumoney_transaction(db: Session, *, invoice: BillingInvoice, settings: BillingSettings, base_url: str, client_ip: str | None = None) -> dict[str, Any]:
if not is_payumoney_ready(settings):
raise ValueError("PayUMoney is not enabled or merchant credentials are missing in Billing Settings.")
if invoice.status not in {"ISSUED", "PARTLY_PAID", "OVERDUE"} or money(invoice.balance_amount) <= Decimal("0.00"):
raise ValueError("Only issued invoices with balance can be paid online.")
amount = f"{money(invoice.balance_amount):.2f}"
txnid = f"AF{invoice.tenant_id}I{invoice.id}T{int(datetime.now(timezone.utc).timestamp())}"
key = (settings.payumoney_merchant_key or "").strip()
salt = (settings.payumoney_merchant_salt or "").strip()
productinfo = (settings.payumoney_product_info or f"Invoice {invoice.invoice_no}").strip()[:250]
firstname = (invoice.client_legal_name or invoice.client_trade_name or getattr(invoice.client, "client_name", None) or "Client").strip()[:120]
email = (invoice.client_email or getattr(invoice.client, "email", None) or settings.contact_email or "no-reply@example.com").strip()
phone = (invoice.client_mobile or getattr(invoice.client, "mobile", None) or settings.contact_mobile or "9999999999").strip()
udf1, udf2, udf3, udf4, udf5 = str(invoice.id), str(invoice.client_id), str(invoice.tenant_id), str(invoice.branch_id or ""), "audit_firm_erp"
hash_value = generate_payumoney_hash(key=key, txnid=txnid, amount=amount, productinfo=productinfo, firstname=firstname, email=email, salt=salt, udf1=udf1, udf2=udf2, udf3=udf3, udf4=udf4, udf5=udf5)
transaction = BillingOnlinePaymentTransaction(
tenant_id=invoice.tenant_id,
branch_id=invoice.branch_id,
invoice_id=invoice.id,
client_id=invoice.client_id,
provider=PAYUMONEY_PROVIDER,
mode=(settings.payumoney_mode or "TEST").upper(),
txnid=txnid,
amount=money(amount),
productinfo=productinfo,
firstname=firstname,
email=email,
phone=phone,
status="INITIATED",
gateway_status="created",
)
db.add(transaction)
db.flush()
surl = f"{base_url.rstrip('/')}/client/billing/payumoney/success"
furl = f"{base_url.rstrip('/')}/client/billing/payumoney/failure"
payload = {
"key": key,
"txnid": txnid,
"amount": amount,
"productinfo": productinfo,
"firstname": firstname,
"email": email,
"phone": phone,
"surl": surl,
"furl": furl,
"hash": hash_value,
"udf1": udf1,
"udf2": udf2,
"udf3": udf3,
"udf4": udf4,
"udf5": udf5,
}
if getattr(settings, "payumoney_merchant_id", None):
payload["merchant_id"] = settings.payumoney_merchant_id
return {"transaction": transaction, "payload": payload, "checkout_url": payumoney_checkout_url(settings)}
def get_online_transaction_by_txnid(db: Session, *, txnid: str) -> BillingOnlinePaymentTransaction | None:
return db.execute(
select(BillingOnlinePaymentTransaction)
.options(selectinload(BillingOnlinePaymentTransaction.invoice).selectinload(BillingInvoice.payments))
.where(BillingOnlinePaymentTransaction.txnid == txnid)
).scalars().unique().one_or_none()
def process_payumoney_response(db: Session, *, response_data: dict[str, Any]) -> BillingOnlinePaymentTransaction | None:
txnid = str(response_data.get("txnid") or "").strip()
if not txnid:
return None
transaction = get_online_transaction_by_txnid(db, txnid=txnid)
if not transaction:
return None
invoice = transaction.invoice
settings = get_effective_billing_settings(db, tenant_id=invoice.tenant_id, branch_id=invoice.branch_id)
hash_ok = verify_payumoney_response_hash(response_data=response_data, salt=(settings.payumoney_merchant_salt or ""), key=(settings.payumoney_merchant_key or ""))
gateway_status = str(response_data.get("status") or "").lower()
transaction.gateway_status = gateway_status or None
transaction.response_hash = str(response_data.get("hash") or "") or None
transaction.payu_payment_id = str(response_data.get("payuMoneyId") or response_data.get("payu_money_id") or "") or None
transaction.mihpayid = str(response_data.get("mihpayid") or "") or None
transaction.bank_ref_num = str(response_data.get("bank_ref_num") or response_data.get("bank_ref_no") or "") or None
transaction.raw_response = json.dumps({k: str(v) for k, v in response_data.items()}, ensure_ascii=False)
transaction.updated_at_utc = datetime.now(timezone.utc)
if not hash_ok:
transaction.status = "HASH_FAILED"
return transaction
if gateway_status == "success":
transaction.status = "SUCCESS"
transaction.completed_at_utc = datetime.now(timezone.utc)
if not transaction.receipt_payment_id:
payment = record_invoice_payment(
db,
invoice=invoice,
payment_date=date.today(),
amount_received=money(response_data.get("amount") or transaction.amount),
tds_deducted=Decimal("0.00"),
bank_charges=Decimal("0.00"),
mode="ONLINE",
reference_no=transaction.bank_ref_num or transaction.mihpayid or transaction.txnid,
remarks=f"Online payment received through PayUMoney. Txn ID: {transaction.txnid}",
created_by_user_id=None,
payment_gateway=PAYUMONEY_PROVIDER,
gateway_transaction_id=transaction.mihpayid or transaction.payu_payment_id or transaction.txnid,
)
transaction.receipt_payment_id = payment.id
else:
transaction.status = "FAILED"
return transaction
# ---------------------------------------------------------------------------
# Phase 7R.6A - Cashfree Payment Gateway Integration helpers
# ---------------------------------------------------------------------------
CASHFREE_PROVIDER = "CASHFREE"
CASHFREE_API_VERSION_DEFAULT = "2023-08-01"
CASHFREE_TEST_BASE_URL = "https://sandbox.cashfree.com/pg"
CASHFREE_PROD_BASE_URL = "https://api.cashfree.com/pg"
CASHFREE_MODES = ["TEST", "LIVE"]
def cashfree_base_url(settings: BillingSettings) -> str:
return CASHFREE_PROD_BASE_URL if str(getattr(settings, "cashfree_mode", "TEST")).upper() == "LIVE" else CASHFREE_TEST_BASE_URL
def is_cashfree_ready(settings: BillingSettings | None) -> bool:
return bool(
settings
and getattr(settings, "cashfree_enabled", False)
and (getattr(settings, "cashfree_client_id", None) or "").strip()
and (getattr(settings, "cashfree_client_secret", None) or "").strip()
)
def _cashfree_headers(settings: BillingSettings) -> dict[str, str]:
return {
"Content-Type": "application/json",
"Accept": "application/json",
"x-api-version": (getattr(settings, "cashfree_api_version", None) or CASHFREE_API_VERSION_DEFAULT).strip() or CASHFREE_API_VERSION_DEFAULT,
"x-client-id": (settings.cashfree_client_id or "").strip(),
"x-client-secret": (settings.cashfree_client_secret or "").strip(),
}
def _cashfree_api_request(settings: BillingSettings, *, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
url = cashfree_base_url(settings).rstrip("/") + path
body = json.dumps(payload or {}).encode("utf-8") if payload is not None else None
req = UrlRequest(url, data=body, headers=_cashfree_headers(settings), method=method.upper())
try:
with urlopen(req, timeout=30) as response:
raw = response.read().decode("utf-8")
return json.loads(raw or "{}")
except HTTPError as exc:
raw = exc.read().decode("utf-8", errors="replace")
raise ValueError(f"Cashfree API error {exc.code}: {raw}") from exc
except URLError as exc:
raise ValueError(f"Cashfree API connection error: {exc.reason}") from exc
def create_cashfree_transaction(db: Session, *, invoice: BillingInvoice, settings: BillingSettings, base_url: str, client_ip: str | None = None) -> dict[str, Any]:
if not is_cashfree_ready(settings):
raise ValueError("Cashfree is not enabled or client credentials are missing in Billing Settings.")
if invoice.status not in {"ISSUED", "PARTLY_PAID", "OVERDUE"} or money(invoice.balance_amount) <= Decimal("0.00"):
raise ValueError("Only issued invoices with balance can be paid online.")
amount = money(invoice.balance_amount)
order_id = f"AF{invoice.tenant_id}I{invoice.id}C{int(datetime.now(timezone.utc).timestamp())}"
customer_name = (invoice.client_legal_name or invoice.client_trade_name or getattr(invoice.client, "client_name", None) or "Client").strip()[:120]
customer_email = (invoice.client_email or getattr(invoice.client, "email", None) or settings.contact_email or "no-reply@example.com").strip()
customer_phone = (invoice.client_mobile or getattr(invoice.client, "mobile", None) or settings.contact_mobile or "9999999999").strip()
note = (settings.cashfree_order_note or f"Invoice {invoice.invoice_no}").strip()[:250]
return_url = f"{base_url.rstrip('/')}/client/billing/cashfree/return?order_id={{order_id}}"
notify_url = f"{base_url.rstrip('/')}/client/billing/cashfree/webhook"
payload = {
"order_id": order_id,
"order_amount": float(amount),
"order_currency": "INR",
"customer_details": {
"customer_id": str(invoice.client_id),
"customer_name": customer_name,
"customer_email": customer_email,
"customer_phone": customer_phone,
},
"order_meta": {
"return_url": return_url,
"notify_url": notify_url,
},
"order_note": note,
"order_tags": {
"tenant_id": str(invoice.tenant_id),
"branch_id": str(invoice.branch_id or ""),
"invoice_id": str(invoice.id),
"invoice_no": str(invoice.invoice_no),
"source": "audit_firm_erp",
},
}
response = _cashfree_api_request(settings, method="POST", path="/orders", payload=payload)
payment_session_id = str(response.get("payment_session_id") or "").strip()
if not payment_session_id:
raise ValueError(f"Cashfree order created without payment_session_id: {response}")
transaction = BillingOnlinePaymentTransaction(
tenant_id=invoice.tenant_id,
branch_id=invoice.branch_id,
invoice_id=invoice.id,
client_id=invoice.client_id,
provider=CASHFREE_PROVIDER,
mode=(settings.cashfree_mode or "TEST").upper(),
txnid=order_id,
amount=amount,
productinfo=note,
firstname=customer_name,
email=customer_email,
phone=customer_phone,
status="INITIATED",
gateway_status=str(response.get("order_status") or "ACTIVE"),
cashfree_order_id=order_id,
cashfree_cf_order_id=str(response.get("cf_order_id") or "") or None,
cashfree_payment_session_id=payment_session_id,
raw_response=json.dumps(response, ensure_ascii=False, default=str),
)
db.add(transaction)
db.flush()
return {"transaction": transaction, "payment_session_id": payment_session_id, "order_response": response}
def get_cashfree_transaction_by_order_id(db: Session, *, order_id: str) -> BillingOnlinePaymentTransaction | None:
return db.execute(
select(BillingOnlinePaymentTransaction)
.options(selectinload(BillingOnlinePaymentTransaction.invoice).selectinload(BillingInvoice.payments))
.where(
BillingOnlinePaymentTransaction.provider == CASHFREE_PROVIDER,
BillingOnlinePaymentTransaction.txnid == order_id,
)
).scalars().unique().one_or_none()
def fetch_cashfree_order_status(settings: BillingSettings, *, order_id: str) -> dict[str, Any]:
return _cashfree_api_request(settings, method="GET", path=f"/orders/{order_id}")
def _mark_cashfree_success(db: Session, transaction: BillingOnlinePaymentTransaction, *, amount: Any, reference_no: str | None, raw_payload: dict[str, Any] | None = None) -> BillingOnlinePaymentTransaction:
invoice = transaction.invoice
transaction.status = "SUCCESS"
transaction.gateway_status = "PAID"
transaction.completed_at_utc = datetime.now(timezone.utc)
transaction.updated_at_utc = datetime.now(timezone.utc)
if raw_payload is not None:
transaction.raw_response = json.dumps(raw_payload, ensure_ascii=False, default=str)
if not transaction.receipt_payment_id:
payment = record_invoice_payment(
db,
invoice=invoice,
payment_date=date.today(),
amount_received=money(amount or transaction.amount),
tds_deducted=Decimal("0.00"),
bank_charges=Decimal("0.00"),
mode="ONLINE",
reference_no=reference_no or transaction.cashfree_payment_id or transaction.txnid,
remarks=f"Online payment received through Cashfree. Order ID: {transaction.txnid}",
created_by_user_id=None,
payment_gateway=CASHFREE_PROVIDER,
gateway_transaction_id=reference_no or transaction.cashfree_payment_id or transaction.txnid,
)
transaction.receipt_payment_id = payment.id
return transaction
def process_cashfree_return(db: Session, *, order_id: str) -> BillingOnlinePaymentTransaction | None:
transaction = get_cashfree_transaction_by_order_id(db, order_id=order_id)
if not transaction:
return None
invoice = transaction.invoice
settings = get_effective_billing_settings(db, tenant_id=invoice.tenant_id, branch_id=invoice.branch_id)
order = fetch_cashfree_order_status(settings, order_id=order_id)
transaction.gateway_status = str(order.get("order_status") or "") or transaction.gateway_status
transaction.cashfree_cf_order_id = str(order.get("cf_order_id") or transaction.cashfree_cf_order_id or "") or None
transaction.raw_response = json.dumps(order, ensure_ascii=False, default=str)
transaction.updated_at_utc = datetime.now(timezone.utc)
if str(order.get("order_status") or "").upper() == "PAID":
return _mark_cashfree_success(db, transaction, amount=order.get("order_amount") or transaction.amount, reference_no=str(order.get("cf_order_id") or order_id), raw_payload=order)
if str(order.get("order_status") or "").upper() in {"EXPIRED", "TERMINATED", "CANCELLED"}:
transaction.status = "FAILED"
return transaction
def verify_cashfree_webhook_signature(*, raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
if not raw_body or not timestamp or not signature or not secret:
return False
signed_payload = timestamp.encode("utf-8") + raw_body
digest = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode("utf-8")
return hmac.compare_digest(expected, signature)
def process_cashfree_webhook(db: Session, *, raw_body: bytes, headers: dict[str, str]) -> BillingOnlinePaymentTransaction | None:
payload = json.loads(raw_body.decode("utf-8") or "{}")
data = payload.get("data") or payload
order = data.get("order") or data
payment = data.get("payment") or {}
order_id = str(order.get("order_id") or data.get("order_id") or "").strip()
if not order_id:
return None
transaction = get_cashfree_transaction_by_order_id(db, order_id=order_id)
if not transaction:
return None
invoice = transaction.invoice
settings = get_effective_billing_settings(db, tenant_id=invoice.tenant_id, branch_id=invoice.branch_id)
timestamp = headers.get("x-webhook-timestamp") or headers.get("X-Webhook-Timestamp") or ""
signature = headers.get("x-webhook-signature") or headers.get("X-Webhook-Signature") or ""
if not verify_cashfree_webhook_signature(raw_body=raw_body, timestamp=timestamp, signature=signature, secret=(settings.cashfree_client_secret or "")):
transaction.status = "HASH_FAILED"
transaction.gateway_status = "WEBHOOK_SIGNATURE_FAILED"
transaction.raw_response = raw_body.decode("utf-8", errors="replace")
transaction.updated_at_utc = datetime.now(timezone.utc)
return transaction
event_id = str(payload.get("event_id") or payload.get("cf_event_id") or "") or None
if event_id and transaction.webhook_event_id == event_id and transaction.status == "SUCCESS":
return transaction
transaction.webhook_event_id = event_id
transaction.cashfree_payment_id = str(payment.get("cf_payment_id") or payment.get("payment_id") or "") or transaction.cashfree_payment_id
transaction.gateway_status = str(payment.get("payment_status") or order.get("order_status") or payload.get("type") or "") or None
transaction.raw_response = raw_body.decode("utf-8", errors="replace")
transaction.updated_at_utc = datetime.now(timezone.utc)
status_text = (transaction.gateway_status or "").upper()
if "SUCCESS" in status_text or status_text == "PAID" or str(order.get("order_status") or "").upper() == "PAID":
amount = payment.get("payment_amount") or order.get("order_amount") or transaction.amount
reference = transaction.cashfree_payment_id or str(payment.get("bank_reference") or order.get("cf_order_id") or order_id)
return _mark_cashfree_success(db, transaction, amount=amount, reference_no=reference, raw_payload=payload)
if "FAILED" in status_text or "CANCELLED" in status_text or "EXPIRED" in status_text:
transaction.status = "FAILED"
return transaction