"""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, }