From 206a8007628b1db7356016cdeddd20b042b786ed Mon Sep 17 00:00:00 2001 From: Chaim Date: Wed, 29 Apr 2026 16:32:29 +0000 Subject: [PATCH] feat(billing): teach Shira the GreenInvoiceBilling extension surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- api/services/agent_runner.py | 8 +- api/services/prompt_builder.py | 5 + mcp_server/tools/billing_tools.py | 420 ++++++++++++++++++++++++++++ mcp_server/tools/legal_aid_tools.py | 216 ++++++++++++++ 4 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 mcp_server/tools/billing_tools.py create mode 100644 mcp_server/tools/legal_aid_tools.py diff --git a/api/services/agent_runner.py b/api/services/agent_runner.py index 2df15b8..a8bda9a 100644 --- a/api/services/agent_runner.py +++ b/api/services/agent_runner.py @@ -14,6 +14,8 @@ from mcp_server.tools.document_tools import register_document_tools from mcp_server.tools.legal_tools import register_legal_tools from mcp_server.tools.legal_kb_tools import register_legal_kb_tools from mcp_server.tools.signature_tools import register_signature_tools +from mcp_server.tools.billing_tools import register_billing_tools +from mcp_server.tools.legal_aid_tools import register_legal_aid_tools from api.services.skills import register_skill_tools from api.services.memory import register_memory_tools from api.services.delegate import register_delegate_tool @@ -111,7 +113,7 @@ class AgentRunner: Args: depth: Current delegation depth. 0 = main conversation, 1 = child agent. blocked_tools: Tool names to exclude (used by delegation to prevent recursion). - allowed_toolsets: If set, only register tools from these categories (crm, documents, legal). + allowed_toolsets: If set, only register tools from these categories (crm, documents, legal, signature, billing, legal_aid). on_event: Optional sync callback fired between iterations and tool calls. Receives a dict {type, label, ...} where type ∈ {thinking, tool_start, tool_done, tool_error}. Used by /kb/ask/stream @@ -150,6 +152,10 @@ class AgentRunner: ) if should_register_all or "signature" in (allowed_toolsets or []): register_signature_tools(tools_registry, crm, case_id, user_id, context) + if should_register_all or "billing" in (allowed_toolsets or []): + register_billing_tools(tools_registry, crm, case_id, user_id, context) + if should_register_all or "legal_aid" in (allowed_toolsets or []): + register_legal_aid_tools(tools_registry, crm, case_id, user_id, context) # Phase 3 tools (only for main conversation, not child agents) if depth == 0: diff --git a/api/services/prompt_builder.py b/api/services/prompt_builder.py index c421e38..85b0d8d 100644 --- a/api/services/prompt_builder.py +++ b/api/services/prompt_builder.py @@ -55,6 +55,11 @@ TOOL_RULES = ( "- When user asks to summarize multiple documents or the entire case folder — use read_multiple_documents with all relevant file paths.\n" "- When user asks to rename or organize files — use batch_rename_documents with an array of rename operations.\n" "- TEMPLATE GENERATION: When user asks to generate/create a document from a template — use generate_from_template. The templateId comes from availableTemplates in your context.\n" + "- BILLING (חיוב/חשבונית): When user asks about billable activities, charges, or invoices — start with list_unbilled_activities for a case, or list_charges for charge status views. Show the user a summary BEFORE creating an invoice.\n" + "- BILLING — invoice creation: create_invoice_from_activities only creates a Draft. It does NOT auto-send to Green Invoice. Always show the user the draft amount and ask if they want to send it before calling send_invoice_to_green_invoice.\n" + "- BILLING — irreversible: send_invoice_to_green_invoice issues an official tax document and CANNOT be undone. Require an explicit instruction like 'תשלחי לחשבונית ירוקה', not a vague 'כן'. If unsure, ask again.\n" + "- BILLING — charges: A Charge moves Pending → Approved → (invoiced via create_invoice_from_activities). Cancel only if user explicitly asks. Never approve in bulk without listing the IDs and amounts first.\n" + "- LEGAL AID (סיוע משפטי): the flow is recorded charges → create_legal_aid_proforma → user submits manually at the official site → mark_legal_aid_submitted with the request number → payment report ingestion (automated) → create_monthly_legal_aid_invoice for the month. NEVER call mark_legal_aid_submitted without an explicit request number provided by the user. Use legal_aid_monthly_summary to preview before create_monthly_legal_aid_invoice.\n" ) LEGAL_ASSISTANCE_PROMPT = ( diff --git a/mcp_server/tools/billing_tools.py b/mcp_server/tools/billing_tools.py new file mode 100644 index 0000000..334e832 --- /dev/null +++ b/mcp_server/tools/billing_tools.py @@ -0,0 +1,420 @@ +"""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, + } diff --git a/mcp_server/tools/legal_aid_tools.py b/mcp_server/tools/legal_aid_tools.py new file mode 100644 index 0000000..2984cf8 --- /dev/null +++ b/mcp_server/tools/legal_aid_tools.py @@ -0,0 +1,216 @@ +"""Legal-aid billing tools: proforma → submission → monthly 320 → email payload.""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mcp_server.espocrm_client import EspoCrmClient + +from mcp_server.tools._helpers import ok, fail + + +MONTH_RE = re.compile(r"^\d{4}-\d{2}$") + + +def _money(amount) -> str: + try: + return f"₪{float(amount):,.2f}" + except (TypeError, ValueError): + return f"₪{amount}" + + +def register_legal_aid_tools( + tools: dict, + crm: "EspoCrmClient", + case_id: str | None, + user_id: str | None, + context: dict, +): + """Register Israeli Legal-Aid (סיוע משפטי) billing tools.""" + + async def create_legal_aid_proforma(chargeIds: list[str]) -> str: + if not chargeIds: + return fail("צריך לפחות מזהה חיוב סיוע משפטי אחד") + try: + result = await crm.post("Invoice/action/createLegalAidProforma", {"chargeIds": chargeIds}) + if not result.get("success"): + return fail(f"יצירת חשבון עסקה נכשלה: {result.get('error', 'unknown')}") + return ok( + f"חשבון עסקה (פרופורמה לסיוע משפטי) נוצר: " + f"{result.get('name', '')} (ID: {result.get('id')}, " + f"מספר חיובים: {result.get('chargesCount', len(chargeIds))}, " + f"סכום כולל: {_money(result.get('totalAmount'))}). " + f"כעת יש להגיש ידנית באתר משרד הסיוע ולאחר מכן לקרוא ל-mark_legal_aid_submitted עם מספר הבקשה שהאתר יחזיר." + ) + except Exception as e: + return fail(f"שגיאה ביצירת פרופורמה: {e}") + + async def mark_legal_aid_submitted(invoiceId: str, legalAidRequestNumber: str) -> str: + if not invoiceId: + return fail("צריך מזהה חשבון עסקה (פרופורמה)") + if not legalAidRequestNumber: + return fail("צריך מספר בקשה מאתר משרד הסיוע (לדוגמה: 2026-04-00123)") + try: + result = await crm.post( + f"Invoice/{invoiceId}/action/markLegalAidSubmitted", + {"legalAidRequestNumber": legalAidRequestNumber}, + ) + if not result.get("success"): + return fail(f"סימון הגשה נכשל: {result.get('error', 'unknown')}") + return ok( + f"פרופורמה סומנה כמוגשת. מספר בקשה: {result.get('legalAidRequestNumber')}, " + f"זמן הגשה: {result.get('legalAidSubmittedAt')}. " + f"כל החיובים המקושרים עברו לסטטוס Submitted." + ) + except Exception as e: + return fail(f"שגיאה בסימון הגשה: {e}") + + async def legal_aid_monthly_summary(month: str) -> str: + if not MONTH_RE.match(month or ""): + return fail("פורמט חודש חייב להיות YYYY-MM (לדוגמה 2026-04)") + try: + result = await crm.get(f"Invoice/action/legalAidMonthlyWrapSummary?month={month}") + return ok(json.dumps(result, ensure_ascii=False)) + except Exception as e: + return fail(f"שגיאה בשליפת סיכום חודשי: {e}") + + async def create_monthly_legal_aid_invoice(month: str) -> str: + if not MONTH_RE.match(month or ""): + return fail("פורמט חודש חייב להיות YYYY-MM (לדוגמה 2026-04)") + try: + result = await crm.post("Invoice/action/createLegalAidMonthlyTaxInvoice", {"month": month}) + if not result.get("success"): + return fail(f"יצירת חשבונית חודשית נכשלה: {result.get('error', 'unknown')}") + return ok( + f"חשבונית מס חודשית (320) לחודש {month} נוצרה: " + f"{result.get('name', '')} (ID: {result.get('id')}, " + f"סכום כולל: {_money(result.get('totalAmount'))}). " + f"כעת ניתן להפיק payload לאימייל למשרד המשפטים באמצעות prepare_monthly_320_email." + ) + except Exception as e: + return fail(f"שגיאה ביצירת חשבונית חודשית: {e}") + + async def prepare_monthly_320_email(invoiceId: str) -> str: + if not invoiceId: + return fail("צריך מזהה חשבונית 320 חודשית") + try: + result = await crm.get(f"Invoice/{invoiceId}/action/prepareMonthly320Email") + payload = result.get("emailPayload") or result + return ok(json.dumps(payload, ensure_ascii=False)) + except Exception as e: + return fail(f"שגיאה בהכנת payload לאימייל: {e}") + + async def resubmit_charge(chargeId: str, newActivitySubType: str = "") -> str: + if not chargeId: + return fail("צריך מזהה חיוב") + body = {"id": chargeId} + if newActivitySubType: + body["newActivitySubType"] = newActivitySubType + try: + result = await crm.post("Charge/action/resubmit", body) + if not result.get("success"): + return fail("הגשה מחדש נכשלה") + return ok( + f"חיוב הוגש מחדש: ID חדש {result.get('newChargeId')} " + f"(מס׳ {result.get('newChargeNumber')}), " + f"סכום {_money(result.get('newChargeAmount'))}, " + f"סוג פעילות {result.get('newChargeActivitySubType')}" + ) + except Exception as e: + return fail(f"שגיאה בהגשה מחדש: {e}") + + tools["create_legal_aid_proforma"] = { + "description": ( + "Create a legal-aid Proforma (Invoice type 400, חשבון עסקה) bundling Recorded charges. " + "All chargeIds must be legal-aid charges in 'Recorded' status. " + "After this, the secretary submits the proforma manually at the official Legal Aid site and gets a request number — " + "then call mark_legal_aid_submitted with that number." + ), + "parameters": { + "type": "object", + "properties": { + "chargeIds": {"type": "array", "items": {"type": "string"}, "description": "Legal-aid charge IDs (status=Recorded)"}, + }, + "required": ["chargeIds"], + }, + "handler": create_legal_aid_proforma, + } + + tools["mark_legal_aid_submitted"] = { + "description": ( + "Mark a legal-aid Proforma as submitted to the official Legal Aid website. " + "Requires the legalAidRequestNumber returned by the official site (format like '2026-04-00123'). " + "Flips all linked charges from Recorded to Submitted. Do NOT call without an explicit number from the user." + ), + "parameters": { + "type": "object", + "properties": { + "invoiceId": {"type": "string", "description": "Proforma Invoice ID (documentType=400)"}, + "legalAidRequestNumber": {"type": "string", "description": "Request number from official Legal Aid site"}, + }, + "required": ["invoiceId", "legalAidRequestNumber"], + }, + "handler": mark_legal_aid_submitted, + } + + tools["legal_aid_monthly_summary"] = { + "description": "Preview what a monthly 320 legal-aid tax invoice would contain for a given YYYY-MM. Read-only.", + "parameters": { + "type": "object", + "properties": { + "month": {"type": "string", "description": "Month in YYYY-MM format (e.g. 2026-04)"}, + }, + "required": ["month"], + }, + "handler": legal_aid_monthly_summary, + } + + tools["create_monthly_legal_aid_invoice"] = { + "description": ( + "Create the monthly type-320 tax invoice that aggregates all Paid legal-aid charges for the given YYYY-MM. " + "Flips all aggregated charges from Paid to Billed (terminal). " + "Use legal_aid_monthly_summary first to preview before creating." + ), + "parameters": { + "type": "object", + "properties": { + "month": {"type": "string", "description": "Month in YYYY-MM format (e.g. 2026-04)"}, + }, + "required": ["month"], + }, + "handler": create_monthly_legal_aid_invoice, + } + + tools["prepare_monthly_320_email"] = { + "description": ( + "Build the email payload (recipient, subject, body, attachment ref) for sending a monthly 320 legal-aid invoice " + "to the Justice Ministry. Returns JSON — does NOT actually send. The secretary copy-pastes or pipes via n8n." + ), + "parameters": { + "type": "object", + "properties": { + "invoiceId": {"type": "string", "description": "Monthly 320 Invoice ID"}, + }, + "required": ["invoiceId"], + }, + "handler": prepare_monthly_320_email, + } + + tools["resubmit_charge"] = { + "description": ( + "Clone a Rejected legal-aid charge as a new Recorded charge, optionally with a corrected activitySubType. " + "Use after the Legal Aid office rejects a line in a payment report." + ), + "parameters": { + "type": "object", + "properties": { + "chargeId": {"type": "string", "description": "ID of the Rejected charge to clone"}, + "newActivitySubType": {"type": "string", "description": "Corrected activity sub-type code (optional)"}, + }, + "required": ["chargeId"], + }, + "handler": resubmit_charge, + }