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

1067 lines
43 KiB
Python

from __future__ import annotations
from datetime import date, datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from typing import Iterable
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import joinedload
from app.modules.clients.models import Client
from app.modules.consultants.models import ConsultantManagedClient, ConsultantProfile
from app.modules.employees.models import Employee
from app.modules.core.tenancy.models import Branch, Tenant
from app.modules.platform_billing.models import (
PlatformBillingAccount,
PlatformInvoice,
PlatformInvoiceLine,
PlatformPayment,
PlatformPlan,
PlatformPlanFeature,
PlatformSubscription,
)
ACCOUNT_TYPES = ["AUDIT_FIRM", "CLIENT", "CONSULTANT", "MARKETPLACE_CUSTOMER"]
BILLING_CYCLES = ["Monthly", "Quarterly", "Yearly", "One-time"]
TAX_TYPES = ["CGST_SGST", "IGST", "NO_GST"]
CHARGE_TYPES = [
"SUBSCRIPTION",
"CLIENT_USAGE",
"CONSULTANT_USAGE",
"EMPLOYEE_USAGE",
"BRANCH_USAGE",
"MODULE_CHARGE",
"COMPLIANCE_DASHBOARD",
"CONSULTANT_PORTAL",
"MANAGED_CLIENT_USAGE",
"USER_ACCOUNT_USAGE",
"SERVICE_REQUEST_USAGE",
"GSTIN_USAGE",
"PAN_USAGE",
"COMPLIANCE_MODULE_USAGE",
"LEAD_FEE",
"LEAD_COMMISSION",
"AI_CREDITS",
"STORAGE",
"OTHER",
]
def money(value) -> Decimal:
if value in (None, ""):
return Decimal("0.00")
return Decimal(str(value)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def parse_date(value: str | None) -> date | None:
if not value:
return None
return datetime.strptime(value, "%Y-%m-%d").date()
def _tax_split(taxable: Decimal, gst_rate: Decimal, tax_type: str) -> tuple[Decimal, Decimal, Decimal]:
taxable = money(taxable)
gst_rate = money(gst_rate)
if tax_type == "NO_GST" or gst_rate <= 0:
return money(0), money(0), money(0)
tax = money(taxable * gst_rate / Decimal("100"))
if tax_type == "IGST":
return money(0), money(0), tax
half = money(tax / Decimal("2"))
return half, money(tax - half), money(0)
def list_platform_plans(db, q: str = "") -> list[PlatformPlan]:
stmt = select(PlatformPlan).order_by(PlatformPlan.target_account_type.asc(), PlatformPlan.name.asc())
if q:
like = f"%{q}%"
stmt = stmt.where(or_(PlatformPlan.code.ilike(like), PlatformPlan.name.ilike(like)))
return list(db.execute(stmt).scalars().all())
def create_platform_plan(db, *, code: str, name: str, target_account_type: str, billing_cycle: str, base_amount, gst_rate, description: str | None, feature_text: str | None) -> PlatformPlan:
plan = PlatformPlan(
code=code.strip(),
name=name.strip(),
target_account_type=target_account_type,
billing_cycle=billing_cycle,
base_amount=money(base_amount),
gst_rate=money(gst_rate),
description=description or None,
is_active=True,
)
db.add(plan)
db.flush()
for idx, raw in enumerate((feature_text or "").splitlines(), start=1):
raw = raw.strip()
if not raw:
continue
code_part = raw.upper().replace(" ", "_")[:80]
db.add(PlatformPlanFeature(plan_id=plan.id, feature_code=code_part, feature_name=raw, sort_order=idx))
db.commit()
db.refresh(plan)
return plan
def list_platform_accounts(db, q: str = "", account_type: str = "") -> list[PlatformBillingAccount]:
stmt = select(PlatformBillingAccount).order_by(PlatformBillingAccount.account_type.asc(), PlatformBillingAccount.display_name.asc())
if account_type:
stmt = stmt.where(PlatformBillingAccount.account_type == account_type)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(PlatformBillingAccount.account_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.email.ilike(like)))
return list(db.execute(stmt).scalars().all())
def list_reference_audit_firms(db) -> list[Tenant]:
return list(db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all())
def list_reference_clients(db) -> list[Client]:
return list(db.execute(select(Client).order_by(Client.client_name.asc())).scalars().all())
def list_reference_consultants(db) -> list[ConsultantProfile]:
return list(db.execute(select(ConsultantProfile).order_by(ConsultantProfile.contact_person.asc())).scalars().all())
def create_platform_account(db, *, account_type: str, account_code: str, display_name: str, tenant_id: int | None, client_id: int | None, consultant_id: int | None, email: str | None, mobile: str | None, gstin: str | None, pan: str | None, billing_address: str | None, state: str | None, notes: str | None, user_id: int | None) -> PlatformBillingAccount:
account = PlatformBillingAccount(
account_type=account_type,
account_code=account_code.strip(),
display_name=display_name.strip(),
tenant_id=tenant_id,
client_id=client_id,
consultant_id=consultant_id,
email=email or None,
mobile=mobile or None,
gstin=gstin or None,
pan=pan or None,
billing_address=billing_address or None,
state=state or None,
notes=notes or None,
created_by_user_id=user_id,
updated_by_user_id=user_id,
status="ACTIVE",
)
db.add(account)
db.commit()
db.refresh(account)
return account
def list_platform_subscriptions(db, q: str = "") -> list[PlatformSubscription]:
stmt = select(PlatformSubscription).options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan)).order_by(PlatformSubscription.id.desc())
if q:
like = f"%{q}%"
stmt = stmt.join(PlatformBillingAccount).where(or_(PlatformSubscription.subscription_code.ilike(like), PlatformBillingAccount.display_name.ilike(like)))
return list(db.execute(stmt).scalars().all())
def create_platform_subscription(db, *, account_id: int, plan_id: int, subscription_code: str, start_date: date, end_date: date | None, billing_cycle: str, amount, gst_rate, auto_generate_invoice: bool, notes: str | None, user_id: int | None) -> PlatformSubscription:
sub = PlatformSubscription(
account_id=account_id,
plan_id=plan_id,
subscription_code=subscription_code.strip(),
start_date=start_date,
end_date=end_date,
billing_cycle=billing_cycle,
amount=money(amount),
gst_rate=money(gst_rate),
auto_generate_invoice=auto_generate_invoice,
notes=notes or None,
status="ACTIVE",
created_by_user_id=user_id,
)
db.add(sub)
db.commit()
db.refresh(sub)
return sub
# -----------------------------------------------------------------------------
# PB2 - Audit Firm Subscription Billing helpers
# -----------------------------------------------------------------------------
def list_audit_firm_accounts(db, q: str = "") -> list[PlatformBillingAccount]:
stmt = (
select(PlatformBillingAccount)
.where(PlatformBillingAccount.account_type == "AUDIT_FIRM")
.order_by(PlatformBillingAccount.display_name.asc())
)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(PlatformBillingAccount.account_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.email.ilike(like)))
return list(db.execute(stmt).scalars().all())
def sync_audit_firm_billing_accounts(db, *, user_id: int | None = None) -> dict:
"""Create/update platform billing accounts for every active Audit Firm.
This only touches platform_billing_accounts with account_type=AUDIT_FIRM.
It does not alter tenants, firm billing invoices, or any firm-owned records.
"""
tenants = list(db.execute(select(Tenant).order_by(Tenant.name.asc())).scalars().all())
created = 0
updated = 0
for tenant in tenants:
existing = db.execute(
select(PlatformBillingAccount).where(
or_(
and_(PlatformBillingAccount.account_type == "AUDIT_FIRM", PlatformBillingAccount.tenant_id == tenant.id),
and_(PlatformBillingAccount.account_type == "AUDIT_FIRM", PlatformBillingAccount.account_code == tenant.code),
)
)
).scalars().first()
if existing:
changed = False
if existing.tenant_id != tenant.id:
existing.tenant_id = tenant.id
changed = True
if existing.account_code != tenant.code:
existing.account_code = tenant.code
changed = True
if existing.display_name != tenant.name:
existing.display_name = tenant.name
changed = True
if existing.status != "ACTIVE" and getattr(tenant, "is_active", True):
existing.status = "ACTIVE"
changed = True
if changed:
existing.updated_by_user_id = user_id
updated += 1
continue
db.add(
PlatformBillingAccount(
account_type="AUDIT_FIRM",
account_code=tenant.code,
display_name=tenant.name,
tenant_id=tenant.id,
status="ACTIVE" if getattr(tenant, "is_active", True) else "INACTIVE",
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
)
created += 1
db.commit()
return {"created": created, "updated": updated, "total": len(tenants)}
def list_audit_firm_subscriptions(db, q: str = "") -> list[PlatformSubscription]:
stmt = (
select(PlatformSubscription)
.join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id)
.options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan))
.where(PlatformBillingAccount.account_type == "AUDIT_FIRM")
.order_by(PlatformBillingAccount.display_name.asc(), PlatformSubscription.id.desc())
)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(PlatformSubscription.subscription_code.ilike(like), PlatformBillingAccount.display_name.ilike(like)))
return list(db.execute(stmt).scalars().all())
def _count_scalar(db, stmt) -> int:
value = db.execute(stmt).scalar()
return int(value or 0)
def get_audit_firm_usage_counts(db, tenant_id: int | None) -> dict[str, int]:
if not tenant_id:
return {"clients": 0, "branches": 0, "employees": 0, "consultants": 0}
return {
"clients": _count_scalar(db, select(func.count()).select_from(Client).where(Client.tenant_id == tenant_id, Client.is_archived.is_(False), Client.is_active.is_(True))),
"branches": _count_scalar(db, select(func.count()).select_from(Branch).where(Branch.tenant_id == tenant_id, Branch.is_active.is_(True))),
"employees": _count_scalar(db, select(func.count()).select_from(Employee).where(Employee.tenant_id == tenant_id, Employee.is_active.is_(True))),
"consultants": _count_scalar(db, select(func.count()).select_from(ConsultantProfile).where(ConsultantProfile.tenant_id == tenant_id, ConsultantProfile.is_active.is_(True))),
}
def list_audit_firm_subscription_rows(db, q: str = "") -> list[dict]:
rows = []
for sub in list_audit_firm_subscriptions(db, q=q):
rows.append({"subscription": sub, "usage": get_audit_firm_usage_counts(db, sub.account.tenant_id if sub.account else None)})
return rows
def _platform_invoice_exists_for_subscription_period(db, *, subscription_id: int, period_from: date, period_to: date) -> PlatformInvoice | None:
return db.execute(
select(PlatformInvoice).where(
PlatformInvoice.subscription_id == subscription_id,
PlatformInvoice.billing_period_from == period_from,
PlatformInvoice.billing_period_to == period_to,
PlatformInvoice.status != "CANCELLED",
)
).scalars().first()
def _next_audit_firm_platform_invoice_no(db, *, subscription: PlatformSubscription, invoice_date: date) -> str:
base = f"PB/AF/{invoice_date.strftime('%Y%m')}/{subscription.id:05d}"
candidate = base
suffix = 1
while db.execute(select(PlatformInvoice.id).where(PlatformInvoice.invoice_no == candidate)).scalar() is not None:
suffix += 1
candidate = f"{base}-{suffix}"
return candidate
def generate_audit_firm_subscription_invoices(
db,
*,
subscription_ids: list[int],
period_from: date,
period_to: date,
invoice_date: date,
due_date: date | None,
tax_type: str,
client_rate,
employee_rate,
consultant_rate,
branch_rate,
include_zero_usage_lines: bool = False,
user_id: int | None = None,
) -> dict:
"""Generate draft platform invoices for Audit Firm subscriptions.
Duplicate prevention is based on subscription + billing period. Generated
invoices are kept as DRAFT for review/posting by System Admin.
"""
clean_ids = [int(x) for x in subscription_ids if str(x).strip()]
if not clean_ids:
return {"created": 0, "skipped": 0, "errors": ["No subscriptions selected."], "invoices": []}
stmt = (
select(PlatformSubscription)
.join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id)
.options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan))
.where(PlatformSubscription.id.in_(clean_ids), PlatformBillingAccount.account_type == "AUDIT_FIRM")
)
subscriptions = list(db.execute(stmt).scalars().all())
created = 0
skipped = 0
errors: list[str] = []
invoices: list[PlatformInvoice] = []
rate_map = {
"CLIENT_USAGE": money(client_rate),
"EMPLOYEE_USAGE": money(employee_rate),
"CONSULTANT_USAGE": money(consultant_rate),
"BRANCH_USAGE": money(branch_rate),
}
for sub in subscriptions:
account = sub.account
if not account or account.account_type != "AUDIT_FIRM" or not account.tenant_id:
skipped += 1
errors.append(f"Skipped subscription {sub.subscription_code}: not linked to an Audit Firm account.")
continue
if sub.status != "ACTIVE" or not sub.auto_generate_invoice:
skipped += 1
errors.append(f"Skipped {account.display_name}: subscription is not active/auto-generate enabled.")
continue
existing = _platform_invoice_exists_for_subscription_period(db, subscription_id=sub.id, period_from=period_from, period_to=period_to)
if existing:
skipped += 1
errors.append(f"Skipped {account.display_name}: invoice already exists for this period ({existing.invoice_no}).")
continue
usage = get_audit_firm_usage_counts(db, account.tenant_id)
line_items: list[dict] = []
if money(sub.amount) > 0:
line_items.append({
"charge_type": "SUBSCRIPTION",
"description": f"{sub.plan.name if sub.plan else 'Audit Firm Subscription'} - {period_from.strftime('%d-%m-%Y')} to {period_to.strftime('%d-%m-%Y')}",
"quantity": "1",
"rate": sub.amount,
"discount_amount": "0",
"gst_rate": sub.gst_rate,
"reference_type": "PLATFORM_SUBSCRIPTION",
"reference_id": sub.id,
})
usage_specs = [
("CLIENT_USAGE", "Client usage", usage["clients"]),
("EMPLOYEE_USAGE", "Employee usage", usage["employees"]),
("CONSULTANT_USAGE", "Consultant usage", usage["consultants"]),
("BRANCH_USAGE", "Branch usage", usage["branches"]),
]
for charge_type, label, count in usage_specs:
rate = rate_map[charge_type]
if rate > 0 and (count > 0 or include_zero_usage_lines):
line_items.append({
"charge_type": charge_type,
"description": f"{label} for {account.display_name} ({period_from.strftime('%b %Y')})",
"quantity": str(count),
"rate": rate,
"discount_amount": "0",
"gst_rate": sub.gst_rate,
"reference_type": "AUDIT_FIRM",
"reference_id": account.tenant_id,
})
if not line_items:
skipped += 1
errors.append(f"Skipped {account.display_name}: no billable line items.")
continue
invoice = create_platform_invoice(
db,
account_id=account.id,
subscription_id=sub.id,
invoice_no=_next_audit_firm_platform_invoice_no(db, subscription=sub, invoice_date=invoice_date),
invoice_date=invoice_date,
due_date=due_date,
billing_period_from=period_from,
billing_period_to=period_to,
tax_type=tax_type,
line_items=line_items,
notes="Generated from Audit Firm subscription billing (PB2).",
user_id=user_id,
)
created += 1
invoices.append(invoice)
return {"created": created, "skipped": skipped, "errors": errors, "invoices": invoices}
# -----------------------------------------------------------------------------
# PB3 - Client Compliance Dashboard Billing helpers
# -----------------------------------------------------------------------------
CLIENT_COMPLIANCE_FLAGS = [
("gst_applicable", "GST"),
("income_tax_applicable", "Income Tax"),
("tds_applicable", "TDS"),
("roc_applicable", "ROC"),
("audit_applicable", "Audit"),
("pf_applicable", "PF"),
("esi_applicable", "ESI"),
("professional_tax_applicable", "Professional Tax"),
("payroll_applicable", "Payroll"),
("msme_applicable", "MSME"),
("import_export_applicable", "Import/Export"),
]
def _client_account_code(client: Client) -> str:
return f"CL-{client.id:06d}"
def list_client_dashboard_accounts(db, q: str = "") -> list[PlatformBillingAccount]:
stmt = (
select(PlatformBillingAccount)
.where(PlatformBillingAccount.account_type == "CLIENT")
.order_by(PlatformBillingAccount.display_name.asc())
)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(PlatformBillingAccount.account_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.email.ilike(like)))
return list(db.execute(stmt).scalars().all())
def sync_client_dashboard_billing_accounts(db, *, user_id: int | None = None) -> dict:
"""Create/update platform billing accounts for active clients.
This is for Platform/System Admin billing of client compliance dashboard
access. It only touches platform_billing_accounts with account_type=CLIENT.
It does not alter client master records or firm-level billing invoices.
"""
clients = list(
db.execute(
select(Client)
.where(Client.is_archived.is_(False), Client.is_active.is_(True))
.order_by(Client.client_name.asc())
).scalars().all()
)
created = 0
updated = 0
for client in clients:
code = _client_account_code(client)
display_name = client.client_name
address_parts = [client.address_line_1, client.address_line_2, client.city, client.state, client.pincode]
billing_address = ", ".join([part for part in address_parts if part]) or None
existing = db.execute(
select(PlatformBillingAccount).where(
or_(
and_(PlatformBillingAccount.account_type == "CLIENT", PlatformBillingAccount.client_id == client.id),
and_(PlatformBillingAccount.account_type == "CLIENT", PlatformBillingAccount.account_code == code),
)
)
).scalars().first()
if existing:
changed = False
updates = {
"account_code": code,
"display_name": display_name,
"tenant_id": client.tenant_id,
"client_id": client.id,
"email": client.email,
"mobile": client.mobile,
"gstin": client.gstin,
"pan": client.pan,
"billing_address": billing_address,
"state": client.state,
"status": "ACTIVE",
}
for field, value in updates.items():
if getattr(existing, field) != value:
setattr(existing, field, value)
changed = True
if changed:
existing.updated_by_user_id = user_id
updated += 1
continue
db.add(
PlatformBillingAccount(
account_type="CLIENT",
account_code=code,
display_name=display_name,
tenant_id=client.tenant_id,
client_id=client.id,
email=client.email,
mobile=client.mobile,
gstin=client.gstin,
pan=client.pan,
billing_address=billing_address,
state=client.state,
status="ACTIVE",
created_by_user_id=user_id,
updated_by_user_id=user_id,
)
)
created += 1
db.commit()
return {"created": created, "updated": updated, "total": len(clients)}
def list_client_dashboard_plans(db) -> list[PlatformPlan]:
return list(
db.execute(
select(PlatformPlan)
.where(PlatformPlan.target_account_type == "CLIENT", PlatformPlan.is_active.is_(True))
.order_by(PlatformPlan.name.asc())
).scalars().all()
)
def list_client_dashboard_subscriptions(db, q: str = "") -> list[PlatformSubscription]:
stmt = (
select(PlatformSubscription)
.join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id)
.options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan))
.where(PlatformBillingAccount.account_type == "CLIENT")
.order_by(PlatformBillingAccount.display_name.asc(), PlatformSubscription.id.desc())
)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(PlatformSubscription.subscription_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.account_code.ilike(like)))
return list(db.execute(stmt).scalars().all())
def get_client_dashboard_usage(db, client_id: int | None) -> dict:
if not client_id:
return {"pan_units": 0, "gstin_units": 0, "module_count": 0, "modules": []}
client = db.get(Client, client_id)
if not client:
return {"pan_units": 0, "gstin_units": 0, "module_count": 0, "modules": []}
modules = [label for attr, label in CLIENT_COMPLIANCE_FLAGS if bool(getattr(client, attr, False))]
return {
"pan_units": 1 if client.pan else 0,
"gstin_units": 1 if client.gstin else 0,
"module_count": len(modules),
"modules": modules,
}
def list_client_dashboard_subscription_rows(db, q: str = "") -> list[dict]:
rows = []
for sub in list_client_dashboard_subscriptions(db, q=q):
rows.append({"subscription": sub, "usage": get_client_dashboard_usage(db, sub.account.client_id if sub.account else None)})
return rows
def _next_client_dashboard_invoice_no(db, *, subscription: PlatformSubscription, invoice_date: date) -> str:
base = f"PB/CL/{invoice_date.strftime('%Y%m')}/{subscription.id:05d}"
candidate = base
suffix = 1
while db.execute(select(PlatformInvoice.id).where(PlatformInvoice.invoice_no == candidate)).scalar() is not None:
suffix += 1
candidate = f"{base}-{suffix}"
return candidate
def generate_client_dashboard_subscription_invoices(
db,
*,
subscription_ids: list[int],
period_from: date,
period_to: date,
invoice_date: date,
due_date: date | None,
tax_type: str,
pan_rate,
gstin_rate,
module_rate,
include_zero_usage_lines: bool = False,
user_id: int | None = None,
) -> dict:
"""Generate draft platform invoices for client compliance dashboard subscriptions."""
clean_ids = [int(x) for x in subscription_ids if str(x).strip()]
if not clean_ids:
return {"created": 0, "skipped": 0, "errors": ["No client dashboard subscriptions selected."], "invoices": []}
stmt = (
select(PlatformSubscription)
.join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id)
.options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan))
.where(PlatformSubscription.id.in_(clean_ids), PlatformBillingAccount.account_type == "CLIENT")
)
subscriptions = list(db.execute(stmt).scalars().all())
created = 0
skipped = 0
errors: list[str] = []
invoices: list[PlatformInvoice] = []
pan_rate = money(pan_rate)
gstin_rate = money(gstin_rate)
module_rate = money(module_rate)
for sub in subscriptions:
account = sub.account
if not account or account.account_type != "CLIENT" or not account.client_id:
skipped += 1
errors.append(f"Skipped subscription {sub.subscription_code}: not linked to a client account.")
continue
if sub.status != "ACTIVE" or not sub.auto_generate_invoice:
skipped += 1
errors.append(f"Skipped {account.display_name}: subscription is not active/auto-generate enabled.")
continue
existing = _platform_invoice_exists_for_subscription_period(db, subscription_id=sub.id, period_from=period_from, period_to=period_to)
if existing:
skipped += 1
errors.append(f"Skipped {account.display_name}: invoice already exists for this period ({existing.invoice_no}).")
continue
usage = get_client_dashboard_usage(db, account.client_id)
line_items: list[dict] = []
if money(sub.amount) > 0:
line_items.append({
"charge_type": "COMPLIANCE_DASHBOARD",
"description": f"{sub.plan.name if sub.plan else 'Client Compliance Dashboard'} - {period_from.strftime('%d-%m-%Y')} to {period_to.strftime('%d-%m-%Y')}",
"quantity": "1",
"rate": sub.amount,
"discount_amount": "0",
"gst_rate": sub.gst_rate,
"reference_type": "CLIENT_DASHBOARD_SUBSCRIPTION",
"reference_id": sub.id,
})
usage_specs = [
("PAN_USAGE", "PAN dashboard access", usage["pan_units"], pan_rate),
("GSTIN_USAGE", "GSTIN dashboard access", usage["gstin_units"], gstin_rate),
("COMPLIANCE_MODULE_USAGE", "Compliance modules", usage["module_count"], module_rate),
]
for charge_type, label, quantity, rate in usage_specs:
if rate > 0 and (quantity > 0 or include_zero_usage_lines):
extra = ""
if charge_type == "COMPLIANCE_MODULE_USAGE" and usage["modules"]:
extra = f" ({', '.join(usage['modules'])})"
line_items.append({
"charge_type": charge_type,
"description": f"{label}{extra} for {account.display_name} ({period_from.strftime('%b %Y')})",
"quantity": str(quantity),
"rate": rate,
"discount_amount": "0",
"gst_rate": sub.gst_rate,
"reference_type": "CLIENT",
"reference_id": account.client_id,
})
if not line_items:
skipped += 1
errors.append(f"Skipped {account.display_name}: no billable line items.")
continue
invoice = create_platform_invoice(
db,
account_id=account.id,
subscription_id=sub.id,
invoice_no=_next_client_dashboard_invoice_no(db, subscription=sub, invoice_date=invoice_date),
invoice_date=invoice_date,
due_date=due_date,
billing_period_from=period_from,
billing_period_to=period_to,
tax_type=tax_type,
line_items=line_items,
notes="Generated from Client Compliance Dashboard subscription billing (PB3).",
user_id=user_id,
)
created += 1
invoices.append(invoice)
return {"created": created, "skipped": skipped, "errors": errors, "invoices": invoices}
# -----------------------------------------------------------------------------
# PB4 - Consultant SaaS / Tool Access Billing helpers
# -----------------------------------------------------------------------------
def _consultant_account_code(consultant: ConsultantProfile) -> str:
return f"CON-{consultant.id:06d}"
def _consultant_display_name(consultant: ConsultantProfile) -> str:
if consultant.firm_name and consultant.contact_person:
return f"{consultant.firm_name} - {consultant.contact_person}"
return consultant.firm_name or consultant.contact_person or f"Consultant {consultant.id}"
def list_consultant_billing_accounts(db, q: str = "") -> list[PlatformBillingAccount]:
stmt = (
select(PlatformBillingAccount)
.where(PlatformBillingAccount.account_type == "CONSULTANT")
.order_by(PlatformBillingAccount.display_name.asc())
)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(PlatformBillingAccount.account_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.email.ilike(like)))
return list(db.execute(stmt).scalars().all())
def sync_consultant_billing_accounts(db, *, user_id: int | None = None) -> dict:
"""Create/update platform billing accounts for active consultants.
This is for Platform/System Admin billing of consultant SaaS/tool access.
It only touches platform_billing_accounts with account_type=CONSULTANT.
It does not alter consultant profiles, consultant workspace data, firm billing,
or firm-owned client invoices.
"""
consultants = list(
db.execute(
select(ConsultantProfile)
.where(ConsultantProfile.is_active.is_(True))
.order_by(ConsultantProfile.contact_person.asc())
).scalars().all()
)
created = 0
updated = 0
for consultant in consultants:
code = _consultant_account_code(consultant)
display_name = _consultant_display_name(consultant)
existing = db.execute(
select(PlatformBillingAccount).where(
or_(
and_(PlatformBillingAccount.account_type == "CONSULTANT", PlatformBillingAccount.consultant_id == consultant.id),
and_(PlatformBillingAccount.account_type == "CONSULTANT", PlatformBillingAccount.account_code == code),
)
)
).scalars().first()
updates = {
"account_code": code,
"display_name": display_name,
"tenant_id": consultant.tenant_id,
"consultant_id": consultant.id,
"email": consultant.email,
"mobile": consultant.mobile,
"gstin": consultant.gstin,
"pan": consultant.pan,
"billing_address": consultant.address,
"status": "ACTIVE",
}
if existing:
changed = False
for field, value in updates.items():
if getattr(existing, field) != value:
setattr(existing, field, value)
changed = True
if changed:
existing.updated_by_user_id = user_id
updated += 1
continue
db.add(
PlatformBillingAccount(
account_type="CONSULTANT",
created_by_user_id=user_id,
updated_by_user_id=user_id,
**updates,
)
)
created += 1
db.commit()
return {"created": created, "updated": updated, "total": len(consultants)}
def list_consultant_billing_plans(db) -> list[PlatformPlan]:
return list(
db.execute(
select(PlatformPlan)
.where(PlatformPlan.target_account_type == "CONSULTANT", PlatformPlan.is_active.is_(True))
.order_by(PlatformPlan.name.asc())
).scalars().all()
)
def list_consultant_billing_subscriptions(db, q: str = "") -> list[PlatformSubscription]:
stmt = (
select(PlatformSubscription)
.join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id)
.options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan))
.where(PlatformBillingAccount.account_type == "CONSULTANT")
.order_by(PlatformBillingAccount.display_name.asc(), PlatformSubscription.id.desc())
)
if q:
like = f"%{q}%"
stmt = stmt.where(or_(PlatformSubscription.subscription_code.ilike(like), PlatformBillingAccount.display_name.ilike(like), PlatformBillingAccount.account_code.ilike(like)))
return list(db.execute(stmt).scalars().all())
def get_consultant_tool_usage(db, consultant_id: int | None) -> dict:
if not consultant_id:
return {"managed_client_count": 0, "user_account_count": 1, "workspace_type": "-", "workspace_plan": "-"}
consultant = db.get(ConsultantProfile, consultant_id)
managed_client_count = db.execute(
select(func.count(ConsultantManagedClient.id)).where(
ConsultantManagedClient.consultant_id == consultant_id,
ConsultantManagedClient.is_active.is_(True),
)
).scalar() or 0
workspace = getattr(consultant, "workspace", None) if consultant else None
workspace_type = "-"
if consultant:
workspace_type = getattr(workspace, "workspace_type", None) or getattr(consultant, "consultant_type", "-") or "-"
return {
"managed_client_count": int(managed_client_count),
"user_account_count": 1 if consultant and consultant.user_id else 0,
"workspace_type": workspace_type,
"workspace_plan": getattr(workspace, "plan_code", None) or "-",
}
def list_consultant_billing_subscription_rows(db, q: str = "") -> list[dict]:
rows = []
for sub in list_consultant_billing_subscriptions(db, q=q):
rows.append({"subscription": sub, "usage": get_consultant_tool_usage(db, sub.account.consultant_id if sub.account else None)})
return rows
def _next_consultant_invoice_no(db, *, subscription: PlatformSubscription, invoice_date: date) -> str:
base = f"PB/CON/{invoice_date.strftime('%Y%m')}/{subscription.id:05d}"
candidate = base
suffix = 1
while db.execute(select(PlatformInvoice.id).where(PlatformInvoice.invoice_no == candidate)).scalar() is not None:
suffix += 1
candidate = f"{base}-{suffix}"
return candidate
def generate_consultant_subscription_invoices(
db,
*,
subscription_ids: list[int],
period_from: date,
period_to: date,
invoice_date: date,
due_date: date | None,
tax_type: str,
managed_client_rate,
user_account_rate,
include_zero_usage_lines: bool = False,
user_id: int | None = None,
) -> dict:
"""Generate draft platform invoices for consultant SaaS/tool subscriptions."""
clean_ids = [int(x) for x in subscription_ids if str(x).strip()]
if not clean_ids:
return {"created": 0, "skipped": 0, "errors": ["No consultant subscriptions selected."], "invoices": []}
stmt = (
select(PlatformSubscription)
.join(PlatformBillingAccount, PlatformSubscription.account_id == PlatformBillingAccount.id)
.options(joinedload(PlatformSubscription.account), joinedload(PlatformSubscription.plan))
.where(PlatformSubscription.id.in_(clean_ids), PlatformBillingAccount.account_type == "CONSULTANT")
)
subscriptions = list(db.execute(stmt).scalars().all())
created = 0
skipped = 0
errors: list[str] = []
invoices: list[PlatformInvoice] = []
managed_client_rate = money(managed_client_rate)
user_account_rate = money(user_account_rate)
for sub in subscriptions:
account = sub.account
if not account or account.account_type != "CONSULTANT" or not account.consultant_id:
skipped += 1
errors.append(f"Skipped subscription {sub.subscription_code}: not linked to a consultant account.")
continue
if sub.status != "ACTIVE" or not sub.auto_generate_invoice:
skipped += 1
errors.append(f"Skipped {account.display_name}: subscription is not active/auto-generate enabled.")
continue
existing = _platform_invoice_exists_for_subscription_period(db, subscription_id=sub.id, period_from=period_from, period_to=period_to)
if existing:
skipped += 1
errors.append(f"Skipped {account.display_name}: invoice already exists for this period ({existing.invoice_no}).")
continue
usage = get_consultant_tool_usage(db, account.consultant_id)
line_items: list[dict] = []
if money(sub.amount) > 0:
line_items.append({
"charge_type": "CONSULTANT_PORTAL",
"description": f"{sub.plan.name if sub.plan else 'Consultant Tool Access'} - {period_from.strftime('%d-%m-%Y')} to {period_to.strftime('%d-%m-%Y')}",
"quantity": "1",
"rate": sub.amount,
"discount_amount": "0",
"gst_rate": sub.gst_rate,
"reference_type": "CONSULTANT_SUBSCRIPTION",
"reference_id": sub.id,
})
usage_specs = [
("MANAGED_CLIENT_USAGE", "Managed consultant clients", usage["managed_client_count"], managed_client_rate),
("USER_ACCOUNT_USAGE", "Consultant portal user accounts", usage["user_account_count"], user_account_rate),
]
for charge_type, label, quantity, rate in usage_specs:
if rate > 0 and (quantity > 0 or include_zero_usage_lines):
line_items.append({
"charge_type": charge_type,
"description": f"{label} for {account.display_name} ({period_from.strftime('%b %Y')})",
"quantity": str(quantity),
"rate": rate,
"discount_amount": "0",
"gst_rate": sub.gst_rate,
"reference_type": "CONSULTANT",
"reference_id": account.consultant_id,
})
if not line_items:
skipped += 1
errors.append(f"Skipped {account.display_name}: no billable line items.")
continue
invoice = create_platform_invoice(
db,
account_id=account.id,
subscription_id=sub.id,
invoice_no=_next_consultant_invoice_no(db, subscription=sub, invoice_date=invoice_date),
invoice_date=invoice_date,
due_date=due_date,
billing_period_from=period_from,
billing_period_to=period_to,
tax_type=tax_type,
line_items=line_items,
notes="Generated from Consultant SaaS/tool subscription billing (PB4).",
user_id=user_id,
)
created += 1
invoices.append(invoice)
return {"created": created, "skipped": skipped, "errors": errors, "invoices": invoices}
def list_platform_invoices(db, q: str = "") -> list[PlatformInvoice]:
stmt = select(PlatformInvoice).options(joinedload(PlatformInvoice.account)).order_by(PlatformInvoice.invoice_date.desc(), PlatformInvoice.id.desc())
if q:
like = f"%{q}%"
stmt = stmt.join(PlatformBillingAccount).where(or_(PlatformInvoice.invoice_no.ilike(like), PlatformBillingAccount.display_name.ilike(like)))
return list(db.execute(stmt).scalars().all())
def get_platform_invoice(db, invoice_id: int) -> PlatformInvoice | None:
return db.execute(
select(PlatformInvoice).options(joinedload(PlatformInvoice.account), joinedload(PlatformInvoice.lines)).where(PlatformInvoice.id == invoice_id)
).unique().scalar_one_or_none()
def create_platform_invoice(db, *, account_id: int, subscription_id: int | None, invoice_no: str, invoice_date: date, due_date: date | None, billing_period_from: date | None, billing_period_to: date | None, tax_type: str, line_items: Iterable[dict], notes: str | None, user_id: int | None) -> PlatformInvoice:
invoice = PlatformInvoice(
account_id=account_id,
subscription_id=subscription_id,
invoice_no=invoice_no.strip(),
invoice_date=invoice_date,
due_date=due_date,
billing_period_from=billing_period_from,
billing_period_to=billing_period_to,
tax_type=tax_type,
notes=notes or None,
created_by_user_id=user_id,
status="DRAFT",
)
db.add(invoice)
db.flush()
subtotal = money(0)
taxable_total = money(0)
cgst_total = money(0)
sgst_total = money(0)
igst_total = money(0)
for idx, item in enumerate(line_items, start=1):
description = (item.get("description") or "").strip()
if not description:
continue
quantity = money(item.get("quantity", 1))
rate = money(item.get("rate", 0))
discount = money(item.get("discount_amount", 0))
gst_rate = money(item.get("gst_rate", 18))
taxable = money((quantity * rate) - discount)
if taxable < 0:
taxable = money(0)
cgst, sgst, igst = _tax_split(taxable, gst_rate, tax_type)
line_total = money(taxable + cgst + sgst + igst)
subtotal += money(quantity * rate)
taxable_total += taxable
cgst_total += cgst
sgst_total += sgst
igst_total += igst
db.add(PlatformInvoiceLine(
invoice_id=invoice.id,
charge_type=item.get("charge_type") or "SUBSCRIPTION",
description=description,
reference_type=item.get("reference_type") or None,
reference_id=item.get("reference_id") or None,
quantity=quantity,
rate=rate,
discount_amount=discount,
taxable_amount=taxable,
gst_rate=gst_rate,
cgst_amount=cgst,
sgst_amount=sgst,
igst_amount=igst,
line_total=line_total,
sort_order=idx,
))
invoice.subtotal = money(subtotal)
invoice.discount_amount = money(subtotal - taxable_total)
invoice.taxable_amount = money(taxable_total)
invoice.cgst_amount = money(cgst_total)
invoice.sgst_amount = money(sgst_total)
invoice.igst_amount = money(igst_total)
invoice.total_amount = money(taxable_total + cgst_total + sgst_total + igst_total)
db.commit()
db.refresh(invoice)
return invoice
def post_platform_invoice(db, invoice: PlatformInvoice, user_id: int | None) -> PlatformInvoice:
invoice.status = "POSTED"
invoice.posted_by_user_id = user_id
invoice.posted_at_utc = datetime.now(timezone.utc)
db.commit()
db.refresh(invoice)
return invoice
def record_platform_payment(db, *, invoice: PlatformInvoice, amount, mode: str, reference_no: str | None, notes: str | None, user_id: int | None) -> PlatformPayment:
payment = PlatformPayment(
invoice_id=invoice.id,
account_id=invoice.account_id,
amount=money(amount),
mode=mode,
reference_no=reference_no or None,
notes=notes or None,
created_by_user_id=user_id,
)
db.add(payment)
db.commit()
db.refresh(payment)
return payment