This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
chaim 206a800762 feat(billing): teach Shira the GreenInvoiceBilling extension surface
Adds two new tool categories — billing (10 tools) and legal_aid (6 tools)
— so Shira can drive the full Green Invoice flow: list unbilled activities,
manage charges (approve/cancel/calculate), build draft invoices from
activities, sync to Green Invoice, plus the legal-aid sub-flow (proforma,
mark-submitted, monthly 320 wrap-up, prepare-email, resubmit rejected).

Wires both registrars into agent_runner with allowed_toolsets gating, and
extends TOOL_RULES so Shira (a) shows draft totals before creating, (b)
treats send_invoice_to_green_invoice as irreversible and demands explicit
confirmation, and (c) never marks a legal-aid proforma as submitted
without an explicit request number from the user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 16:32:29 +00:00

421 lines
18 KiB
Python

"""Green Invoice billing tools: activities, charges, invoices, sync to Green Invoice."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from urllib.parse import urlencode
if TYPE_CHECKING:
from mcp_server.espocrm_client import EspoCrmClient
from mcp_server.tools._helpers import normalize_date, ok, fail
DOCUMENT_TYPES = {
"305": "חשבונית מס",
"320": "חשבונית מס/קבלה",
"330": "קבלה",
"400": "חשבון עסקה",
}
CHARGE_STATUSES = [
"Pending", "Approved", "Invoiced", "Cancelled",
"Recorded", "Submitted", "Paid", "Billed", "Rejected",
]
def _to_date(date_str: str | None) -> str | None:
if not date_str:
return None
norm = normalize_date(date_str, default_time="08:00:00")
if norm and len(norm) >= 10:
return norm[:10]
return norm
def _money(amount) -> str:
try:
return f"{float(amount):,.2f}"
except (TypeError, ValueError):
return f"{amount}"
def register_billing_tools(
tools: dict,
crm: "EspoCrmClient",
case_id: str | None,
user_id: str | None,
context: dict,
):
"""Register Green Invoice billing tools (regular flow: activities → charges → invoices → sync)."""
async def list_unbilled_activities(caseId: str = "") -> str:
target = caseId or case_id
if not target:
return fail("צריך מזהה תיק (caseId) או להיות בתוך תיק")
try:
result = await crm.get(f"Invoice/getUnbilledActivities?caseId={target}")
activities = result.get("list", [])
total = result.get("total", len(activities))
if not activities:
return ok("אין פעילויות שטרם חויבו בתיק זה.")
return ok(json.dumps({
"total": total,
"activities": [
{
"id": a.get("id"),
"name": a.get("name"),
"activityType": a.get("activityType"),
"activityDate": a.get("activityDate"),
"billUnits": a.get("billUnits"),
"billRate": a.get("billRate"),
"billAmount": a.get("billAmount"),
"description": a.get("description"),
}
for a in activities
],
}, ensure_ascii=False))
except Exception as e:
return fail(f"שגיאה בשליפת פעילויות לחיוב: {e}")
async def list_charges(
caseId: str = "",
accountId: str = "",
status: str = "",
) -> str:
target_case = caseId or case_id
try:
if status == "Approved":
qs_parts = []
if accountId:
qs_parts.append(f"accountId={accountId}")
if target_case:
qs_parts.append(f"caseId={target_case}")
endpoint = "Charge/action/getApprovedCharges"
if qs_parts:
endpoint += "?" + "&".join(qs_parts)
result = await crm.get(endpoint)
charges = result.get("list", [])
else:
where = []
idx = 0
if status:
where.extend([
(f"where[{idx}][type]", "equals"),
(f"where[{idx}][attribute]", "status"),
(f"where[{idx}][value]", status),
])
idx += 1
if target_case:
where.extend([
(f"where[{idx}][type]", "equals"),
(f"where[{idx}][attribute]", "caseId"),
(f"where[{idx}][value]", target_case),
])
idx += 1
if accountId:
where.extend([
(f"where[{idx}][type]", "equals"),
(f"where[{idx}][attribute]", "accountId"),
(f"where[{idx}][value]", accountId),
])
idx += 1
qs = urlencode(where) if where else ""
endpoint = f"Charge?{qs}" if qs else "Charge"
result = await crm.get(endpoint)
charges = result.get("list", [])
if not charges:
return ok("לא נמצאו חיובים תואמים.")
return ok(json.dumps({
"total": len(charges),
"charges": [
{
"id": c.get("id"),
"name": c.get("name"),
"number": c.get("number"),
"activityType": c.get("activityType"),
"chargeDate": c.get("chargeDate"),
"amount": c.get("amount"),
"status": c.get("status"),
"isLegalAid": c.get("isLegalAid"),
"caseId": c.get("caseId"),
}
for c in charges
],
}, ensure_ascii=False))
except Exception as e:
return fail(f"שגיאה בשליפת חיובים: {e}")
async def approve_charges(chargeIds: list[str]) -> str:
if not chargeIds:
return fail("צריך לפחות מזהה חיוב אחד")
try:
if len(chargeIds) == 1:
result = await crm.post("Charge/action/approve", {"id": chargeIds[0]})
return ok(f"חיוב אושר (ID: {result.get('id')}, סטטוס: {result.get('status')})")
result = await crm.post("Charge/action/approveMultiple", {"ids": chargeIds})
results = result.get("results", [])
success_count = sum(1 for r in results if r.get("success"))
failed = [r for r in results if not r.get("success")]
msg = f"אושרו {success_count} מתוך {len(chargeIds)} חיובים"
if failed:
msg += f". כשלים: {json.dumps(failed, ensure_ascii=False)}"
return ok(msg)
except Exception as e:
return fail(f"שגיאה באישור חיובים: {e}")
async def cancel_charges(chargeIds: list[str]) -> str:
if not chargeIds:
return fail("צריך לפחות מזהה חיוב אחד")
try:
if len(chargeIds) == 1:
result = await crm.post("Charge/action/cancel", {"id": chargeIds[0]})
return ok(f"חיוב בוטל (ID: {result.get('id')}, סטטוס: {result.get('status')})")
result = await crm.post("Charge/action/cancelMultiple", {"ids": chargeIds})
results = result.get("results", [])
success_count = sum(1 for r in results if r.get("success"))
failed = [r for r in results if not r.get("success")]
msg = f"בוטלו {success_count} מתוך {len(chargeIds)} חיובים"
if failed:
msg += f". כשלים: {json.dumps(failed, ensure_ascii=False)}"
return ok(msg)
except Exception as e:
return fail(f"שגיאה בביטול חיובים: {e}")
async def calculate_charges_total(chargeIds: list[str]) -> str:
if not chargeIds:
return fail("צריך לפחות מזהה חיוב אחד")
try:
result = await crm.post("Charge/action/calculateTotal", {"ids": chargeIds})
total = result.get("total", 0)
count = result.get("count", len(chargeIds))
return ok(f"סכום כולל ל-{count} חיובים: {_money(total)}")
except Exception as e:
return fail(f"שגיאה בחישוב סכום חיובים: {e}")
async def create_invoice_from_activities(
activityIds: list[str],
documentType: str = "320",
name: str = "",
issueDate: str = "",
dueDate: str = "",
vatRate: float | None = None,
notes: str = "",
) -> str:
if not activityIds:
return fail("צריך לפחות מזהה פעילות אחד")
if documentType not in DOCUMENT_TYPES:
return fail(f"סוג מסמך לא תקין: {documentType}. תקינים: {list(DOCUMENT_TYPES.keys())}")
body = {
"activityIds": activityIds,
"documentType": documentType,
"name": name or None,
"issueDate": _to_date(issueDate),
"dueDate": _to_date(dueDate),
"vatRate": vatRate,
"notes": notes or None,
}
body = {k: v for k, v in body.items() if v is not None}
try:
result = await crm.post("Invoice/createFromActivities", body)
if not result.get("success"):
return fail(f"יצירת חשבונית נכשלה: {result.get('error', 'unknown')}")
doc_label = DOCUMENT_TYPES.get(documentType, documentType)
return ok(
f"{doc_label} נוצרה במצב טיוטה (ID: {result.get('id')}, "
f"סכום כולל: {_money(result.get('totalAmount'))}). "
f"החשבונית עדיין לא נשלחה לחשבונית ירוקה."
)
except Exception as e:
return fail(f"שגיאה ביצירת חשבונית: {e}")
async def add_activity_to_invoice(invoiceId: str, activityId: str) -> str:
if not invoiceId or not activityId:
return fail("צריך מזהה חשבונית ומזהה פעילות")
try:
result = await crm.post(f"Invoice/{invoiceId}/addActivity", {"activityId": activityId})
if not result.get("success"):
return fail(f"הוספת פעילות נכשלה: {result.get('error', 'unknown')}")
return ok(f"פעילות נוספה לחשבונית. סכום כולל מעודכן: {_money(result.get('totalAmount'))}")
except Exception as e:
return fail(f"שגיאה בהוספת פעילות לחשבונית: {e}")
async def remove_activity_from_invoice(invoiceId: str, activityId: str) -> str:
if not invoiceId or not activityId:
return fail("צריך מזהה חשבונית ומזהה פעילות")
try:
result = await crm.post(f"Invoice/{invoiceId}/removeActivity", {"activityId": activityId})
if not result.get("success"):
return fail(f"הסרת פעילות נכשלה: {result.get('error', 'unknown')}")
return ok(f"פעילות הוסרה מהחשבונית. סכום כולל מעודכן: {_money(result.get('totalAmount'))}")
except Exception as e:
return fail(f"שגיאה בהסרת פעילות מחשבונית: {e}")
async def recalculate_invoice(invoiceId: str) -> str:
if not invoiceId:
return fail("צריך מזהה חשבונית")
try:
result = await crm.post(f"Invoice/{invoiceId}/recalculate", {})
return ok(
f"חשבונית חושבה מחדש: סכום {_money(result.get('amount'))}, "
f"מע״מ {_money(result.get('vatAmount'))}, סה״כ {_money(result.get('totalAmount'))}"
)
except Exception as e:
return fail(f"שגיאה בחישוב מחדש של חשבונית: {e}")
async def send_invoice_to_green_invoice(invoiceId: str) -> str:
if not invoiceId:
return fail("צריך מזהה חשבונית")
try:
result = await crm.post(f"Invoice/{invoiceId}/sendToGreenInvoice", {})
if not result.get("success"):
err = result.get("error") or "שגיאה לא ידועה"
return fail(f"שליחה לחשבונית ירוקה נכשלה: {err}")
data = result.get("data", {}) or {}
gi_number = data.get("greenInvoiceNumber") or data.get("number")
gi_url = data.get("url") or data.get("greenInvoiceUrl")
msg = f"חשבונית נשלחה לחשבונית ירוקה (מס׳ מסמך: {gi_number})"
if gi_url:
msg += f"\nקישור: {gi_url}"
return ok(msg)
except Exception as e:
return fail(f"שגיאה בשליחה לחשבונית ירוקה: {e}")
tools["list_unbilled_activities"] = {
"description": "List billable case activities that have not yet been included in any invoice. Defaults to current case if caseId is omitted.",
"parameters": {
"type": "object",
"properties": {
"caseId": {"type": "string", "description": "Case ID. Optional — defaults to current case context."},
},
},
"handler": list_unbilled_activities,
}
tools["list_charges"] = {
"description": "List charges (חיובים) filtered by case, account, and/or status. Use status='Approved' for charges ready to invoice; 'Pending' for ones awaiting approval; 'Recorded'/'Submitted'/'Paid'/'Rejected' for legal-aid charges.",
"parameters": {
"type": "object",
"properties": {
"caseId": {"type": "string", "description": "Case ID filter (optional, defaults to current case)"},
"accountId": {"type": "string", "description": "Account ID filter (optional)"},
"status": {"type": "string", "enum": CHARGE_STATUSES, "description": "Charge status filter"},
},
},
"handler": list_charges,
}
tools["approve_charges"] = {
"description": "Approve one or more charges (Pending → Approved). Required before they can be invoiced.",
"parameters": {
"type": "object",
"properties": {
"chargeIds": {"type": "array", "items": {"type": "string"}, "description": "One or more charge IDs"},
},
"required": ["chargeIds"],
},
"handler": approve_charges,
}
tools["cancel_charges"] = {
"description": "Cancel one or more charges (Pending/Approved → Cancelled). Terminal — cancelled charges cannot be reactivated.",
"parameters": {
"type": "object",
"properties": {
"chargeIds": {"type": "array", "items": {"type": "string"}, "description": "One or more charge IDs"},
},
"required": ["chargeIds"],
},
"handler": cancel_charges,
}
tools["calculate_charges_total"] = {
"description": "Calculate the total monetary sum across selected charges. Use to preview an invoice total before creating it.",
"parameters": {
"type": "object",
"properties": {
"chargeIds": {"type": "array", "items": {"type": "string"}, "description": "One or more charge IDs"},
},
"required": ["chargeIds"],
},
"handler": calculate_charges_total,
}
tools["create_invoice_from_activities"] = {
"description": (
"Create a draft Invoice from selected unbilled CaseActivity records. "
"Document types: 305=Tax Invoice (חשבונית מס), 320=Tax Invoice+Receipt (default, חשבונית מס/קבלה), "
"330=Receipt (קבלה), 400=Proforma (חשבון עסקה — for legal-aid). "
"Returned invoice is in Draft state — does NOT auto-send to Green Invoice."
),
"parameters": {
"type": "object",
"properties": {
"activityIds": {"type": "array", "items": {"type": "string"}, "description": "Activity IDs to bundle"},
"documentType": {"type": "string", "enum": list(DOCUMENT_TYPES.keys()), "description": "Document type code (default 320)"},
"name": {"type": "string", "description": "Optional invoice name (auto-generated if omitted)"},
"issueDate": {"type": "string", "description": "Issue date — any format (defaults to today)"},
"dueDate": {"type": "string", "description": "Due date — any format"},
"vatRate": {"type": "number", "description": "VAT % (default 17)"},
"notes": {"type": "string", "description": "Free-text notes"},
},
"required": ["activityIds"],
},
"handler": create_invoice_from_activities,
}
tools["add_activity_to_invoice"] = {
"description": "Link an unbilled activity to an existing draft invoice and recalculate totals.",
"parameters": {
"type": "object",
"properties": {
"invoiceId": {"type": "string", "description": "Invoice ID"},
"activityId": {"type": "string", "description": "Activity ID to add"},
},
"required": ["invoiceId", "activityId"],
},
"handler": add_activity_to_invoice,
}
tools["remove_activity_from_invoice"] = {
"description": "Unlink an activity from an invoice and recalculate totals.",
"parameters": {
"type": "object",
"properties": {
"invoiceId": {"type": "string", "description": "Invoice ID"},
"activityId": {"type": "string", "description": "Activity ID to remove"},
},
"required": ["invoiceId", "activityId"],
},
"handler": remove_activity_from_invoice,
}
tools["recalculate_invoice"] = {
"description": "Recalculate amount, VAT, and total of an invoice from its linked activities. Useful after activity rates change.",
"parameters": {
"type": "object",
"properties": {
"invoiceId": {"type": "string", "description": "Invoice ID"},
},
"required": ["invoiceId"],
},
"handler": recalculate_invoice,
}
tools["send_invoice_to_green_invoice"] = {
"description": (
"IRREVERSIBLE: Push a draft invoice to Green Invoice external service to issue an official tax document. "
"After this, linked activities are flagged as billed and linked charges become Invoiced. "
"MUST get explicit user confirmation before calling — do not call on a vague 'yes'."
),
"parameters": {
"type": "object",
"properties": {
"invoiceId": {"type": "string", "description": "Invoice ID to send"},
},
"required": ["invoiceId"],
},
"handler": send_invoice_to_green_invoice,
}