Skill: mailnike/erp-claw (version 3.3.0)

--- name: erpclaw version: 3.3.0 description: > AI-native ERP system with self-extending OS. Full accounting, invoicing, inventory, purchasing, tax, billing, HR, payroll, advanced accounting (ASC 606/842, intercompany, consolidation), and financial reporting. 413 actions across 14 domains, 43 expansion modules. Constitutional guardrails, adversarial audit, schema migration. Double-entry GL, immutable audit trail, US GAAP. author: AvanSaber homepage: https://github.com/avansaber/erpclaw source: https://github.com/avansaber/erpclaw user-invocable: true tags: [erp, accounting, invoicing, inventory, purchasing, tax, billing, payments, gl, reports, sales, buying, setup, hr, payroll, employees, leave, attendance, salary, revenue-recognition, lease-accounting, intercompany, consolidation] metadata: {"openclaw":{"type":"executable","install":{"post":"python3 scripts/erpclaw-setup/db_query.py --action initialize-database"},"requires":{"bins":["python3","git"],"env":[],"optionalEnv":["ERPCLAW_DB_PATH"]},"os":["darwin","linux"]}} cron: - expression: "0 1 * * *" timezone: "America/Chicago" message: "Using erpclaw, run the process-recurring action." announce: false - expression: "0 6 * * *" timezone: "America/Chicago" message: "Using erpclaw, run the generate-recurring-invoices action." announce: false - expression: "0 7 * * *" timezone: "America/Chicago" message: "Using erpclaw, run the check-reorder action." announce: false - expression: "0 8 * * *" timezone: "America/Chicago" message: "Using erpclaw, run the check-overdue action and summarize any overdue invoices." announce: false --- # erpclaw You are a **Full-Stack ERP Controller** for ERPClaw, an AI-native ERP system. You handle all core business operations: company setup, chart of accounts, journal entries, payments, tax, financial reports, customers, sales orders, invoices, suppliers, purchase orders, inventory, usage-based billing, HR (employees, leave, attendance, expenses), and US payroll (salary structures, FICA, income tax withholding, W-2 generation, garnishments). All data lives in a single local SQLite database with full double-entry accounting and immutable audit trail. ## Security Model - **Local-first**: All data in `~/.openclaw/erpclaw/data.sqlite`. Parameterized queries, RBAC (PBKDF2-HMAC-SHA256 600K), immutable GL (cancel = reverse). PII stored locally only. - **Network** (user-initiated, requires confirmation): `fetch-exchange-rates` (public API), `install-module` / `update-modules` (GitHub `avansaber/*` only). - **Routing**: `scripts/db_query.py` → domain scripts or installed modules in `~/.openclaw/erpclaw/modules/` ### Skill Activation Triggers Activate this skill when the user mentions: ERP, accounting, invoice, sales order, purchase order, customer, supplier, inventory, payment, GL, trial balance, P&L, balance sheet, tax, billing, modules, install module, onboard, CRM, manufacturing, healthcare, education, retail, employee, HR, payroll, salary, leave, attendance, expense claim, W-2, garnishment. ### Auto-Detection (IMPORTANT) When a user describes their business for the first time: 1. **Detect business type** from context (e.g., "dental practice" → dental, "trucking company" → fleet, "restaurant" → food-service) 2. **Ask the user to confirm** the detected type and proposed modules before proceeding 3. **On confirmation**, call `setup-company` with `--industry <detected-type>` and `--country <country-code>` 4. **After setup, call `list-all-actions`** to discover newly available module-specific actions 5. **Use module-specific actions** going forward (e.g., `health-add-patient` instead of `add-customer` for healthcare) If a user mentions a country other than US, confirm with the user, then use `--country` on `setup-company` (e.g., `--country IN` for India, `--country CA` for Canada). This installs the regional compliance module after confirmation. If an action returns "Unknown action" with a `suggested_module` field: - Tell the user: "This feature requires the {module} module. Want me to install it?" - **Wait for explicit user confirmation** before installing - On confirmation: `--action install-module --module-name {module}` - After install: `--action list-all-actions` to refresh available actions Industry values: retail, restaurant, healthcare, dental, veterinary, construction, manufacturing, legal, agriculture, hospitality, property, school, university, nonprofit, automotive, therapy, home-health, consulting, distribution, saas ### Setup (First Use Only) ``` python3 {baseDir}/scripts/erpclaw-setup/db_query.py --action initialize-database python3 {baseDir}/scripts/db_query.py --action seed-defaults --company-id <id> python3 {baseDir}/scripts/db_query.py --action setup-chart-of-accounts --company-id <id> --template us_gaap ``` ## Quick Start (Tier 1) For all actions: `python3 {baseDir}/scripts/db_query.py --action <action> [flags]` ``` --action setup-company --name "Acme Inc" --country US --currency USD --fiscal-year-start-month 1 --action add-customer --company-id <id> --customer-name "Jane Corp" --email "[REDACTED_SECRET]" --action create-sales-invoice --company-id <id> --customer-id <id> --items '[{"item_id":"<id>","qty":"1","rate":"100.00"}]' --action submit-sales-invoice --invoice-id <id> --action add-payment --company-id <id> --payment-type Receive --party-type Customer --party-id <id> --paid-amount "100.00" --action submit-payment --payment-id <id> --action trial-balance --company-id <id> --to-date 2026-03-08 ``` New here? Just describe your business — the onboard action detects your industry and sets up everything. ## All Actions (Tier 2) Run `list-all-actions` for the complete list of all available actions. Regional modules add prefixed actions (india-\*, eu-\*, uk-\*, canada-\*) for local tax and compliance. For a web dashboard, run `setup-web-dashboard` (auto-clones erpclaw-web, builds, deploys with nginx + SSL). ### Setup & Admin (44 actions) | Action | Description | |--------|-------------| | `initialize-database` / `setup-company` / `update-company` / `get-company` / `list-companies` | DB init & company CRUD | | `add-currency` / `list-currencies` / `add-exchange-rate` / `get-exchange-rate` / `list-exchange-rates` | Currency & FX | | `add-payment-terms` / `list-payment-terms` / `add-uom` / `list-uoms` / `add-uom-conversion` | Terms & UoMs | | `seed-defaults` / `seed-demo-data` / `check-installation` / `install-guide` / `setup-web-dashboard` | Seeding & install | | `add-user` / `update-user` / `get-user` / `list-users` | User management | | `add-role` / `list-roles` / `assign-role` / `revoke-role` / `set-password` / `seed-permissions` | RBAC & security | | `link-telegram-user` / `unlink-telegram-user` / `check-telegram-permission` | Telegram integration | | `backup-database` / `list-backups` / `verify-backup` / `restore-database` / `cleanup-backups` | DB backup/restore | | `get-audit-log` / `get-schema-version` / `update-regional-settings` | System admin | | `fetch-exchange-rates` / `tutorial` / `onboarding-step` / `status` | Utilities | ### General Ledger (26 actions) | Action | Description | |--------|-------------| | `setup-chart-of-accounts` | Create CoA from template (us_gaap) | | `add-account` / `update-account` / `get-account` / `list-accounts` | Account CRUD | | `freeze-account` / `unfreeze-account` | Lock/unlock accounts | | `post-gl-entries` / `reverse-gl-entries` / `list-gl-entries` | GL posting | | `add-fiscal-year` / `list-fiscal-years` | Fiscal year management | | `validate-period-close` / `close-fiscal-year` / `reopen-fiscal-year` | Period closing | | `add-cost-center` / `list-cost-centers` | Cost center tracking | | `add-budget` / `list-budgets` | Budget management | | `seed-naming-series` / `next-series` | Document naming (INV-, SO-, PO-, etc.) | | `check-gl-integrity` / `get-account-balance` | Validation | | `revalue-foreign-balances` | FX revaluation | | `import-chart-of-accounts` / `import-opening-balances` | CSV import | ### Journal Entries (16 actions) | Action | Description | |--------|-------------| | `add-journal-entry` / `update-journal-entry` / `get-journal-entry` / `list-journal-entries` | JE CRUD | | `submit-journal-entry` / `cancel-journal-entry` / `amend-journal-entry` | JE lifecycle | | `delete-journal-entry` / `duplicate-journal-entry` | JE utilities | | `create-intercompany-je` | Intercompany journal entry | | `add-recurring-template` / `update-recurring-template` / `list-recurring-templates` / `get-recurring-template` | Recurring JE templates | | `process-recurring` / `delete-recurring-template` | Recurring JE processing | ### Payments (13 actions) | Action | Description | |--------|-------------| | `add-payment` / `update-payment` / `get-payment` / `list-payments` | Payment CRUD | | `submit-payment` / `cancel-payment` / `delete-payment` | Payment lifecycle | | `create-payment-ledger-entry` / `get-outstanding` / `get-unallocated-payments` | Payment ledger | | `allocate-payment` / `reconcile-payments` / `bank-reconciliation` | Reconciliation | ### Tax (17 actions) | Action | Description | |--------|-------------| | `add-tax-template` / `update-tax-template` / `get-tax-template` / `list-tax-templates` / `delete-tax-template` | Tax template CRUD | | `resolve-tax-template` / `calculate-tax` | Tax calculation | | `add-tax-category` / `list-tax-categories` | Tax categories | | `add-tax-rule` / `list-tax-rules` | Tax rules | | `add-item-tax-template` | Item-level tax overrides | | `add-tax-withholding-category` / `get-withholding-details` | Withholding | | `record-withholding-entry` / `record-1099-payment` / `generate-1099-data` | 1099 reporting | ### Financial Reports (20 actions) | Action | Description | |--------|-------------| | `trial-balance` / `profit-and-loss` / `balance-sheet` / `cash-flow` | Core statements | | `general-ledger` / `party-ledger` | Ledger reports | | `ar-aging` / `ap-aging` | Receivable/payable aging | | `budget-vs-actual` (alias: `budget-variance`) | Budget analysis | | `tax-summary` / `payment-summary` / `gl-summary` | Summaries | | `comparative-pl` / `check-overdue` | Analysis | | `add-elimination-rule` / `list-elimination-rules` / `run-elimination` / `list-elimination-entries` | Intercompany | ### Selling (42 actions) | Action | Description | |--------|-------------| | `add-customer` / `update-customer` / `get-customer` / `list-customers` | Customer CRUD | | `add-quotation` / `update-quotation` / `get-quotation` / `list-quotations` / `submit-quotation` | Quotations | | `convert-quotation-to-so` | Quotation → Sales Order | | `add-sales-order` / `update-sales-order` / `get-sales-order` / `list-sales-orders` / `submit-sales-order` / `cancel-sales-order` | Sales orders | | `create-delivery-note` / `get-delivery-note` / `list-delivery-notes` / `submit-delivery-note` / `cancel-delivery-note` | Delivery | | `create-sales-invoice` / `update-sales-invoice` / `get-sales-invoice` / `list-sales-invoices` / `submit-sales-invoice` / `cancel-sales-invoice` | Invoicing | | `create-credit-note` / `list-credit-notes` / `update-invoice-outstanding` | Credit notes | | `add-sales-partner` / `list-sales-partners` | Sales partners | | `add-recurring-invoice-template` / [REDACTED_SECRET] / [REDACTED_SECRET] / `generate-recurring-invoices` | Recurring invoices | | `import-customers` | CSV import | | `add-intercompany-account-map` / `list-intercompany-account-maps` / `create-intercompany-invoice` / `list-intercompany-invoices` / `cancel-intercompany-invoice` | Intercompany | ### Buying (34 actions) | Action | Description | |--------|-------------| | `add-supplier` / `update-supplier` / `get-supplier` / `list-suppliers` | Supplier CRUD | | `add-material-request` / `submit-material-request` / `list-material-requests` | Material requests | | `add-rfq` / `submit-rfq` / `list-rfqs` | RFQs | | `add-supplier-quotation` / `list-supplier-quotations` / `compare-supplier-quotations` | Supplier quotes | | `add-purchase-order` / `update-purchase-order` / `get-purchase-order` / `list-purchase-orders` / `submit-purchase-order` / `cancel-purchase-order` | Purchase orders | | `create-purchase-receipt` / `get-purchase-receipt` / `list-purchase-receipts` / `submit-purchase-receipt` / `cancel-purchase-receipt` | Receipts | | `create-purchase-invoice` / `update-purchase-invoice` / `get-purchase-invoice` / `list-purchase-invoices` / `submit-purchase-invoice` / `cancel-purchase-invoice` | Purchase invoices | | `create-debit-note` / `update-purchase-outstanding` / `add-landed-cost-voucher` | Adjustments | | `import-suppliers` | CSV import | ### Inventory (36 actions) | Action | Description | |--------|-------------| | `add-item` / `update-item` / `get-item` / `list-items` | Item master | | `add-item-group` / `list-item-groups` | Item groups | | `add-warehouse` / `update-warehouse` / `list-warehouses` | Warehouses | | `add-stock-entry` / `get-stock-entry` / `list-stock-entries` / `submit-stock-entry` / `cancel-stock-entry` | Stock entries | | `create-stock-ledger-entries` / `reverse-stock-ledger-entries` | Stock ledger | | `get-stock-balance` / `stock-balance` / `stock-balance-report` / `stock-ledger-report` | Stock reports | | `add-batch` / `list-batches` / `add-serial-number` / `list-serial-numbers` | Batch & serial tracking | | `add-price-list` / `add-item-price` / `get-item-price` / `add-pricing-rule` | Pricing | | `add-stock-reconciliation` / `submit-stock-reconciliation` | Reconciliation | | `revalue-stock` / `list-stock-revaluations` / `get-stock-revaluation` / `cancel-stock-revaluation` | Revaluation | | `check-reorder` / `import-items` | Utilities | ### Billing & Metering (21 actions) | Action | Description | |--------|-------------| | `add-meter` / `update-meter` / `get-meter` / `list-meters` | Meter CRUD | | `add-meter-reading` / `list-meter-readings` | Readings | | `add-usage-event` / `add-usage-events-batch` | Usage tracking | | `add-rate-plan` / `update-rate-plan` / `get-rate-plan` / `list-rate-plans` / `rate-consumption` | Rate plans | | `create-billing-period` / `run-billing` / `generate-invoices` | Billing cycles | | `add-billing-adjustment` / `list-billing-periods` / `get-billing-period` | Adjustments | | `add-prepaid-credit` / `get-prepaid-balance` | Prepaid credits | ### Advanced Accounting (45 actions) | Action | Description | |--------|-------------| | `add-revenue-contract` / `update-revenue-contract` / `get-revenue-contract` / `list-revenue-contracts` | Revenue contract CRUD | | `add-performance-obligation` / `list-performance-obligations` / `satisfy-performance-obligation` | ASC 606 performance obligations | | `add-variable-consideration` / `list-variable-considerations` / `modify-contract` | Variable consideration & modifications | | `calculate-revenue-schedule` / `generate-revenue-entries` / `revenue-waterfall-report` / `revenue-recognition-summary` | Revenue recognition processing & reports | | `add-lease` / `update-lease` / `get-lease` / `list-leases` / `classify-lease` | ASC 842 lease CRUD & classification | | `calculate-rou-asset` / `calculate-lease-liability` / `generate-amortization-schedule` / `record-lease-payment` | Lease calculations & payments | | `lease-maturity-report` / `lease-disclosure-report` / `lease-summary` | Lease reports | | `add-ic-transaction` / `update-ic-transaction` / `get-ic-transaction` / `list-ic-transactions` | Intercompany transaction CRUD | | `approve-ic-transaction` / `post-ic-transaction` / `add-transfer-price-rule` / `list-transfer-price-rules` | IC approvals & transfer pricing | | `ic-reconciliation-report` / `ic-elimination-report` | IC reports | | `add-consolidation-group` / `list-consolidation-groups` / `add-group-entity` / `add-currency-translation` | Multi-entity consolidation setup | | `run-consolidation` / `generate-elimination-entries` / [REDACTED_SECRET] / `consolidation-summary` | Consolidation processing & reports | | `standards-compliance-dashboard` | ASC 606/842 compliance overview | ### HR & Payroll (49 actions) | Action | Description | |--------|-------------| | `add-employee` / `update-employee` / `get-employee` / `list-employees` | Employee CRUD | | `add-department` / `list-departments` / `add-designation` / `list-designations` | Org structure | | `add-leave-type` / `list-leave-types` / `add-leave-allocation` / `get-leave-balance` | Leave config | | `add-leave-application` / `approve-leave` / `reject-leave` / `list-leave-applications` | Leave requests | | `mark-attendance` / `bulk-mark-attendance` / `list-attendance` / `add-holiday-list` | Attendance | | `add-expense-claim` / `submit-expense-claim` / `approve-expense-claim` / `reject-expense-claim` | Expenses | | `update-expense-claim-status` / `list-expense-claims` / `record-lifecycle-event` | Expense status & HR events | | `add-salary-component` / `list-salary-components` / `add-salary-structure` / `get-salary-structure` / `list-salary-structures` | Salary components & structures | | `add-salary-assignment` / `list-salary-assignments` / `add-income-tax-slab` / `update-fica-config` / `update-futa-suta-config` | Payroll config | | `create-payroll-run` / `generate-salary-slips` / `get-salary-slip` / `list-salary-slips` | Payroll processing | | `submit-payroll-run` / `cancel-payroll-run` / `generate-w2-data` | Payroll lifecycle & W-2 | | `add-garnishment` / `update-garnishment` / `get-garnishment` / `list-garnishments` / `payroll-status` | Garnishments & status | ### Module Management & OS (41 actions) | Action | Description | |--------|-------------| | `install-module` | Install a module from GitHub (`--module-name <name>`) | | `remove-module` | Remove an installed module (`--module-name <name>`) | | `update-modules` | Update all or a specific module | | `list-modules` / `available-modules` / `search-modules` / `module-status` | Browse and search module catalog | | `rebuild-action-cache` / `list-all-actions` | Refresh available actions after module changes | | `list-profiles` / `onboard` | Browse business profiles, auto-install for a business type | | `validate-module` / `generate-module` / `configure-module` / `deploy-module` / `list-industries` | OS: Module lifecycle | | `build-table-registry` / `list-articles` | OS: Constitution & schema registry | | `install-suite` / `classify-operation` / `run-audit` / `compliance-weather-status` | OS: Suite install, tier classification, audit | | `schema-plan` / `schema-apply` / `schema-rollback` / `schema-drift` / `deploy-audit-log` | OS: Schema migration & deploy audit | | `semantic-check` / `semantic-rules-list` | OS: Semantic correctness — validates GL postings use correct account types | | `log-improvement` / `list-improvements` / `review-improvement` | OS: Self-improvement log — track AI-proposed changes | | `dgm-run-variant` / `dgm-list-variants` / `dgm-select-best` | OS: DGM variant engine — evolutionary optimization (non-financial code only) | | `heartbeat-analyze` / `heartbeat-report` / `heartbeat-suggest` | OS: Heartbeat — usage patterns, gap detection, module suggestions | | `detect-gaps` / `suggest-modules` | OS: Gap detection — identifies missing modules for your industry | | `regenerate-skill-md` | OS: Regenerate SKILL.md after module changes | ### Domain Status (9 actions) | Action | Description | |--------|-------------| | `gl-status` / `journals-status` / `payments-status` / `tax-status` / `reports-status` / `selling-status` / `buying-status` / `inventory-status` / `billing-status` | Per-domain health check | **Always confirm with user before running:** `setup-company` (with --industry or --country), `onboard`, `install-module`, `remove-module`, `update-modules`, `submit-*`, `cancel-*`, `approve-*`, `reject-*`, `run-elimination`, `run-consolidation`, `restore-database`, `close-fiscal-year`, `initialize-database --force`. All `add-*`, `get-*`, `list-*`, `update-*` run immediately. ## Technical Details (Tier 3) Router: `scripts/db_query.py` → 14 core domains + erpclaw-os. Modules from GitHub to `~/.openclaw/erpclaw/modules/`. Single SQLite DB (WAL mode). 188 core tables (688 with all modules), Money=TEXT(Decimal), IDs=TEXT(UUID4), GL immutable. Python 3.10+.

path: scripts/erpclaw-accounting-adv/leases.py
content: """ERPClaw Advanced Accounting -- Lease Accounting (ASC 842) domain module

Actions for leases, lease payments, and amortization schedules (3 tables, 12 actions).
Imported by db_query.py (unified router).
"""
import os
import sys
import uuid
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP

try:
    sys.path.insert(0, os.path.expanduser("~/.openclaw/erpclaw/lib"))
    from erpclaw_lib.naming import get_next_name, ENTITY_PREFIXES
    from erpclaw_lib.response import ok, err, row_to_dict
    from erpclaw_lib.audit import audit
    from erpclaw_lib.query import dynamic_update

    ENTITY_PREFIXES.setdefault("lease", "LEAS-")
except ImportError:
    pass

SKILL = "erpclaw-accounting-adv"

_now_iso = lambda: datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

# ---------------------------------------------------------------------------
# Validation constants
# ---------------------------------------------------------------------------
VALID_LEASE_TYPES = ("operating", "finance")
VALID_LEASE_STATUSES = ("draft", "active", "modified", "expired", "terminated")
VALID_PAYMENT_STATUSES = ("scheduled", "paid", "overdue")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_company(conn, company_id):
    if not company_id:
        err("--company-id is required")
    if not conn.execute("SELECT id FROM company WHERE id = ?", (company_id,)).fetchone():
        err(f"Company {company_id} not found")


def _validate_lease(conn, lease_id):
    if not lease_id:
        err("--id (or --lease-id) is required")
    row = conn.execute("SELECT id FROM advacct_lease WHERE id = ?", (lease_id,)).fetchone()
    if not row:
        err(f"Lease {lease_id} not found")


def _pv_annuity(payment, rate_per_period, periods):
    """Present value of an ordinary annuity using Decimal math."""
    if rate_per_period == Decimal("0"):
        return payment * Decimal(str(periods))
    factor = (Decimal("1") - (Decimal("1") + rate_per_period) ** (-periods)) / rate_per_period
    return (payment * factor).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


# ===========================================================================
# 1. add-lease
# ===========================================================================
def add_lease(conn, args):
    _validate_company(conn, args.company_id)

    lessee_name = getattr(args, "lessee_name", None)
    if not lessee_name:
        err("--lessee-name is required")
    lessor_name = getattr(args, "lessor_name", None)
    if not lessor_name:
        err("--lessor-name is required")

    monthly_payment = getattr(args, "monthly_payment", None) or "0"
    discount_rate = getattr(args, "discount_rate", None) or "0"
    term_months = int(getattr(args, "term_months", None) or 0)

    lease_id = str(uuid.uuid4())
    conn.company_id = args.company_id
    naming = get_next_name(conn, "lease", company_id=args.company_id)
    now = _now_iso()

    conn.execute("""
        INSERT INTO advacct_lease (
            id, naming_series, lessee_name, lessor_name, asset_description,
            lease_type, start_date, end_date, term_months, monthly_payment,
            annual_escalation, discount_rate, purchase_option_price,
            rou_asset_value, lease_liability, lease_status,
            company_id, created_at, updated_at
        ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (
        lease_id, naming, lessee_name, lessor_name,
        getattr(args, "asset_description", None),
        getattr(args, "lease_type", None) or "operating",
        getattr(args, "start_date", None),
        getattr(args, "end_date", None),
        term_months,
        monthly_payment,
        getattr(args, "annual_escalation", None) or "0",
        discount_rate,
        getattr(args, "purchase_option_price", None),
        None, None,  # rou_asset_value, lease_liability -- calculated later
        "draft",
        args.company_id, now, now,
    ))
    audit(conn, SKILL, "add-lease", "advacct_lease", lease_id,
          new_values={"lessee_name": lessee_name, "lessor_name": lessor_name, "term_months": term_months})
    conn.commit()
    ok({
        "id": lease_id, "naming_series": naming,
        "lessee_name": lessee_name, "lessor_name": lessor_name,
        "lease_status": "draft", "term_months": term_months,
    })


# ===========================================================================
# 2. update-lease
# ===========================================================================
def update_lease(conn, args):
    lease_id = getattr(args, "id", None)
    if not lease_id:
        err("--id is required")
    if not conn.execute("SELECT id FROM advacct_lease WHERE id = ?", (lease_id,)).fetchone():
        err(f"Lease {lease_id} not found")

    data, changed = {}, []
    for arg_name, col_name in {
        "lessee_name": "lessee_name",
        "lessor_name": "lessor_name",
        "asset_description": "asset_description",
        "start_date": "start_date",
        "end_date": "end_date",
        "monthly_payment": "monthly_payment",
        "discount_rate": "discount_rate",
        "annual_escalation": "annual_escalation",
        "purchase_option_price": "purchase_option_price",
    }.items():
        val = getattr(args, arg_name, None)
        if val is not None:
            data[col_name] = val
            changed.append(col_name)

    lease_type = getattr(args, "lease_type", None)
    if lease_type:
        if lease_type not in VALID_LEASE_TYPES:
            err(f"Invalid lease-type: {lease_type}. Must be one of: {', '.join(VALID_LEASE_TYPES)}")
        data["lease_type"] = lease_type
        changed.append("lease_type")

    term_months = getattr(args, "term_months", None)
    if term_months is not None:
        data["term_months"] = int(term_months)
        changed.append("term_months")

    if not data:
        err("No fields to update")

    data["updated_at"] = _now_iso()
    sql, params = dynamic_update("advacct_lease", data, where={"id": lease_id})
    conn.execute(sql, params)
    audit(conn, SKILL, "update-lease", "advacct_lease", lease_id,
          new_values={"updated_fields": changed})
    conn.commit()
    ok({"id": lease_id, "updated_fields": changed})


# ===========================================================================
# 3. get-lease
# ===========================================================================
def get_lease(conn, args):
    lease_id = getattr(args, "id", None)
    if not lease_id:
        err("--id is required")
    row = conn.execute("SELECT * FROM advacct_lease WHERE id = ?", (lease_id,)).fetchone()
    if not row:
        err(f"Lease {lease_id} not found")
    data = row_to_dict(row)

    # Include payments
    payments = conn.execute(
        "SELECT * FROM advacct_lease_payment WHERE lease_id = ? ORDER BY payment_date",
        (lease_id,)
    ).fetchall()
    data["payments"] = [row_to_dict(p) for p in payments]
    data["payment_count"] = len(payments)

    # Include amortization entries
    amort = conn.execute(
        "SELECT * FROM advacct_amortization_entry WHERE lease_id = ? ORDER BY period_date",
        (lease_id,)
    ).fetchall()
    data["amortization_entries"] = [row_to_dict(a) for a in amort]

    ok(data)


# ===========================================================================
# 4. list-leases
# ===========================================================================
def list_leases(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)
    if getattr(args, "lease_type", None):
        where.append("lease_type = ?")
        params.append(args.lease_type)
    if getattr(args, "lease_status", None):
        where.append("lease_status = ?")
        params.append(args.lease_status)
    if getattr(args, "search", None):
        where.append("(LOWER(lessee_name) LIKE LOWER(?) OR LOWER(lessor_name) LIKE LOWER(?) OR LOWER(asset_description) LIKE LOWER(?))")
        params.extend([f"%{args.search}%"] * 3)

    where_sql = " AND ".join(where)
    total = conn.execute(
        f"SELECT COUNT(*) FROM advacct_lease WHERE {where_sql}", params
    ).fetchone()[0]
    params.extend([args.limit, args.offset])
    rows = conn.execute(
        f"SELECT * FROM advacct_lease WHERE {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
        params
    ).fetchall()
    ok({
        "rows": [row_to_dict(r) for r in rows],
        "total_count": total, "limit": args.limit, "offset": args.offset,
        "has_more": (args.offset + args.limit) < total,
    })


# ===========================================================================
# 5. classify-lease
# ===========================================================================
def classify_lease(conn, args):
    lease_id = getattr(args, "id", None)
    _validate_lease(conn, lease_id)
    row = conn.execute("SELECT * FROM advacct_lease WHERE id = ?", (lease_id,)).fetchone()
    data = row_to_dict(row)

    # ASC 842 classification criteria (simplified):
    # Finance lease if: term >= 75% of asset life, or PV of payments >= 90% of fair value,
    # or purchase option reasonably certain.
    # For simplicity, we use term_months: >= 36 months => finance, else operating.
    # User can override via --lease-type.

    lease_type = getattr(args, "lease_type", None)
    if not lease_type:
        # Auto-classify based on term
        if data["term_months"] >= 36:
            lease_type = "finance"
        else:
            lease_type = "operating"

    if lease_type not in VALID_LEASE_TYPES:
        err(f"Invalid lease-type: {lease_type}. Must be one of: {', '.join(VALID_LEASE_TYPES)}")

    now = _now_iso()
    conn.execute(
        "UPDATE advacct_lease SET lease_type = ?, updated_at = ? WHERE id = ?",
        (lease_type, now, lease_id)
    )
    audit(conn, SKILL, "classify-lease", "advacct_lease", lease_id,
          new_values={"lease_type": lease_type})
    conn.commit()
    ok({"id": lease_id, "lease_type": lease_type})


# ===========================================================================
# 6. calculate-rou-asset
# ===========================================================================
def calculate_rou_asset(conn, args):
    lease_id = getattr(args, "id", None)
    _validate_lease(conn, lease_id)
    row = conn.execute("SELECT * FROM advacct_lease WHERE id = ?", (lease_id,)).fetchone()
    data = row_to_dict(row)

    monthly_payment = Decimal(data["monthly_payment"])
    discount_rate = Decimal(data["discount_rate"])
    term_months = data["term_months"]

    if term_months <= 0:
        err("term_months must be greater than 0")
    if monthly_payment <= 0:
        err("monthly_payment must be greater than 0")

    # Monthly discount rate
    monthly_rate = discount_rate / Decimal("12")

    # ROU asset = PV of lease payments
    rou_value = _pv_annuity(monthly_payment, monthly_rate, Decimal(str(term_months)))

    now = _now_iso()
    conn.execute(
        "UPDATE advacct_lease SET rou_asset_value = ?, updated_at = ? WHERE id = ?",
        (str(rou_value), now, lease_id)
    )
    audit(conn, SKILL, "calculate-rou-asset", "advacct_lease", lease_id,
          new_values={"rou_asset_value": str(rou_value)})
    conn.commit()
    ok({"id": lease_id, "rou_asset_value": str(rou_value)})


# ===========================================================================
# 7. calculate-lease-liability
# ===========================================================================
def calculate_lease_liability(conn, args):
    lease_id = getattr(args, "id", None)
    _validate_lease(conn, lease_id)
    row = conn.execute("SELECT * FROM advacct_lease WHERE id = ?", (lease_id,)).fetchone()
    data = row_to_dict(row)

    monthly_payment = Decimal(data["monthly_payment"])
    discount_rate = Decimal(data["discount_rate"])
    term_months = data["term_months"]

    if term_months <= 0:
        err("term_months must be greater than 0")
    if monthly_payment <= 0:
        err("monthly_payment must be greater than 0")

    # Monthly discount rate
    monthly_rate = discount_rate / Decimal("12")

    # Lease liability = PV of remaining lease payments (same as ROU at inception)
    liability = _pv_annuity(monthly_payment, monthly_rate, Decimal(str(term_months)))

    now = _now_iso()
    conn.execute(
        "UPDATE advacct_lease SET lease_liability = ?, updated_at = ? WHERE id = ?",
        (str(liability), now, lease_id)
    )
    audit(conn, SKILL, "calculate-lease-liability", "advacct_lease", lease_id,
          new_values={"lease_liability": str(liability)})
    conn.commit()
    ok({"id": lease_id, "lease_liability": str(liability)})


# ===========================================================================
# 8. generate-amortization-schedule
# ===========================================================================
def generate_amortization_schedule(conn, args):
    lease_id = getattr(args, "lease_id", None)
    if not lease_id:
        err("--lease-id is required")
    _validate_lease(conn, lease_id)
    row = conn.execute("SELECT * FROM advacct_lease WHERE id = ?", (lease_id,)).fetchone()
    data = row_to_dict(row)

    monthly_payment = Decimal(data["monthly_payment"])
    discount_rate = Decimal(data["discount_rate"])
    term_months = data["term_months"]
    start_date = data.get("start_date")

    if term_months <= 0:
        err("term_months must be greater than 0")
    if monthly_payment <= 0:
        err("monthly_payment must be greater than 0")
    if not start_date:
        err("Lease must have a start_date to generate amortization schedule")

    monthly_rate = discount_rate / Decimal("12")

    # Calculate initial balance (PV)
    opening = _pv_annuity(monthly_payment, monthly_rate, Decimal(str(term_months)))

    # Delete existing amortization entries
    conn.execute("DELETE FROM advacct_amortization_entry WHERE lease_id = ?", (lease_id,))

    from datetime import date
    start = date.fromisoformat(start_date)
    current_year = start.year
    current_month = start.month

    entries_created = 0
    company_id = data["company_id"]
    now = _now_iso()
    balance = opening

    for i in range(term_months):
        interest = (balance * monthly_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
        principal = monthly_payment - interest
        closing = balance - principal

        # Last payment: adjust for rounding
        if i == term_months - 1:
            principal = balance
            closing = Decimal("0")
            interest = monthly_payment - principal

        period_date = f"{current_year}-{current_month:02d}-01"

        conn.execute("""
            INSERT INTO advacct_amortization_entry (
                id, lease_id, period_date, opening_balance, payment,
                interest, principal, closing_balance, company_id, created_at
            ) VALUES (?,?,?,?,?,?,?,?,?,?)
        """, (
            str(uuid.uuid4()), lease_id, period_date,
            str(balance), str(monthly_payment),
            str(interest), str(principal), str(closing),
            company_id, now,
        ))
        entries_created += 1
        balance = closing

        current_month += 1
        if current_month > 12:
            current_month = 1
            current_year += 1

    audit(conn, SKILL, "generate-amortization-schedule", "advacct_amortization_entry", lease_id,
          new_values={"entries_created": entries_created, "initial_balance": str(opening)})
    conn.commit()
    ok({
        "lease_id": lease_id, "entries_created": entries_created,
        "initial_balance": str(opening), "monthly_payment": str(monthly_payment),
    })


# ===========================================================================
# 9. record-lease-payment
# ===========================================================================
def record_lease_payment(conn, args):
    lease_id = getattr(args, "lease_id", None)
    if not lease_id:
        err("--lease-id is required")
    _validate_lease(conn, lease_id)

    payment_date = getattr(args, "payment_date", None)
    if not payment_date:
        err("--payment-date is required")

    payment_amount = getattr(args, "payment_amount", None)
    if not payment_amount:
        err("--payment-amount is required")

    row = conn.execute("SELECT * FROM advacct_lease WHERE id = ?", (lease_id,)).fetchone()
    data = row_to_dict(row)
    company_id = data["company_id"]

    # Get current balance from latest amortization entry or lease_liability
    latest_amort = conn.execute(
        "SELECT closing_balance FROM advacct_amortization_entry WHERE lease_id = ? ORDER BY period_date DESC LIMIT 1",
        (lease_id,)
    ).fetchone()

    if latest_amort:
        current_balance = Decimal(latest_amort[0])
    elif data.get("lease_liability"):
        current_balance = Decimal(data["lease_liability"])
    else:
        current_balance = Decimal("0")

    payment_amt = Decimal(payment_amount)
    discount_rate = Decimal(data["discount_rate"])
    monthly_rate = discount_rate / Decimal("12")

    interest = (current_balance * monthly_rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    principal = payment_amt - interest
    balance_after = current_balance - principal

    payment_id = str(uuid.uuid4())
    now = _now_iso()

    conn.execute("""
        INSERT INTO advacct_lease_payment (
            id, lease_id, payment_date, payment_amount, principal, interest,
            balance_after, payment_status, company_id, created_at
        ) VALUES (?,?,?,?,?,?,?,?,?,?)
    """, (
        payment_id, lease_id, payment_date, payment_amount,
        str(principal), str(interest), str(balance_after),
        "paid", company_id, now,
    ))
    audit(conn, SKILL, "record-lease-payment", "advacct_lease_payment", payment_id,
          new_values={"lease_id": lease_id, "payment_amount": payment_amount})
    conn.commit()
    ok({
        "id": payment_id, "lease_id": lease_id,
        "payment_amount": payment_amount, "principal": str(principal),
        "interest": str(interest), "balance_after": str(balance_after),
        "payment_status": "paid",
    })


# ===========================================================================
# 10. lease-maturity-report
# ===========================================================================
def lease_maturity_report(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)

    where_sql = " AND ".join(where)
    rows = conn.execute(f"""
        SELECT id, lessee_name, lessor_name, lease_type, start_date, end_date,
               term_months, monthly_payment, rou_asset_value, lease_liability, lease_status
        FROM advacct_lease
        WHERE {where_sql}
        ORDER BY end_date ASC
    """, params).fetchall()

    ok({
        "report": "lease_maturity",
        "rows": [row_to_dict(r) for r in rows],
        "total_leases": len(rows),
    })


# ===========================================================================
# 11. lease-disclosure-report
# ===========================================================================
def lease_disclosure_report(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("l.company_id = ?")
        params.append(args.company_id)

    where_sql = " AND ".join(where)
    rows = conn.execute(f"""
        SELECT l.lease_type,
               COUNT(*) as lease_count,
               SUM(CAST(l.monthly_payment AS NUMERIC)) as total_monthly_payments,
               SUM(CAST(COALESCE(l.rou_asset_value, '0') AS NUMERIC)) as total_rou_assets,
               SUM(CAST(COALESCE(l.lease_liability, '0') AS NUMERIC)) as total_lease_liabilities
        FROM advacct_lease l
        WHERE {where_sql}
        GROUP BY l.lease_type
    """, params).fetchall()

    ok({
        "report": "lease_disclosure",
        "rows": [row_to_dict(r) for r in rows],
    })


# ===========================================================================
# 12. lease-summary
# ===========================================================================
def lease_summary(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)

    where_sql = " AND ".join(where)
    total = conn.execute(
        f"SELECT COUNT(*) FROM advacct_lease WHERE {where_sql}", params
    ).fetchone()[0]

    by_status = conn.execute(f"""
        SELECT lease_status, COUNT(*) as cnt
        FROM advacct_lease WHERE {where_sql} GROUP BY lease_status
    """, params).fetchall()

    by_type = conn.execute(f"""
        SELECT lease_type, COUNT(*) as cnt
        FROM advacct_lease WHERE {where_sql} GROUP BY lease_type
    """, params).fetchall()

    ok({
        "report": "lease_summary",
        "total_leases": total,
        "by_status": {r[0]: r[1] for r in by_status},
        "by_type": {r[0]: r[1] for r in by_type},
    })


# ---------------------------------------------------------------------------
# Action registry
# ---------------------------------------------------------------------------
ACTIONS = {
    "add-lease": add_lease,
    "update-lease": update_lease,
    "get-lease": get_lease,
    "list-leases": list_leases,
    "classify-lease": classify_lease,
    "calculate-rou-asset": calculate_rou_asset,
    "calculate-lease-liability": calculate_lease_liability,
    "generate-amortization-schedule": generate_amortization_schedule,
    "record-lease-payment": record_lease_payment,
    "lease-maturity-report": lease_maturity_report,
    "lease-disclosure-report": lease_disclosure_report,
    "lease-summary": lease_summary,
}


path: scripts/erpclaw-accounting-adv/consolidation.py
content: """ERPClaw Advanced Accounting -- Multi-Entity Consolidation domain module

Actions for consolidation groups, group entities, and elimination entries (3 tables, 8 actions).
Imported by db_query.py (unified router).
"""
import os
import sys
import uuid
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP

try:
    sys.path.insert(0, os.path.expanduser("~/.openclaw/erpclaw/lib"))
    from erpclaw_lib.naming import get_next_name, ENTITY_PREFIXES
    from erpclaw_lib.response import ok, err, row_to_dict
    from erpclaw_lib.audit import audit

    ENTITY_PREFIXES.setdefault("consolidation_group", "CGRP-")
except ImportError:
    pass

SKILL = "erpclaw-accounting-adv"

_now_iso = lambda: datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

# ---------------------------------------------------------------------------
# Validation constants
# ---------------------------------------------------------------------------
VALID_GROUP_STATUSES = ("active", "inactive")
VALID_CONSOLIDATION_METHODS = ("full", "proportional", "equity")
VALID_ENTRY_TYPES = ("ic_elimination", "minority_interest", "currency_translation", "goodwill")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_company(conn, company_id):
    if not company_id:
        err("--company-id is required")
    if not conn.execute("SELECT id FROM company WHERE id = ?", (company_id,)).fetchone():
        err(f"Company {company_id} not found")


def _validate_group(conn, group_id):
    if not group_id:
        err("--group-id is required")
    row = conn.execute("SELECT id FROM advacct_consolidation_group WHERE id = ?", (group_id,)).fetchone()
    if not row:
        err(f"Consolidation group {group_id} not found")


# ===========================================================================
# 1. add-consolidation-group
# ===========================================================================
def add_consolidation_group(conn, args):
    _validate_company(conn, args.company_id)

    name = getattr(args, "name", None)
    if not name:
        err("--name is required")

    group_id = str(uuid.uuid4())
    conn.company_id = args.company_id
    naming = get_next_name(conn, "consolidation_group", company_id=args.company_id)
    now = _now_iso()

    conn.execute("""
        INSERT INTO advacct_consolidation_group (
            id, naming_series, name, parent_company_id, consolidation_currency,
            group_status, company_id, created_at, updated_at
        ) VALUES (?,?,?,?,?,?,?,?,?)
    """, (
        group_id, naming, name,
        getattr(args, "parent_company_id", None),
        getattr(args, "consolidation_currency", None) or "USD",
        "active", args.company_id, now, now,
    ))
    audit(conn, SKILL, "add-consolidation-group", "advacct_consolidation_group", group_id,
          new_values={"name": name})
    conn.commit()
    ok({
        "id": group_id, "naming_series": naming, "name": name,
        "group_status": "active",
    })


# ===========================================================================
# 2. list-consolidation-groups
# ===========================================================================
def list_consolidation_groups(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)
    if getattr(args, "group_status", None):
        where.append("group_status = ?")
        params.append(args.group_status)
    if getattr(args, "search", None):
        where.append("(LOWER(name) LIKE LOWER(?))")
        params.append(f"%{args.search}%")

    where_sql = " AND ".join(where)
    total = conn.execute(
        f"SELECT COUNT(*) FROM advacct_consolidation_group WHERE {where_sql}", params
    ).fetchone()[0]
    params.extend([args.limit, args.offset])
    rows = conn.execute(
        f"SELECT * FROM advacct_consolidation_group WHERE {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
        params
    ).fetchall()
    ok({
        "rows": [row_to_dict(r) for r in rows],
        "total_count": total, "limit": args.limit, "offset": args.offset,
        "has_more": (args.offset + args.limit) < total,
    })


# ===========================================================================
# 3. add-group-entity
# ===========================================================================
def add_group_entity(conn, args):
    group_id = getattr(args, "group_id", None)
    _validate_group(conn, group_id)
    _validate_company(conn, args.company_id)

    entity_company_id = getattr(args, "entity_company_id", None)
    if not entity_company_id:
        err("--entity-company-id is required")

    entity_name = getattr(args, "entity_name", None)
    if not entity_name:
        err("--entity-name is required")

    consolidation_method = getattr(args, "consolidation_method", None) or "full"
    if consolidation_method not in VALID_CONSOLIDATION_METHODS:
        err(f"Invalid consolidation-method: {consolidation_method}. Must be one of: {', '.join(VALID_CONSOLIDATION_METHODS)}")

    ownership_pct = getattr(args, "ownership_pct", None) or "100"

    entity_id = str(uuid.uuid4())
    now = _now_iso()

    conn.execute("""
        INSERT INTO advacct_group_entity (
            id, group_id, entity_company_id, entity_name, ownership_pct,
            functional_currency, consolidation_method, is_active,
            company_id, created_at
        ) VALUES (?,?,?,?,?,?,?,?,?,?)
    """, (
        entity_id, group_id, entity_company_id, entity_name, ownership_pct,
        getattr(args, "functional_currency", None) or "USD",
        consolidation_method, 1,
        args.company_id, now,
    ))
    audit(conn, SKILL, "add-group-entity", "advacct_group_entity", entity_id,
          new_values={"group_id": group_id, "entity_name": entity_name, "ownership_pct": ownership_pct})
    conn.commit()
    ok({
        "id": entity_id, "group_id": group_id,
        "entity_company_id": entity_company_id, "entity_name": entity_name,
        "ownership_pct": ownership_pct, "consolidation_method": consolidation_method,
    })


# ===========================================================================
# 4. run-consolidation
# ===========================================================================
def run_consolidation(conn, args):
    group_id = getattr(args, "group_id", None)
    _validate_group(conn, group_id)

    period_date = getattr(args, "period_date", None)
    if not period_date:
        err("--period-date is required")

    # Get group info
    group = row_to_dict(conn.execute(
        "SELECT * FROM advacct_consolidation_group WHERE id = ?", (group_id,)
    ).fetchone())

    # Get entities
    entities = conn.execute(
        "SELECT * FROM advacct_group_entity WHERE group_id = ? AND is_active = 1",
        (group_id,)
    ).fetchall()

    if not entities:
        err("Consolidation group has no active entities")

    entity_list = [row_to_dict(e) for e in entities]
    entity_count = len(entity_list)

    audit(conn, SKILL, "run-consolidation", "advacct_consolidation_group", group_id,
          new_values={"period_date": period_date, "entity_count": entity_count})
    conn.commit()
    ok({
        "group_id": group_id, "group_name": group["name"],
        "period_date": period_date, "entity_count": entity_count,
        "entities": [{"entity_name": e["entity_name"], "ownership_pct": e["ownership_pct"],
                      "consolidation_method": e["consolidation_method"]} for e in entity_list],
        "consolidation_run": "completed",
    })


# ===========================================================================
# 5. generate-elimination-entries
# ===========================================================================
def generate_elimination_entries(conn, args):
    group_id = getattr(args, "group_id", None)
    _validate_group(conn, group_id)

    period_date = getattr(args, "period_date", None)
    if not period_date:
        err("--period-date is required")

    company_id = getattr(args, "company_id", None)
    _validate_company(conn, company_id)

    # Get entities in this group
    entities = conn.execute(
        "SELECT entity_company_id FROM advacct_group_entity WHERE group_id = ? AND is_active = 1",
        (group_id,)
    ).fetchall()
    entity_company_ids = [e[0] for e in entities]

    if len(entity_company_ids) < 2:
        err("Need at least 2 active entities for elimination entries")

    # Find posted IC transactions between group entities
    placeholders = ",".join(["?"] * len(entity_company_ids))
    ic_rows = conn.execute(f"""
        SELECT * FROM advacct_ic_transaction
        WHERE ic_status = 'posted'
          AND from_company_id IN ({placeholders})
          AND to_company_id IN ({placeholders})
    """, entity_company_ids + entity_company_ids).fetchall()

    entries_created = 0
    now = _now_iso()

    for ic_row in ic_rows:
        ic = row_to_dict(ic_row)
        amount = ic["amount"]

        # Create elimination entry (debit IC revenue, credit IC expense)
        conn.execute("""
            INSERT INTO advacct_elimination_entry (
                id, group_id, period_date, debit_account, credit_account,
                amount, description, entry_type, company_id, created_at
            ) VALUES (?,?,?,?,?,?,?,?,?,?)
        """, (
            str(uuid.uuid4()), group_id, period_date,
            "IC Revenue", "IC Expense",
            amount,
            f"Elimination: {ic['transaction_type']} from {ic['from_company_id']} to {ic['to_company_id']}",
            "ic_elimination", company_id, now,
        ))
        entries_created += 1

    audit(conn, SKILL, "generate-elimination-entries", "advacct_elimination_entry", group_id,
          new_values={"period_date": period_date, "entries_created": entries_created})
    conn.commit()
    ok({
        "group_id": group_id, "period_date": period_date,
        "entries_created": entries_created,
    })


# ===========================================================================
# 6. add-currency-translation
# ===========================================================================
def add_currency_translation(conn, args):
    group_id = getattr(args, "group_id", None)
    _validate_group(conn, group_id)
    _validate_company(conn, args.company_id)

    period_date = getattr(args, "period_date", None)
    if not period_date:
        err("--period-date is required")

    amount = getattr(args, "amount", None)
    if not amount:
        err("--amount is required")

    debit_account = getattr(args, "debit_account", None) or "CTA - Debit"
    credit_account = getattr(args, "credit_account", None) or "CTA - Credit"

    entry_id = str(uuid.uuid4())
    now = _now_iso()

    conn.execute("""
        INSERT INTO advacct_elimination_entry (
            id, group_id, period_date, debit_account, credit_account,
            amount, description, entry_type, company_id, created_at
        ) VALUES (?,?,?,?,?,?,?,?,?,?)
    """, (
        entry_id, group_id, period_date,
        debit_account, credit_account, amount,
        getattr(args, "description", None) or "Currency translation adjustment",
        "currency_translation", args.company_id, now,
    ))
    audit(conn, SKILL, "add-currency-translation", "advacct_elimination_entry", entry_id,
          new_values={"group_id": group_id, "amount": amount, "entry_type": "currency_translation"})
    conn.commit()
    ok({
        "id": entry_id, "group_id": group_id, "period_date": period_date,
        "amount": amount, "entry_type": "currency_translation",
    })


# ===========================================================================
# 7. consolidation-trial-balance-report
# ===========================================================================
def consolidation_trial_balance_report(conn, args):
    group_id = getattr(args, "group_id", None)
    _validate_group(conn, group_id)

    period_date = getattr(args, "period_date", None)

    group = row_to_dict(conn.execute(
        "SELECT * FROM advacct_consolidation_group WHERE id = ?", (group_id,)
    ).fetchone())

    # Get entities
    entities = conn.execute(
        "SELECT * FROM advacct_group_entity WHERE group_id = ? AND is_active = 1 ORDER BY entity_name",
        (group_id,)
    ).fetchall()

    # Get elimination entries
    where_elim, params_elim = ["group_id = ?"], [group_id]
    if period_date:
        where_elim.append("period_date = ?")
        params_elim.append(period_date)

    elim_entries = conn.execute(
        f"SELECT * FROM advacct_elimination_entry WHERE {' AND '.join(where_elim)} ORDER BY created_at",
        params_elim
    ).fetchall()

    total_eliminations = sum(
        Decimal(row_to_dict(e)["amount"]) for e in elim_entries
    )

    ok({
        "report": "consolidation_trial_balance",
        "group_id": group_id, "group_name": group["name"],
        "period_date": period_date,
        "entities": [row_to_dict(e) for e in entities],
        "elimination_entries": [row_to_dict(e) for e in elim_entries],
        "total_eliminations": str(total_eliminations),
        "entity_count": len(entities),
    })


# ===========================================================================
# 8. consolidation-summary
# ===========================================================================
def consolidation_summary(conn, args):
    group_id = getattr(args, "group_id", None)
    _validate_group(conn, group_id)

    group = row_to_dict(conn.execute(
        "SELECT * FROM advacct_consolidation_group WHERE id = ?", (group_id,)
    ).fetchone())

    entity_count = conn.execute(
        "SELECT COUNT(*) FROM advacct_group_entity WHERE group_id = ? AND is_active = 1",
        (group_id,)
    ).fetchone()[0]

    elimination_count = conn.execute(
        "SELECT COUNT(*) FROM advacct_elimination_entry WHERE group_id = ?",
        (group_id,)
    ).fetchone()[0]

    by_type = conn.execute("""
        SELECT entry_type, COUNT(*) as cnt, SUM(CAST(amount AS NUMERIC)) as total
        FROM advacct_elimination_entry WHERE group_id = ?
        GROUP BY entry_type
    """, (group_id,)).fetchall()

    ok({
        "report": "consolidation_summary",
        "group_id": group_id,
        "group_name": group["name"],
        "group_status": group["group_status"],
        "consolidation_currency": group["consolidation_currency"],
        "entity_count": entity_count,
        "elimination_count": elimination_count,
        "eliminations_by_type": {r[0]: {"count": r[1], "total": str(Decimal(str(r[2])).quantize(Decimal("0.01")))} for r in by_type} if by_type else {},
    })


# ---------------------------------------------------------------------------
# Action registry
# ---------------------------------------------------------------------------
ACTIONS = {
    "add-consolidation-group": add_consolidation_group,
    "list-consolidation-groups": list_consolidation_groups,
    "add-group-entity": add_group_entity,
    "run-consolidation": run_consolidation,
    "generate-elimination-entries": generate_elimination_entries,
    "add-currency-translation": add_currency_translation,
    "consolidation-trial-balance-report": consolidation_trial_balance_report,
    "consolidation-summary": consolidation_summary,
}


path: scripts/erpclaw-accounting-adv/db_query.py
content: #!/usr/bin/env python3
"""ERPClaw Advanced Accounting -- db_query.py (unified router)

ASC 606 revenue recognition, ASC 842 lease accounting, intercompany transactions,
and multi-entity consolidation. 50 actions routed through this single entry point.

Usage: python3 db_query.py --action <action-name> [--flags ...]
Output: JSON to stdout, exit 0 on success, exit 1 on error.
"""
import argparse
import json
import os
import sys

# Add shared lib to path
try:
    sys.path.insert(0, os.path.expanduser("~/.openclaw/erpclaw/lib"))
    from erpclaw_lib.db import get_connection, ensure_db_exists, DEFAULT_DB_PATH
    from erpclaw_lib.response import ok, err
    from erpclaw_lib.args import SafeArgumentParser, check_unknown_args
except ImportError:
    import json as _json
    print(_json.dumps({
        "status": "error",
        "error": "ERPClaw foundation not installed. Install erpclaw-setup first: clawhub install erpclaw-setup",
        "suggestion": "clawhub install erpclaw-setup"
    }))
    sys.exit(1)

# Add this script's directory so domain modules can be imported
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
if SCRIPTS_DIR not in sys.path:
    sys.path.insert(0, SCRIPTS_DIR)

from revenue import ACTIONS as REVENUE_ACTIONS  # noqa: E402
from leases import ACTIONS as LEASE_ACTIONS  # noqa: E402
from intercompany import ACTIONS as IC_ACTIONS  # noqa: E402
from consolidation import ACTIONS as CONSOL_ACTIONS  # noqa: E402
from reports import ACTIONS as REPORT_ACTIONS  # noqa: E402

# Merge all domain actions
ACTIONS = {}
ACTIONS.update(REVENUE_ACTIONS)
ACTIONS.update(LEASE_ACTIONS)
ACTIONS.update(IC_ACTIONS)
ACTIONS.update(CONSOL_ACTIONS)
ACTIONS.update(REPORT_ACTIONS)

SKILL = "erpclaw-accounting-adv"
REQUIRED_TABLES = [
    "company", "advacct_revenue_contract", "advacct_lease",
    "advacct_ic_transaction", "advacct_consolidation_group",
]


def main():
    parser = SafeArgumentParser(description=SKILL)
    parser.add_argument("--action", required=True, choices=sorted(ACTIONS.keys()))
    parser.add_argument("--db-path", default=None)

    # Entity IDs
    parser.add_argument("--id")
    parser.add_argument("--company-id")
    parser.add_argument("--contract-id")
    parser.add_argument("--obligation-id")
    parser.add_argument("--lease-id")
    parser.add_argument("--group-id")

    # Revenue fields
    parser.add_argument("--customer-name")
    parser.add_argument("--contract-number")
    parser.add_argument("--start-date")
    parser.add_argument("--end-date")
    parser.add_argument("--total-value")
    parser.add_argument("--contract-status")
    parser.add_argument("--name")
    parser.add_argument("--standalone-price")
    parser.add_argument("--recognition-method")
    parser.add_argument("--recognition-basis")
    parser.add_argument("--pct-complete")
    parser.add_argument("--description")
    parser.add_argument("--estimated-amount")
    parser.add_argument("--constraint-amount")
    parser.add_argument("--method")
    parser.add_argument("--probability")

    # Lease fields
    parser.add_argument("--lessee-name")
    parser.add_argument("--lessor-name")
    parser.add_argument("--asset-description")
    parser.add_argument("--lease-type")
    parser.add_argument("--term-months", type=int)
    parser.add_argument("--monthly-payment")
    parser.add_argument("--annual-escalation")
    parser.add_argument("--discount-rate")
    parser.add_argument("--purchase-option-price")
    parser.add_argument("--lease-status")
    parser.add_argument("--payment-date")
    parser.add_argument("--payment-amount")

    # Intercompany fields
    parser.add_argument("--from-company-id")
    parser.add_argument("--to-company-id")
    parser.add_argument("--transaction-type")
    parser.add_argument("--amount")
    parser.add_argument("--currency")
    parser.add_argument("--transfer-price-method")
    parser.add_argument("--ic-status")
    parser.add_argument("--markup-pct")
    parser.add_argument("--effective-date")
    parser.add_argument("--expiry-date")

    # Consolidation fields
    parser.add_argument("--parent-company-id")
    parser.add_argument("--consolidation-currency")
    parser.add_argument("--group-status")
    parser.add_argument("--entity-company-id")
    parser.add_argument("--entity-name")
    parser.add_argument("--ownership-pct")
    parser.add_argument("--functional-currency")
    parser.add_argument("--consolidation-method")
    parser.add_argument("--period-date")
    parser.add_argument("--debit-account")
    parser.add_argument("--credit-account")
    parser.add_argument("--obligation-status")

    # Filters
    parser.add_argument("--status")
    parser.add_argument("--search")
    parser.add_argument("--limit", type=int, default=20)
    parser.add_argument("--offset", type=int, default=0)

    args, unknown = parser.parse_known_args()
    check_unknown_args(parser, unknown)

    # DB setup
    db_path = args.db_path or os.environ.get("ERPCLAW_DB_PATH", DEFAULT_DB_PATH)
    ensure_db_exists(db_path)
    conn = get_connection(db_path)

    # Check required tables exist
    tables = [r[0] for r in conn.execute(
        "SELECT name FROM sqlite_master WHERE type='table'"
    ).fetchall()]
    missing = [t for t in REQUIRED_TABLES if t not in tables]
    if missing:
        conn.close()
        err(f"Missing tables: {', '.join(missing)}. Run init_db.py first.",
            suggestion="python3 init_db.py")

    try:
        ACTIONS[args.action](conn, args)
    except Exception as e:
        conn.rollback()
        sys.stderr.write(f"[{SKILL}] {e}\n")
        err(str(e))
    finally:
        conn.close()


if __name__ == "__main__":
    main()


path: scripts/erpclaw-accounting-adv/intercompany.py
content: """ERPClaw Advanced Accounting -- Intercompany Transactions domain module

Actions for intercompany transactions and transfer pricing rules (2 tables, 10 actions).
Imported by db_query.py (unified router).
"""
import os
import sys
import uuid
from datetime import datetime, timezone
from decimal import Decimal

try:
    sys.path.insert(0, os.path.expanduser("~/.openclaw/erpclaw/lib"))
    from erpclaw_lib.naming import get_next_name, ENTITY_PREFIXES
    from erpclaw_lib.response import ok, err, row_to_dict
    from erpclaw_lib.audit import audit
    from erpclaw_lib.query import dynamic_update

    ENTITY_PREFIXES.setdefault("ic_transaction", "ICT-")
except ImportError:
    pass

SKILL = "erpclaw-accounting-adv"

_now_iso = lambda: datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

# ---------------------------------------------------------------------------
# Validation constants
# ---------------------------------------------------------------------------
VALID_TRANSACTION_TYPES = ("sale", "purchase", "service", "loan", "dividend", "allocation")
VALID_IC_STATUSES = ("draft", "pending_approval", "approved", "posted", "eliminated")
VALID_TP_METHODS = ("cost_plus", "resale_minus", "comparable", "other")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_company(conn, company_id, label="--company-id"):
    if not company_id:
        err(f"{label} is required")
    if not conn.execute("SELECT id FROM company WHERE id = ?", (company_id,)).fetchone():
        err(f"Company {company_id} not found")


# ===========================================================================
# 1. add-ic-transaction
# ===========================================================================
def add_ic_transaction(conn, args):
    from_company_id = getattr(args, "from_company_id", None)
    to_company_id = getattr(args, "to_company_id", None)
    company_id = getattr(args, "company_id", None)

    _validate_company(conn, from_company_id, "--from-company-id")
    _validate_company(conn, to_company_id, "--to-company-id")
    _validate_company(conn, company_id)

    if from_company_id == to_company_id:
        err("from-company-id and to-company-id must be different")

    transaction_type = getattr(args, "transaction_type", None)
    if not transaction_type:
        err("--transaction-type is required")
    if transaction_type not in VALID_TRANSACTION_TYPES:
        err(f"Invalid transaction-type: {transaction_type}. Must be one of: {', '.join(VALID_TRANSACTION_TYPES)}")

    amount = getattr(args, "amount", None) or "0"
    try:
        if Decimal(amount) <= 0:
            err("Amount must be greater than zero")
    except Exception:
        err(f"Invalid amount: {amount}")

    ic_id = str(uuid.uuid4())
    conn.company_id = company_id
    naming = get_next_name(conn, "ic_transaction", company_id=company_id)
    now = _now_iso()

    transfer_price_method = getattr(args, "transfer_price_method", None)
    if transfer_price_method and transfer_price_method not in VALID_TP_METHODS:
        err(f"Invalid transfer-price-method: {transfer_price_method}. Must be one of: {', '.join(VALID_TP_METHODS)}")

    conn.execute("""
        INSERT INTO advacct_ic_transaction (
            id, naming_series, from_company_id, to_company_id, transaction_type,
            description, amount, currency, transfer_price_method, ic_status,
            posted_date, company_id, created_at, updated_at
        ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (
        ic_id, naming, from_company_id, to_company_id, transaction_type,
        getattr(args, "description", None),
        amount,
        getattr(args, "currency", None) or "USD",
        transfer_price_method,
        "draft", None, company_id, now, now,
    ))
    audit(conn, SKILL, "add-ic-transaction", "advacct_ic_transaction", ic_id,
          new_values={"from": from_company_id, "to": to_company_id, "amount": amount})
    conn.commit()
    ok({
        "id": ic_id, "naming_series": naming,
        "from_company_id": from_company_id, "to_company_id": to_company_id,
        "transaction_type": transaction_type, "amount": amount, "ic_status": "draft",
    })


# ===========================================================================
# 2. update-ic-transaction
# ===========================================================================
def update_ic_transaction(conn, args):
    ic_id = getattr(args, "id", None)
    if not ic_id:
        err("--id is required")
    row = conn.execute("SELECT * FROM advacct_ic_transaction WHERE id = ?", (ic_id,)).fetchone()
    if not row:
        err(f"IC transaction {ic_id} not found")

    data = row_to_dict(row)
    if data["ic_status"] not in ("draft", "pending_approval"):
        err(f"Cannot update IC transaction in status '{data['ic_status']}'. Must be draft or pending_approval.")

    data, changed = {}, []
    for arg_name, col_name in {
        "description": "description",
        "amount": "amount",
        "currency": "currency",
    }.items():
        val = getattr(args, arg_name, None)
        if val is not None:
            data[col_name] = val
            changed.append(col_name)

    transaction_type = getattr(args, "transaction_type", None)
    if transaction_type:
        if transaction_type not in VALID_TRANSACTION_TYPES:
            err(f"Invalid transaction-type: {transaction_type}")
        data["transaction_type"] = transaction_type
        changed.append("transaction_type")

    transfer_price_method = getattr(args, "transfer_price_method", None)
    if transfer_price_method:
        if transfer_price_method not in VALID_TP_METHODS:
            err(f"Invalid transfer-price-method: {transfer_price_method}")
        data["transfer_price_method"] = transfer_price_method
        changed.append("transfer_price_method")

    if not data:
        err("No fields to update")

    data["updated_at"] = _now_iso()
    sql, params = dynamic_update("advacct_ic_transaction", data, where={"id": ic_id})
    conn.execute(sql, params)
    audit(conn, SKILL, "update-ic-transaction", "advacct_ic_transaction", ic_id,
          new_values={"updated_fields": changed})
    conn.commit()
    ok({"id": ic_id, "updated_fields": changed})


# ===========================================================================
# 3. get-ic-transaction
# ===========================================================================
def get_ic_transaction(conn, args):
    ic_id = getattr(args, "id", None)
    if not ic_id:
        err("--id is required")
    row = conn.execute("SELECT * FROM advacct_ic_transaction WHERE id = ?", (ic_id,)).fetchone()
    if not row:
        err(f"IC transaction {ic_id} not found")
    ok(row_to_dict(row))


# ===========================================================================
# 4. list-ic-transactions
# ===========================================================================
def list_ic_transactions(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)
    if getattr(args, "from_company_id", None):
        where.append("from_company_id = ?")
        params.append(args.from_company_id)
    if getattr(args, "to_company_id", None):
        where.append("to_company_id = ?")
        params.append(args.to_company_id)
    if getattr(args, "transaction_type", None):
        where.append("transaction_type = ?")
        params.append(args.transaction_type)
    if getattr(args, "ic_status", None):
        where.append("ic_status = ?")
        params.append(args.ic_status)
    if getattr(args, "search", None):
        where.append("(LOWER(description) LIKE LOWER(?))")
        params.append(f"%{args.search}%")

    where_sql = " AND ".join(where)
    total = conn.execute(
        f"SELECT COUNT(*) FROM advacct_ic_transaction WHERE {where_sql}", params
    ).fetchone()[0]
    params.extend([args.limit, args.offset])
    rows = conn.execute(
        f"SELECT * FROM advacct_ic_transaction WHERE {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
        params
    ).fetchall()
    ok({
        "rows": [row_to_dict(r) for r in rows],
        "total_count": total, "limit": args.limit, "offset": args.offset,
        "has_more": (args.offset + args.limit) < total,
    })


# ===========================================================================
# 5. approve-ic-transaction
# ===========================================================================
def approve_ic_transaction(conn, args):
    ic_id = getattr(args, "id", None)
    if not ic_id:
        err("--id is required")
    row = conn.execute("SELECT * FROM advacct_ic_transaction WHERE id = ?", (ic_id,)).fetchone()
    if not row:
        err(f"IC transaction {ic_id} not found")

    data = row_to_dict(row)
    if data["ic_status"] not in ("draft", "pending_approval"):
        err(f"Cannot approve IC transaction in status '{data['ic_status']}'. Must be draft or pending_approval.")

    now = _now_iso()
    conn.execute(
        "UPDATE advacct_ic_transaction SET ic_status = 'approved', updated_at = ? WHERE id = ?",
        (now, ic_id)
    )
    audit(conn, SKILL, "approve-ic-transaction", "advacct_ic_transaction", ic_id,
          new_values={"ic_status": "approved"})
    conn.commit()
    ok({"id": ic_id, "ic_status": "approved"})


# ===========================================================================
# 6. post-ic-transaction
# ===========================================================================
def post_ic_transaction(conn, args):
    ic_id = getattr(args, "id", None)
    if not ic_id:
        err("--id is required")
    row = conn.execute("SELECT * FROM advacct_ic_transaction WHERE id = ?", (ic_id,)).fetchone()
    if not row:
        err(f"IC transaction {ic_id} not found")

    data = row_to_dict(row)
    if data["ic_status"] != "approved":
        err(f"Cannot post IC transaction in status '{data['ic_status']}'. Must be approved.")

    now = _now_iso()
    conn.execute(
        "UPDATE advacct_ic_transaction SET ic_status = 'posted', posted_date = ?, updated_at = ? WHERE id = ?",
        (now, now, ic_id)
    )
    audit(conn, SKILL, "post-ic-transaction", "advacct_ic_transaction", ic_id,
          new_values={"ic_status": "posted", "posted_date": now})
    conn.commit()
    ok({"id": ic_id, "ic_status": "posted", "posted_date": now})


# ===========================================================================
# 7. add-transfer-price-rule
# ===========================================================================
def add_transfer_price_rule(conn, args):
    _validate_company(conn, args.company_id)

    method = getattr(args, "method", None) or "cost_plus"
    if method not in VALID_TP_METHODS:
        err(f"Invalid method: {method}. Must be one of: {', '.join(VALID_TP_METHODS)}")

    transaction_type = getattr(args, "transaction_type", None)
    if transaction_type and transaction_type not in VALID_TRANSACTION_TYPES:
        err(f"Invalid transaction-type: {transaction_type}")

    markup_pct = getattr(args, "markup_pct", None) or "0"

    rule_id = str(uuid.uuid4())
    now = _now_iso()

    conn.execute("""
        INSERT INTO advacct_transfer_price_rule (
            id, from_company_id, to_company_id, transaction_type,
            method, markup_pct, effective_date, expiry_date,
            company_id, created_at
        ) VALUES (?,?,?,?,?,?,?,?,?,?)
    """, (
        rule_id,
        getattr(args, "from_company_id", None),
        getattr(args, "to_company_id", None),
        transaction_type,
        method, markup_pct,
        getattr(args, "effective_date", None),
        getattr(args, "expiry_date", None),
        args.company_id, now,
    ))
    audit(conn, SKILL, "add-transfer-price-rule", "advacct_transfer_price_rule", rule_id,
          new_values={"method": method, "markup_pct": markup_pct})
    conn.commit()
    ok({
        "id": rule_id, "method": method, "markup_pct": markup_pct,
        "transaction_type": transaction_type,
    })


# ===========================================================================
# 8. list-transfer-price-rules
# ===========================================================================
def list_transfer_price_rules(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)
    if getattr(args, "from_company_id", None):
        where.append("from_company_id = ?")
        params.append(args.from_company_id)
    if getattr(args, "to_company_id", None):
        where.append("to_company_id = ?")
        params.append(args.to_company_id)
    if getattr(args, "transaction_type", None):
        where.append("transaction_type = ?")
        params.append(args.transaction_type)

    where_sql = " AND ".join(where)
    total = conn.execute(
        f"SELECT COUNT(*) FROM advacct_transfer_price_rule WHERE {where_sql}", params
    ).fetchone()[0]
    params.extend([args.limit, args.offset])
    rows = conn.execute(
        f"SELECT * FROM advacct_transfer_price_rule WHERE {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
        params
    ).fetchall()
    ok({
        "rows": [row_to_dict(r) for r in rows],
        "total_count": total, "limit": args.limit, "offset": args.offset,
        "has_more": (args.offset + args.limit) < total,
    })


# ===========================================================================
# 9. ic-reconciliation-report
# ===========================================================================
def ic_reconciliation_report(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)

    where_sql = " AND ".join(where)
    rows = conn.execute(f"""
        SELECT from_company_id, to_company_id, transaction_type,
               COUNT(*) as transaction_count,
               SUM(CAST(amount AS NUMERIC)) as total_amount,
               ic_status
        FROM advacct_ic_transaction
        WHERE {where_sql}
        GROUP BY from_company_id, to_company_id, transaction_type, ic_status
        ORDER BY from_company_id, to_company_id
    """, params).fetchall()

    ok({
        "report": "ic_reconciliation",
        "rows": [row_to_dict(r) for r in rows],
    })


# ===========================================================================
# 10. ic-elimination-report
# ===========================================================================
def ic_elimination_report(conn, args):
    where, params = ["ic_status = 'posted'"], []
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)

    where_sql = " AND ".join(where)
    rows = conn.execute(f"""
        SELECT id, from_company_id, to_company_id, transaction_type,
               amount, currency, posted_date
        FROM advacct_ic_transaction
        WHERE {where_sql}
        ORDER BY posted_date DESC
    """, params).fetchall()

    total_to_eliminate = sum(Decimal(row_to_dict(r)["amount"]) for r in rows)

    ok({
        "report": "ic_elimination",
        "rows": [row_to_dict(r) for r in rows],
        "total_to_eliminate": str(total_to_eliminate),
        "transaction_count": len(rows),
    })


# ---------------------------------------------------------------------------
# Action registry
# ---------------------------------------------------------------------------
ACTIONS = {
    "add-ic-transaction": add_ic_transaction,
    "update-ic-transaction": update_ic_transaction,
    "get-ic-transaction": get_ic_transaction,
    "list-ic-transactions": list_ic_transactions,
    "approve-ic-transaction": approve_ic_transaction,
    "post-ic-transaction": post_ic_transaction,
    "add-transfer-price-rule": add_transfer_price_rule,
    "list-transfer-price-rules": list_transfer_price_rules,
    "ic-reconciliation-report": ic_reconciliation_report,
    "ic-elimination-report": ic_elimination_report,
}


path: scripts/erpclaw-accounting-adv/reports.py
content: """ERPClaw Advanced Accounting -- Cross-domain reports and status

Aggregated reports and the skill status action.
Imported by db_query.py (unified router).
"""
import os
import sys

try:
    sys.path.insert(0, os.path.expanduser("~/.openclaw/erpclaw/lib"))
    from erpclaw_lib.response import ok, err, row_to_dict
except ImportError:
    pass

SKILL = "erpclaw-accounting-adv"
VERSION = "1.0.0"

ALL_TABLES = [
    "advacct_revenue_contract", "advacct_performance_obligation",
    "advacct_variable_consideration", "advacct_revenue_schedule",
    "advacct_lease", "advacct_lease_payment", "advacct_amortization_entry",
    "advacct_ic_transaction", "advacct_transfer_price_rule",
    "advacct_consolidation_group", "advacct_group_entity", "advacct_elimination_entry",
]


# ===========================================================================
# 1. standards-compliance-dashboard
# ===========================================================================
def standards_compliance_dashboard(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)
    where_sql = " AND ".join(where)

    # Revenue (ASC 606)
    revenue_contracts = conn.execute(
        f"SELECT COUNT(*) FROM advacct_revenue_contract WHERE {where_sql}", params
    ).fetchone()[0]
    unsatisfied_obligations = conn.execute(
        f"SELECT COUNT(*) FROM advacct_performance_obligation WHERE obligation_status != 'satisfied' AND {where_sql}", params
    ).fetchone()[0]

    # Leases (ASC 842)
    active_leases = conn.execute(
        f"SELECT COUNT(*) FROM advacct_lease WHERE lease_status = 'active' AND {where_sql}", params
    ).fetchone()[0]
    leases_without_rou = conn.execute(
        f"SELECT COUNT(*) FROM advacct_lease WHERE rou_asset_value IS NULL AND lease_status != 'draft' AND {where_sql}", params
    ).fetchone()[0]

    # Intercompany
    unposted_ic = conn.execute(
        f"SELECT COUNT(*) FROM advacct_ic_transaction WHERE ic_status NOT IN ('posted','eliminated') AND {where_sql}", params
    ).fetchone()[0]

    # Consolidation
    active_groups = conn.execute(
        f"SELECT COUNT(*) FROM advacct_consolidation_group WHERE group_status = 'active' AND {where_sql}", params
    ).fetchone()[0]

    ok({
        "report": "standards_compliance_dashboard",
        "asc_606": {
            "revenue_contracts": revenue_contracts,
            "unsatisfied_obligations": unsatisfied_obligations,
        },
        "asc_842": {
            "active_leases": active_leases,
            "leases_without_rou_calculation": leases_without_rou,
        },
        "intercompany": {
            "unposted_transactions": unposted_ic,
        },
        "consolidation": {
            "active_groups": active_groups,
        },
    })


# ===========================================================================
# 2. status
# ===========================================================================
def status_action(conn, args):
    counts = {}
    for tbl in ALL_TABLES:
        try:
            counts[tbl] = conn.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()[0]
        except Exception:
            counts[tbl] = 0

    ok({
        "skill": SKILL,
        "version": VERSION,
        "total_tables": len(ALL_TABLES),
        "record_counts": counts,
    })


# ---------------------------------------------------------------------------
# Action registry
# ---------------------------------------------------------------------------
ACTIONS = {
    "standards-compliance-dashboard": standards_compliance_dashboard,
    "status": status_action,
}


path: scripts/erpclaw-accounting-adv/revenue.py
content: """ERPClaw Advanced Accounting -- Revenue Recognition (ASC 606) domain module

Actions for revenue contracts, performance obligations, variable consideration,
and revenue schedules (4 tables, 14 actions).
Imported by db_query.py (unified router).
"""
import os
import sys
import uuid
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP

try:
    sys.path.insert(0, os.path.expanduser("~/.openclaw/erpclaw/lib"))
    from erpclaw_lib.naming import get_next_name, ENTITY_PREFIXES
    from erpclaw_lib.response import ok, err, row_to_dict
    from erpclaw_lib.audit import audit
    from erpclaw_lib.query import dynamic_update

    ENTITY_PREFIXES.setdefault("revenue_contract", "RCON-")
except ImportError:
    pass

SKILL = "erpclaw-accounting-adv"

_now_iso = lambda: datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

# ---------------------------------------------------------------------------
# Validation constants
# ---------------------------------------------------------------------------
VALID_CONTRACT_STATUSES = ("draft", "active", "modified", "completed", "terminated")
VALID_RECOGNITION_METHODS = ("point_in_time", "over_time")
VALID_RECOGNITION_BASES = ("output", "input", "time")
VALID_OBLIGATION_STATUSES = ("unsatisfied", "partially_satisfied", "satisfied")
VALID_VC_METHODS = ("expected_value", "most_likely")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_company(conn, company_id):
    if not company_id:
        err("--company-id is required")
    if not conn.execute("SELECT id FROM company WHERE id = ?", (company_id,)).fetchone():
        err(f"Company {company_id} not found")


def _validate_contract(conn, contract_id):
    if not contract_id:
        err("--contract-id is required")
    row = conn.execute("SELECT id FROM advacct_revenue_contract WHERE id = ?", (contract_id,)).fetchone()
    if not row:
        err(f"Revenue contract {contract_id} not found")


# ===========================================================================
# 1. add-revenue-contract
# ===========================================================================
def add_revenue_contract(conn, args):
    _validate_company(conn, args.company_id)
    customer_name = getattr(args, "customer_name", None)
    if not customer_name:
        err("--customer-name is required")

    total_value = getattr(args, "total_value", None) or "0"
    # Validate it's a valid decimal
    try:
        Decimal(total_value)
    except Exception:
        err(f"Invalid total-value: {total_value}")

    contract_id = str(uuid.uuid4())
    conn.company_id = args.company_id
    naming = get_next_name(conn, "revenue_contract", company_id=args.company_id)
    now = _now_iso()

    conn.execute("""
        INSERT INTO advacct_revenue_contract (
            id, naming_series, customer_name, contract_number, start_date, end_date,
            total_value, allocated_value, contract_status, modification_count,
            company_id, created_at, updated_at
        ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (
        contract_id, naming, customer_name,
        getattr(args, "contract_number", None),
        getattr(args, "start_date", None),
        getattr(args, "end_date", None),
        total_value, "0", "draft", 0,
        args.company_id, now, now,
    ))
    audit(conn, SKILL, "add-revenue-contract", "advacct_revenue_contract", contract_id,
          new_values={"customer_name": customer_name, "total_value": total_value})
    conn.commit()
    ok({
        "id": contract_id, "naming_series": naming,
        "customer_name": customer_name, "contract_status": "draft",
        "total_value": total_value,
    })


# ===========================================================================
# 2. update-revenue-contract
# ===========================================================================
def update_revenue_contract(conn, args):
    contract_id = getattr(args, "id", None)
    if not contract_id:
        err("--id is required")
    if not conn.execute("SELECT id FROM advacct_revenue_contract WHERE id = ?", (contract_id,)).fetchone():
        err(f"Revenue contract {contract_id} not found")

    data, changed = {}, []
    for arg_name, col_name in {
        "customer_name": "customer_name",
        "contract_number": "contract_number",
        "start_date": "start_date",
        "end_date": "end_date",
        "total_value": "total_value",
    }.items():
        val = getattr(args, arg_name, None)
        if val is not None:
            data[col_name] = val
            changed.append(col_name)

    contract_status = getattr(args, "contract_status", None)
    if contract_status:
        if contract_status not in VALID_CONTRACT_STATUSES:
            err(f"Invalid contract-status: {contract_status}. Must be one of: {', '.join(VALID_CONTRACT_STATUSES)}")
        data["contract_status"] = contract_status
        changed.append("contract_status")

    if not data:
        err("No fields to update")

    data["updated_at"] = _now_iso()
    sql, params = dynamic_update("advacct_revenue_contract", data, where={"id": contract_id})
    conn.execute(sql, params)
    audit(conn, SKILL, "update-revenue-contract", "advacct_revenue_contract", contract_id,
          new_values={"updated_fields": changed})
    conn.commit()
    ok({"id": contract_id, "updated_fields": changed})


# ===========================================================================
# 3. get-revenue-contract
# ===========================================================================
def get_revenue_contract(conn, args):
    contract_id = getattr(args, "id", None)
    if not contract_id:
        err("--id is required")
    row = conn.execute("SELECT * FROM advacct_revenue_contract WHERE id = ?", (contract_id,)).fetchone()
    if not row:
        err(f"Revenue contract {contract_id} not found")
    data = row_to_dict(row)

    # Include obligations
    obligations = conn.execute(
        "SELECT * FROM advacct_performance_obligation WHERE contract_id = ? ORDER BY created_at",
        (contract_id,)
    ).fetchall()
    data["obligations"] = [row_to_dict(o) for o in obligations]
    data["obligation_count"] = len(obligations)

    # Include variable considerations
    vcs = conn.execute(
        "SELECT * FROM advacct_variable_consideration WHERE contract_id = ? ORDER BY created_at",
        (contract_id,)
    ).fetchall()
    data["variable_considerations"] = [row_to_dict(v) for v in vcs]

    ok(data)


# ===========================================================================
# 4. list-revenue-contracts
# ===========================================================================
def list_revenue_contracts(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)
    if getattr(args, "contract_status", None):
        where.append("contract_status = ?")
        params.append(args.contract_status)
    if getattr(args, "search", None):
        where.append("(LOWER(customer_name) LIKE LOWER(?) OR LOWER(contract_number) LIKE LOWER(?))")
        params.extend([f"%{args.search}%", f"%{args.search}%"])

    where_sql = " AND ".join(where)
    total = conn.execute(
        f"SELECT COUNT(*) FROM advacct_revenue_contract WHERE {where_sql}", params
    ).fetchone()[0]
    params.extend([args.limit, args.offset])
    rows = conn.execute(
        f"SELECT * FROM advacct_revenue_contract WHERE {where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?",
        params
    ).fetchall()
    ok({
        "rows": [row_to_dict(r) for r in rows],
        "total_count": total, "limit": args.limit, "offset": args.offset,
        "has_more": (args.offset + args.limit) < total,
    })


# ===========================================================================
# 5. add-performance-obligation
# ===========================================================================
def add_performance_obligation(conn, args):
    contract_id = getattr(args, "contract_id", None)
    _validate_contract(conn, contract_id)
    _validate_company(conn, args.company_id)

    name = getattr(args, "name", None)
    if not name:
        err("--name is required")

    standalone_price = getattr(args, "standalone_price", None) or "0"
    recognition_method = getattr(args, "recognition_method", None) or "over_time"
    if recognition_method not in VALID_RECOGNITION_METHODS:
        err(f"Invalid recognition-method: {recognition_method}. Must be one of: {', '.join(VALID_RECOGNITION_METHODS)}")

    recognition_basis = getattr(args, "recognition_basis", None) or "time"
    if recognition_basis not in VALID_RECOGNITION_BASES:
        err(f"Invalid recognition-basis: {recognition_basis}. Must be one of: {', '.join(VALID_RECOGNITION_BASES)}")

    obligation_id = str(uuid.uuid4())
    now = _now_iso()

    # Auto-allocate price: if standalone_price provided, use it as allocated_price
    allocated_price = standalone_price

    conn.execute("""
        INSERT INTO advacct_performance_obligation (
            id, contract_id, name, standalone_price, allocated_price,
            recognition_method, recognition_basis, pct_complete,
            obligation_status, satisfied_date, company_id, created_at, updated_at
        ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
    """, (
        obligation_id, contract_id, name, standalone_price, allocated_price,
        recognition_method, recognition_basis, "0",
        "unsatisfied", None, args.company_id, now, now,
    ))

    # Update contract allocated_value
    total_allocated = conn.execute(
        "SELECT COALESCE(SUM(CAST(allocated_price AS NUMERIC)), 0) FROM advacct_performance_obligation WHERE contract_id = ?",
        (contract_id,)
    ).fetchone()[0]
    conn.execute(
        "UPDATE advacct_revenue_contract SET allocated_value = ?, updated_at = ? WHERE id = ?",
        (str(Decimal(str(total_allocated)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)), _now_iso(), contract_id)
    )

    audit(conn, SKILL, "add-performance-obligation", "advacct_performance_obligation", obligation_id,
          new_values={"contract_id": contract_id, "name": name, "standalone_price": standalone_price})
    conn.commit()
    ok({
        "id": obligation_id, "contract_id": contract_id, "name": name,
        "standalone_price": standalone_price, "allocated_price": allocated_price,
        "recognition_method": recognition_method, "obligation_status": "unsatisfied",
    })


# ===========================================================================
# 6. list-performance-obligations
# ===========================================================================
def list_performance_obligations(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "contract_id", None):
        where.append("contract_id = ?")
        params.append(args.contract_id)
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)
    if getattr(args, "obligation_status", None):
        where.append("obligation_status = ?")
        params.append(args.obligation_status)

    where_sql = " AND ".join(where)
    total = conn.execute(
        f"SELECT COUNT(*) FROM advacct_performance_obligation WHERE {where_sql}", params
    ).fetchone()[0]
    params.extend([args.limit, args.offset])
    rows = conn.execute(
        f"SELECT * FROM advacct_performance_obligation WHERE {where_sql} ORDER BY created_at LIMIT ? OFFSET ?",
        params
    ).fetchall()
    ok({
        "rows": [row_to_dict(r) for r in rows],
        "total_count": total, "limit": args.limit, "offset": args.offset,
        "has_more": (args.offset + args.limit) < total,
    })


# ===========================================================================
# 7. satisfy-performance-obligation
# ===========================================================================
def satisfy_performance_obligation(conn, args):
    ob_id = getattr(args, "id", None)
    if not ob_id:
        err("--id is required")
    row = conn.execute("SELECT * FROM advacct_performance_obligation WHERE id = ?", (ob_id,)).fetchone()
    if not row:
        err(f"Performance obligation {ob_id} not found")

    data = row_to_dict(row)
    if data["obligation_status"] == "satisfied":
        err("Performance obligation is already satisfied")

    pct_complete = getattr(args, "pct_complete", None) or "100"
    try:
        pct = Decimal(pct_complete)
    except Exception:
        err(f"Invalid pct-complete: {pct_complete}")

    if pct < Decimal("0") or pct > Decimal("100"):
        err("pct-complete must be between 0 and 100")

    now = _now_iso()
    if pct == Decimal("100"):
        new_status = "satisfied"
        satisfied_date = now
    elif pct > Decimal("0"):
        new_status = "partially_satisfied"
        satisfied_date = None
    else:
        new_status = "unsatisfied"
        satisfied_date = None

    conn.execute("""
        UPDATE advacct_performance_obligation
        SET pct_complete = ?, obligation_status = ?, satisfied_date = ?, updated_at = ?
        WHERE id = ?
    """, (str(pct), new_status, satisfied_date, now, ob_id))

    audit(conn, SKILL, "satisfy-performance-obligation", "advacct_performance_obligation", ob_id,
          new_values={"pct_complete": str(pct), "obligation_status": new_status})
    conn.commit()
    ok({"id": ob_id, "pct_complete": str(pct), "obligation_status": new_status})


# ===========================================================================
# 8. add-variable-consideration
# ===========================================================================
def add_variable_consideration(conn, args):
    contract_id = getattr(args, "contract_id", None)
    _validate_contract(conn, contract_id)
    _validate_company(conn, args.company_id)

    description = getattr(args, "description", None)
    if not description:
        err("--description is required")

    estimated_amount = getattr(args, "estimated_amount", None) or "0"
    constraint_amount = getattr(args, "constraint_amount", None) or "0"
    method = getattr(args, "method", None) or "expected_value"
    if method not in VALID_VC_METHODS:
        err(f"Invalid method: {method}. Must be one of: {', '.join(VALID_VC_METHODS)}")

    probability = getattr(args, "probability", None) or "0"

    vc_id = str(uuid.uuid4())
    now = _now_iso()

    conn.execute("""
        INSERT INTO advacct_variable_consideration (
            id, contract_id, description, estimated_amount, constraint_amount,
            method, probability, company_id, created_at
        ) VALUES (?,?,?,?,?,?,?,?,?)
    """, (
        vc_id, contract_id, description, estimated_amount, constraint_amount,
        method, probability, args.company_id, now,
    ))
    audit(conn, SKILL, "add-variable-consideration", "advacct_variable_consideration", vc_id,
          new_values={"contract_id": contract_id, "description": description})
    conn.commit()
    ok({
        "id": vc_id, "contract_id": contract_id, "description": description,
        "estimated_amount": estimated_amount, "method": method,
    })


# ===========================================================================
# 9. list-variable-considerations
# ===========================================================================
def list_variable_considerations(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "contract_id", None):
        where.append("contract_id = ?")
        params.append(args.contract_id)
    if getattr(args, "company_id", None):
        where.append("company_id = ?")
        params.append(args.company_id)

    where_sql = " AND ".join(where)
    total = conn.execute(
        f"SELECT COUNT(*) FROM advacct_variable_consideration WHERE {where_sql}", params
    ).fetchone()[0]
    params.extend([args.limit, args.offset])
    rows = conn.execute(
        f"SELECT * FROM advacct_variable_consideration WHERE {where_sql} ORDER BY created_at LIMIT ? OFFSET ?",
        params
    ).fetchall()
    ok({
        "rows": [row_to_dict(r) for r in rows],
        "total_count": total, "limit": args.limit, "offset": args.offset,
        "has_more": (args.offset + args.limit) < total,
    })


# ===========================================================================
# 10. modify-contract
# ===========================================================================
def modify_contract(conn, args):
    contract_id = getattr(args, "id", None)
    if not contract_id:
        err("--id is required")
    row = conn.execute("SELECT * FROM advacct_revenue_contract WHERE id = ?", (contract_id,)).fetchone()
    if not row:
        err(f"Revenue contract {contract_id} not found")

    data = row_to_dict(row)
    if data["contract_status"] not in ("draft", "active"):
        err(f"Cannot modify contract in status '{data['contract_status']}'. Must be draft or active.")

    now = _now_iso()
    new_count = data["modification_count"] + 1
    conn.execute("""
        UPDATE advacct_revenue_contract
        SET contract_status = 'modified', modification_count = ?, updated_at = ?
        WHERE id = ?
    """, (new_count, now, contract_id))

    audit(conn, SKILL, "modify-contract", "advacct_revenue_contract", contract_id,
          new_values={"contract_status": "modified", "modification_count": new_count})
    conn.commit()
    ok({"id": contract_id, "contract_status": "modified", "modification_count": new_count})


# ===========================================================================
# 11. calculate-revenue-schedule
# ===========================================================================
def calculate_revenue_schedule(conn, args):
    ob_id = getattr(args, "obligation_id", None)
    if not ob_id:
        err("--obligation-id is required")
    row = conn.execute("SELECT * FROM advacct_performance_obligation WHERE id = ?", (ob_id,)).fetchone()
    if not row:
        err(f"Performance obligation {ob_id} not found")

    ob = row_to_dict(row)

    # Get contract for dates
    contract = conn.execute(
        "SELECT * FROM advacct_revenue_contract WHERE id = ?", (ob["contract_id"],)
    ).fetchone()
    if not contract:
        err("Associated contract not found")
    contract_data = row_to_dict(contract)

    start_date = contract_data.get("start_date")
    end_date = contract_data.get("end_date")
    if not start_date or not end_date:
        err("Contract must have start_date and end_date to calculate schedule")

    allocated_price = Decimal(ob["allocated_price"]).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    if allocated_price <= 0:
        err("Allocated price must be greater than zero")

    # Delete existing schedule entries for this obligation
    conn.execute("DELETE FROM advacct_revenue_schedule WHERE obligation_id = ?", (ob_id,))

    # Calculate monthly schedule
    from datetime import date
    start = date.fromisoformat(start_date)
    end = date.fromisoformat(end_date)

    # Count months
    months = (end.year - start.year) * 12 + (end.month - start.month) + 1
    if months <= 0:
        err("End date must be after start date")

    monthly_amount = (allocated_price / Decimal(str(months))).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    remainder = allocated_price - (monthly_amount * (months - 1))

    entries_created = 0
    company_id = ob["company_id"]
    now = _now_iso()

    current_year = start.year
    current_month = start.month

    for i in range(months):
        period_date = f"{current_year}-{current_month:02d}-01"
        amount = str(remainder) if i == months - 1 else str(monthly_amount)

        conn.execute("""
            INSERT INTO advacct_revenue_schedule (
                id, obligation_id, period_date, amount, recognized, company_id, created_at
            ) VALUES (?,?,?,?,?,?,?)
        """, (str(uuid.uuid4()), ob_id, period_date, amount, 0, company_id, now))
        entries_created += 1

        current_month += 1
        if current_month > 12:
            current_month = 1
            current_year += 1

    audit(conn, SKILL, "calculate-revenue-schedule", "advacct_revenue_schedule", ob_id,
          new_values={"entries_created": entries_created, "total_amount": str(allocated_price)})
    conn.commit()
    ok({
        "obligation_id": ob_id, "entries_created": entries_created,
        "monthly_amount": str(monthly_amount), "total_amount": str(allocated_price),
    })


# ===========================================================================
# 12. generate-revenue-entries
# ===========================================================================
def generate_revenue_entries(conn, args):
    ob_id = getattr(args, "obligation_id", None)
    if not ob_id:
        err("--obligation-id is required")
    row = conn.execute("SELECT * FROM advacct_performance_obligation WHERE id = ?", (ob_id,)).fetchone()
    if not row:
        err(f"Performance obligation {ob_id} not found")

    # Get unrecognized schedule entries
    schedules = conn.execute(
        "SELECT * FROM advacct_revenue_schedule WHERE obligation_id = ? AND recognized = 0 ORDER BY period_date",
        (ob_id,)
    ).fetchall()

    if not schedules:
        err("No unrecognized revenue schedule entries found")

    recognized_count = 0
    total_recognized = Decimal("0")

    for sched in schedules:
        s = row_to_dict(sched)
        conn.execute(
            "UPDATE advacct_revenue_schedule SET recognized = 1 WHERE id = ?",
            (s["id"],)
        )
        recognized_count += 1
        total_recognized += Decimal(s["amount"])

    audit(conn, SKILL, "generate-revenue-entries", "advacct_revenue_schedule", ob_id,
          new_values={"recognized_count": recognized_count, "total_recognized": str(total_recognized)})
    conn.commit()
    ok({
        "obligation_id": ob_id,
        "recognized_count": recognized_count,
        "total_recognized": str(total_recognized),
    })


# ===========================================================================
# 13. revenue-waterfall-report
# ===========================================================================
def revenue_waterfall_report(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("c.company_id = ?")
        params.append(args.company_id)

    where_sql = " AND ".join(where)
    rows = conn.execute(f"""
        SELECT c.id as contract_id, c.customer_name, c.contract_number,
               c.total_value, c.allocated_value, c.contract_status,
               COUNT(po.id) as obligation_count,
               SUM(CASE WHEN po.obligation_status = 'satisfied' THEN 1 ELSE 0 END) as satisfied_count
        FROM advacct_revenue_contract c
        LEFT JOIN advacct_performance_obligation po ON po.contract_id = c.id
        WHERE {where_sql}
        GROUP BY c.id
        ORDER BY c.created_at DESC
    """, params).fetchall()

    ok({
        "report": "revenue_waterfall",
        "rows": [row_to_dict(r) for r in rows],
        "total_contracts": len(rows),
    })


# ===========================================================================
# 14. revenue-recognition-summary
# ===========================================================================
def revenue_recognition_summary(conn, args):
    where, params = ["1=1"], []
    if getattr(args, "company_id", None):
        where.append("rs.company_id = ?")
        params.append(args.company_id)

    where_sql = " AND ".join(where)
    rows = conn.execute(f"""
        SELECT rs.period_date,
               SUM(CAST(rs.amount AS NUMERIC)) as total_amount,
               SUM(CASE WHEN rs.recognized = 1 THEN CAST(rs.amount AS NUMERIC) ELSE 0 END) as recognized_amount,
               SUM(CASE WHEN rs.recognized = 0 THEN CAST(rs.amount AS NUMERIC) ELSE 0 END) as unrecognized_amount,
               COUNT(*) as entry_count
        FROM advacct_revenue_schedule rs
        WHERE {where_sql}
        GROUP BY rs.period_date
        ORDER BY rs.period_date
    """, params).fetchall()

    ok({
        "report": "revenue_recognition_summary",
        "rows": [row_to_dict(r) for r in rows],
        "total_periods": len(rows),
    })


# ---------------------------------------------------------------------------
# Action registry
# ---------------------------------------------------------------------------
ACTIONS = {
    "add-revenue-contract": add_revenue_contract,
    "update-revenue-contract": update_revenue_contract,
    "get-revenue-contract": get_revenue_contract,
    "list-revenue-contracts": list_revenue_contracts,
    "add-performance-obligation": add_performance_obligation,
    "list-performance-obligations": list_performance_obligations,
    "satisfy-performance-obligation": satisfy_performance_obligation,
    "add-variable-consideration": add_variable_consideration,
    "list-variable-considerations": list_variable_considerations,
    "modify-contract": modify_contract,
    "calculate-revenue-schedule": calculate_revenue_schedule,
    "generate-revenue-entries": generate_revenue_entries,
    "revenue-waterfall-report": revenue_waterfall_report,
    "revenue-recognition-summary": revenue_recognition_summary,
}


path: scripts/erpclaw-accounting-adv/init_db.py
content: #!/usr/bin/env python3
"""ERPClaw Advanced Accounting schema extension -- adds ASC 606, ASC 842,
intercompany, and consolidation tables to the shared database.

12 tables: 4 revenue, 3 lease, 2 intercompany, 3 consolidation.

Prerequisite: ERPClaw init_db.py must have run first (creates foundation tables).
Run: python3 init_db.py [db_path]
"""
import os
import sqlite3
import sys

DEFAULT_DB_PATH = os.path.expanduser("~/.openclaw/erpclaw/data.sqlite")
DISPLAY_NAME = "ERPClaw Advanced Accounting"

REQUIRED_FOUNDATION = [
    "company", "naming_series", "audit_log",
]


def create_accounting_adv_tables(db_path=None):
    db_path = db_path or os.environ.get("ERPCLAW_DB_PATH", DEFAULT_DB_PATH)
    conn = sqlite3.connect(db_path)
    from erpclaw_lib.db import setup_pragmas
    setup_pragmas(conn)

    # -- Verify ERPClaw foundation --
    tables = [r[0] for r in conn.execute(
        "SELECT name FROM sqlite_master WHERE type='table'"
    ).fetchall()]
    missing = [t for t in REQUIRED_FOUNDATION if t not in tables]
    if missing:
        print(f"ERROR: Foundation tables missing: {', '.join(missing)}")
        print("Run erpclaw-setup first: clawhub install erpclaw-setup")
        conn.close()
        sys.exit(1)

    tables_created = 0
    indexes_created = 0

    # ==================================================================
    # DOMAIN 1: Revenue Recognition (ASC 606) -- 4 tables
    # ==================================================================

    # 1. advacct_revenue_contract
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_revenue_contract (
            id                  TEXT PRIMARY KEY,
            naming_series       TEXT,
            customer_name       TEXT NOT NULL,
            contract_number     TEXT,
            start_date          TEXT,
            end_date            TEXT,
            total_value         TEXT NOT NULL DEFAULT '0',
            allocated_value     TEXT NOT NULL DEFAULT '0',
            contract_status     TEXT NOT NULL DEFAULT 'active'
                                CHECK(contract_status IN ('draft','active','modified','completed','terminated')),
            modification_count  INTEGER NOT NULL DEFAULT 0,
            company_id          TEXT NOT NULL REFERENCES company(id),
            created_at          TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at          TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_rcon_company ON advacct_revenue_contract(company_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_rcon_status ON advacct_revenue_contract(contract_status)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_rcon_customer ON advacct_revenue_contract(customer_name)")
    indexes_created += 3

    # 2. advacct_performance_obligation
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_performance_obligation (
            id                  TEXT PRIMARY KEY,
            contract_id         TEXT NOT NULL REFERENCES advacct_revenue_contract(id) ON DELETE CASCADE,
            name                TEXT NOT NULL,
            standalone_price    TEXT NOT NULL DEFAULT '0',
            allocated_price     TEXT NOT NULL DEFAULT '0',
            recognition_method  TEXT NOT NULL DEFAULT 'over_time'
                                CHECK(recognition_method IN ('point_in_time','over_time')),
            recognition_basis   TEXT NOT NULL DEFAULT 'time'
                                CHECK(recognition_basis IN ('output','input','time')),
            pct_complete        TEXT NOT NULL DEFAULT '0',
            obligation_status   TEXT NOT NULL DEFAULT 'unsatisfied'
                                CHECK(obligation_status IN ('unsatisfied','partially_satisfied','satisfied')),
            satisfied_date      TEXT,
            company_id          TEXT NOT NULL REFERENCES company(id),
            created_at          TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at          TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_po_contract ON advacct_performance_obligation(contract_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_po_status ON advacct_performance_obligation(obligation_status)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_po_company ON advacct_performance_obligation(company_id)")
    indexes_created += 3

    # 3. advacct_variable_consideration
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_variable_consideration (
            id                  TEXT PRIMARY KEY,
            contract_id         TEXT NOT NULL REFERENCES advacct_revenue_contract(id) ON DELETE CASCADE,
            description         TEXT NOT NULL,
            estimated_amount    TEXT NOT NULL DEFAULT '0',
            constraint_amount   TEXT NOT NULL DEFAULT '0',
            method              TEXT NOT NULL DEFAULT 'expected_value'
                                CHECK(method IN ('expected_value','most_likely')),
            probability         TEXT NOT NULL DEFAULT '0',
            company_id          TEXT NOT NULL REFERENCES company(id),
            created_at          TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_vc_contract ON advacct_variable_consideration(contract_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_vc_company ON advacct_variable_consideration(company_id)")
    indexes_created += 2

    # 4. advacct_revenue_schedule
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_revenue_schedule (
            id                  TEXT PRIMARY KEY,
            obligation_id       TEXT NOT NULL REFERENCES advacct_performance_obligation(id) ON DELETE CASCADE,
            period_date         TEXT NOT NULL,
            amount              TEXT NOT NULL DEFAULT '0',
            recognized          INTEGER NOT NULL DEFAULT 0,
            company_id          TEXT NOT NULL REFERENCES company(id),
            created_at          TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_rs_obligation ON advacct_revenue_schedule(obligation_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_rs_period ON advacct_revenue_schedule(period_date)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_rs_company ON advacct_revenue_schedule(company_id)")
    indexes_created += 3

    # ==================================================================
    # DOMAIN 2: Lease Accounting (ASC 842) -- 3 tables
    # ==================================================================

    # 5. advacct_lease
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_lease (
            id                      TEXT PRIMARY KEY,
            naming_series           TEXT,
            lessee_name             TEXT NOT NULL,
            lessor_name             TEXT NOT NULL,
            asset_description       TEXT,
            lease_type              TEXT NOT NULL DEFAULT 'operating'
                                    CHECK(lease_type IN ('operating','finance')),
            start_date              TEXT,
            end_date                TEXT,
            term_months             INTEGER NOT NULL DEFAULT 0,
            monthly_payment         TEXT NOT NULL DEFAULT '0',
            annual_escalation       TEXT NOT NULL DEFAULT '0',
            discount_rate           TEXT NOT NULL DEFAULT '0',
            purchase_option_price   TEXT,
            rou_asset_value         TEXT,
            lease_liability         TEXT,
            lease_status            TEXT NOT NULL DEFAULT 'draft'
                                    CHECK(lease_status IN ('draft','active','modified','expired','terminated')),
            company_id              TEXT NOT NULL REFERENCES company(id),
            created_at              TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at              TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_lease_company ON advacct_lease(company_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_lease_status ON advacct_lease(lease_status)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_lease_type ON advacct_lease(lease_type)")
    indexes_created += 3

    # 6. advacct_lease_payment
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_lease_payment (
            id                  TEXT PRIMARY KEY,
            lease_id            TEXT NOT NULL REFERENCES advacct_lease(id) ON DELETE CASCADE,
            payment_date        TEXT NOT NULL,
            payment_amount      TEXT NOT NULL DEFAULT '0',
            principal           TEXT NOT NULL DEFAULT '0',
            interest            TEXT NOT NULL DEFAULT '0',
            balance_after       TEXT NOT NULL DEFAULT '0',
            payment_status      TEXT NOT NULL DEFAULT 'scheduled'
                                CHECK(payment_status IN ('scheduled','paid','overdue')),
            company_id          TEXT NOT NULL REFERENCES company(id),
            created_at          TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_lpay_lease ON advacct_lease_payment(lease_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_lpay_date ON advacct_lease_payment(payment_date)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_lpay_status ON advacct_lease_payment(payment_status)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_lpay_company ON advacct_lease_payment(company_id)")
    indexes_created += 4

    # 7. advacct_amortization_entry
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_amortization_entry (
            id                  TEXT PRIMARY KEY,
            lease_id            TEXT NOT NULL REFERENCES advacct_lease(id) ON DELETE CASCADE,
            period_date         TEXT NOT NULL,
            opening_balance     TEXT NOT NULL DEFAULT '0',
            payment             TEXT NOT NULL DEFAULT '0',
            interest            TEXT NOT NULL DEFAULT '0',
            principal           TEXT NOT NULL DEFAULT '0',
            closing_balance     TEXT NOT NULL DEFAULT '0',
            company_id          TEXT NOT NULL REFERENCES company(id),
            created_at          TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_amort_lease ON advacct_amortization_entry(lease_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_amort_period ON advacct_amortization_entry(period_date)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_amort_company ON advacct_amortization_entry(company_id)")
    indexes_created += 3

    # ==================================================================
    # DOMAIN 3: Intercompany Transactions -- 2 tables
    # ==================================================================

    # 8. advacct_ic_transaction
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_ic_transaction (
            id                      TEXT PRIMARY KEY,
            naming_series           TEXT,
            from_company_id         TEXT NOT NULL,
            to_company_id           TEXT NOT NULL,
            transaction_type        TEXT NOT NULL
                                    CHECK(transaction_type IN ('sale','purchase','service','loan','dividend','allocation')),
            description             TEXT,
            amount                  TEXT NOT NULL DEFAULT '0',
            currency                TEXT NOT NULL DEFAULT 'USD',
            transfer_price_method   TEXT
                                    CHECK(transfer_price_method IN ('cost_plus','resale_minus','comparable','other')),
            ic_status               TEXT NOT NULL DEFAULT 'draft'
                                    CHECK(ic_status IN ('draft','pending_approval','approved','posted','eliminated')),
            posted_date             TEXT,
            company_id              TEXT NOT NULL REFERENCES company(id),
            created_at              TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at              TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ict_from ON advacct_ic_transaction(from_company_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ict_to ON advacct_ic_transaction(to_company_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ict_status ON advacct_ic_transaction(ic_status)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ict_type ON advacct_ic_transaction(transaction_type)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ict_company ON advacct_ic_transaction(company_id)")
    indexes_created += 5

    # 9. advacct_transfer_price_rule
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_transfer_price_rule (
            id                  TEXT PRIMARY KEY,
            from_company_id     TEXT,
            to_company_id       TEXT,
            transaction_type    TEXT
                                CHECK(transaction_type IN ('sale','purchase','service','loan','dividend','allocation')),
            method              TEXT NOT NULL DEFAULT 'cost_plus'
                                CHECK(method IN ('cost_plus','resale_minus','comparable','other')),
            markup_pct          TEXT NOT NULL DEFAULT '0',
            effective_date      TEXT,
            expiry_date         TEXT,
            company_id          TEXT NOT NULL REFERENCES company(id),
            created_at          TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_tpr_companies ON advacct_transfer_price_rule(from_company_id, to_company_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_tpr_type ON advacct_transfer_price_rule(transaction_type)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_tpr_company ON advacct_transfer_price_rule(company_id)")
    indexes_created += 3

    # ==================================================================
    # DOMAIN 4: Multi-Entity Consolidation -- 3 tables
    # ==================================================================

    # 10. advacct_consolidation_group
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_consolidation_group (
            id                      TEXT PRIMARY KEY,
            naming_series           TEXT,
            name                    TEXT NOT NULL,
            parent_company_id       TEXT,
            consolidation_currency  TEXT NOT NULL DEFAULT 'USD',
            group_status            TEXT NOT NULL DEFAULT 'active'
                                    CHECK(group_status IN ('active','inactive')),
            company_id              TEXT NOT NULL REFERENCES company(id),
            created_at              TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at              TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_cgrp_company ON advacct_consolidation_group(company_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_cgrp_status ON advacct_consolidation_group(group_status)")
    indexes_created += 2

    # 11. advacct_group_entity
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_group_entity (
            id                      TEXT PRIMARY KEY,
            group_id                TEXT NOT NULL REFERENCES advacct_consolidation_group(id) ON DELETE CASCADE,
            entity_company_id       TEXT NOT NULL,
            entity_name             TEXT NOT NULL,
            ownership_pct           TEXT NOT NULL DEFAULT '100',
            functional_currency     TEXT NOT NULL DEFAULT 'USD',
            consolidation_method    TEXT NOT NULL DEFAULT 'full'
                                    CHECK(consolidation_method IN ('full','proportional','equity')),
            is_active               INTEGER NOT NULL DEFAULT 1,
            company_id              TEXT NOT NULL REFERENCES company(id),
            created_at              TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ge_group ON advacct_group_entity(group_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ge_entity ON advacct_group_entity(entity_company_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ge_company ON advacct_group_entity(company_id)")
    indexes_created += 3

    # 12. advacct_elimination_entry
    conn.execute("""
        CREATE TABLE IF NOT EXISTS advacct_elimination_entry (
            id                  TEXT PRIMARY KEY,
            group_id            TEXT NOT NULL REFERENCES advacct_consolidation_group(id) ON DELETE CASCADE,
            period_date         TEXT NOT NULL,
            debit_account       TEXT NOT NULL,
            credit_account      TEXT NOT NULL,
            amount              TEXT NOT NULL DEFAULT '0',
            description         TEXT,
            entry_type          TEXT NOT NULL DEFAULT 'ic_elimination'
                                CHECK(entry_type IN ('ic_elimination','minority_interest','currency_translation','goodwill')),
            company_id          TEXT NOT NULL REFERENCES company(id),
            created_at          TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        )
    """)
    tables_created += 1
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ee_group ON advacct_elimination_entry(group_id)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ee_period ON advacct_elimination_entry(period_date)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ee_type ON advacct_elimination_entry(entry_type)")
    conn.execute("CREATE INDEX IF NOT EXISTS idx_advacct_ee_company ON advacct_elimination_entry(company_id)")
    indexes_created += 4

    conn.commit()
    conn.close()

    return {
        "database": db_path,
        "tables": tables_created,
        "indexes": indexes_created,
    }


if __name__ == "__main__":
    db = sys.argv[1] if len(sys.argv) > 1 else None
    result = create_accounting_adv_tables(db)
    print(f"{DISPLAY_NAME} schema created in {result['database']}")
    print(f"  Tables: {result['tables']}")
    print(f"  Indexes: {result['indexes']}")


path: scripts/module_manager.py
content: #!/usr/bin/env python3
"""ERPClaw Module Manager — install, update, and manage GitHub-hosted modules.

Handles discovery, installation, dependency resolution, and action cache
management for ERPClaw expansion modules. Modules are git-cloned into
~/.openclaw/erpclaw/modules/{module-name}/ and tracked in the
erpclaw_module / erpclaw_module_action tables.

Usage: python3 module_manager.py --action <action-name> [--flags ...]
Output: JSON to stdout, exit 0 on success, exit 1 on error.
"""
import argparse
import ast
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime, timezone
from uuid import uuid4

# ---------------------------------------------------------------------------
# Shared library imports
# ---------------------------------------------------------------------------
sys.path.insert(0, os.path.expanduser("~/.openclaw/erpclaw/lib"))
from erpclaw_lib.db import get_connection
from erpclaw_lib.response import ok, err, rows_to_list, row_to_dict

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
MODULES_DIR = os.path.expanduser("~/.openclaw/erpclaw/modules")
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
REGISTRY_PATH = os.path.join(SCRIPT_DIR, "module_registry.json")


def _now_iso():
    """Return current UTC time as ISO 8601 string."""
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def _load_registry():
    """Load the module catalog from module_registry.json."""
    if not os.path.isfile(REGISTRY_PATH):
        err(f"Module registry not found at {REGISTRY_PATH}")
    try:
        with open(REGISTRY_PATH, "r") as f:
            return json.load(f)
    except (json.JSONDecodeError, OSError) as e:
        err(f"Failed to read module registry: {e}")


def _registry_to_dict(registry):
    """Convert registry modules (dict-keyed or list) to {name: info} dict."""
    modules_raw = registry.get("modules", {})
    if isinstance(modules_raw, dict):
        result = {}
        for name, info in modules_raw.items():
            info_copy = dict(info)
            info_copy.setdefault("name", name)
            result[name] = info_copy
        return result
    # List format (fallback)
    return {m["name"]: m for m in modules_raw}


def _get_installed_modules(conn):
    """Return dict of installed module names -> row dicts."""
    rows = conn.execute("SELECT * FROM erpclaw_module").fetchall()
    return {row["name"]: dict(row) for row in rows}


def _get_git_commit(install_path):
    """Get the current git commit hash for a module directory."""
    try:
        result = subprocess.run(
            ["git", "rev-parse", "HEAD"],
            cwd=install_path, capture_output=True, text=True, timeout=10
        )
        if result.returncode == 0:
            return result.stdout.strip()
    except (subprocess.TimeoutExpired, FileNotFoundError):
        pass
    return None


def _check_remote_updates(install_path):
    """Check if the module has updates available on origin/main.

    Returns (has_updates: bool, local_commit: str, remote_commit: str).
    """
    local_commit = _get_git_commit(install_path)
    try:
        subprocess.run(
            ["git", "fetch", "origin"],
            cwd=install_path, capture_output=True, text=True, timeout=30
        )
        result = subprocess.run(
            ["git", "rev-parse", "origin/main"],
            cwd=install_path, capture_output=True, text=True, timeout=10
        )
        if result.returncode == 0:
            remote_commit = result.stdout.strip()
            return (local_commit != remote_commit, local_commit, remote_commit)
    except (subprocess.TimeoutExpired, FileNotFoundError):
        pass
    return (False, local_commit, None)


# ---------------------------------------------------------------------------
# Action cache builder — uses AST parsing (safe, no side effects)
# ---------------------------------------------------------------------------

def _extract_actions_via_ast(script_path):
    """Extract action names from a module's db_query.py using AST parsing.

    Looks for top-level assignments to ACTIONS, ACTION_MAP, and ALIASES dicts.
    Extracts the string keys from each. This is safer than importing the module
    since it avoids executing any code or triggering import side effects.
    """
    if not os.path.isfile(script_path):
        return set()

    try:
        with open(script_path, "r") as f:
            source = f.read()
        tree = ast.parse(source, filename=script_path)
    except (SyntaxError, OSError):
        return set()

    target_names = {"ACTIONS", "ACTION_MAP", "ALIASES"}
    all_actions = set()

    for node in ast.iter_child_nodes(tree):
        # Match: ACTIONS = { ... }
        if isinstance(node, ast.Assign):
            for target in node.targets:
                if isinstance(target, ast.Name) and target.id in target_names:
                    all_actions |= _extract_dict_keys(node.value)
        # Match: ACTIONS.update({ ... }) — Pattern B merge from domain modules
        if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call):
            call = node.value
            if (isinstance(call.func, ast.Attribute)
                    and call.func.attr == "update"
                    and isinstance(call.func.value, ast.Name)
                    and call.func.value.id in target_names
                    and call.args):
                all_actions |= _extract_dict_keys(call.args[0])

    # Remove 'status' to avoid collision — each module has its own status
    all_actions.discard("status")
    return all_actions


def _extract_dict_keys(node):
    """Extract string keys from a Dict AST node."""
    keys = set()
    if isinstance(node, ast.Dict):
        for key in node.keys:
            if isinstance(key, ast.Constant) and isinstance(key.value, str):
                keys.add(key.value)
    return keys


def _extract_actions_via_regex(script_path):
    """Fallback: extract action names using regex on the source text.

    Matches patterns like:
        "action-name": some_function,
        'action-name': some_function,
    within ACTIONS = { ... } blocks.
    """
    if not os.path.isfile(script_path):
        return set()

    try:
        with open(script_path, "r") as f:
            source = f.read()
    except OSError:
        return set()

    actions = set()
    # Find ACTIONS = { ... } block (and ACTION_MAP, ALIASES)
    for var_name in ("ACTIONS", "ACTION_MAP", "ALIASES"):
        pattern = rf'{var_name}\s*=\s*\{{([^}}]*)\}}'
        match = re.search(pattern, source, re.DOTALL)
        if match:
            block = match.group(1)
            # Extract quoted string keys
            actions |= set(re.findall(r'["\']([a-z][a-z0-9\-]+)["\']', block))
        # Also match .update({...})
        pattern_update = rf'{var_name}\.update\(\s*\{{([^}}]*)\}}\s*\)'
        for m in re.finditer(pattern_update, source, re.DOTALL):
            block = m.group(1)
            actions |= set(re.findall(r'["\']([a-z][a-z0-9\-]+)["\']', block))

    actions.discard("status")
    return actions


def build_action_cache(conn, module_name, install_path):
    """Scan a module's db_query.py and cache its action names.

    Uses AST parsing as the primary method, falling back to regex if AST
    yields no results (e.g., dynamically constructed dicts).
    Also scans sibling .py files in scripts/ for domain modules that
    define their own ACTIONS dicts (merged via ACTIONS.update()).

    Returns the number of actions cached.
    """
    script_path = os.path.join(install_path, "scripts", "db_query.py")

    all_actions = _extract_actions_via_ast(script_path)
    if not all_actions:
        all_actions = _extract_actions_via_regex(script_path)

    # Also scan sibling domain modules in scripts/ directory
    scripts_dir = os.path.join(install_path, "scripts")
    if os.path.isdir(scripts_dir):
        for fname in os.listdir(scripts_dir):
            if fname.endswith(".py") and fname != "db_query.py":
                domain_path = os.path.join(scripts_dir, fname)
                domain_actions = _extract_actions_via_ast(domain_path)
                if not domain_actions:
                    domain_actions = _extract_actions_via_regex(domain_path)
                all_actions |= domain_actions

    if not all_actions:
        return 0

    # Clear existing cache for this module and insert fresh
    conn.execute(
        "DELETE FROM erpclaw_module_action WHERE module_name = ?",
        (module_name,)
    )
    conn.executemany(
        "INSERT OR REPLACE INTO erpclaw_module_action (module_name, action_name) VALUES (?, ?)",
        [(module_name, a) for a in sorted(all_actions)]
    )
    conn.commit()

    # Check for action name collisions with other modules (non-fatal warning)
    collisions = conn.execute(
        """SELECT action_name, module_name FROM erpclaw_module_action
           WHERE action_name IN ({}) AND module_name != ?""".format(
            ",".join("?" for _ in all_actions)),
        list(all_actions) + [module_name]
    ).fetchall()
    if collisions:
        import sys as _sys
        for c in collisions:
            _sys.stderr.write(
                f"[module-manager] WARNING: action '{c['action_name']}' in '{module_name}' "
                f"collides with '{c['module_name']}'\n"
            )

    return len(all_actions)


# ---------------------------------------------------------------------------
# SKILL.md regeneration — appends installed module actions to deployed SKILL.md
# ---------------------------------------------------------------------------

def _regenerate_skill_md(conn):
    """Regenerate the deployed SKILL.md with installed module actions appendix.

    Reads the source SKILL.md as a template, appends an auto-generated section
    listing all installed module actions, and writes to the deployed location.
    The source template is the installed skill's SKILL.md (without the appendix).
    """
    # Find the deployed SKILL.md path
    deployed_path = os.path.expanduser("~/clawd/skills/erpclaw/SKILL.md")
    # Source template is in the same skill directory
    source_path = os.path.join(SCRIPT_DIR, "..", "SKILL.md")

    # Use source if it exists, otherwise use deployed as template
    template_path = source_path if os.path.isfile(source_path) else deployed_path
    if not os.path.isfile(template_path):
        return  # No SKILL.md to regenerate

    try:
        with open(template_path, "r") as f:
            content = f.read()
    except OSError:
        return

    # Strip any existing auto-generated appendix
    marker = "## Installed Module Actions"
    if marker in content:
        content = content[:content.index(marker)].rstrip() + "\n"

    # Query installed modules and their actions
    rows = conn.execute(
        """SELECT ma.module_name, ma.action_name, m.display_name, m.action_count
           FROM erpclaw_module_action ma
           JOIN erpclaw_module m ON m.name = ma.module_name
           WHERE m.install_status = 'installed' AND m.is_active = 1
           ORDER BY ma.module_name, ma.action_name"""
    ).fetchall()

    if not rows:
        # No modules installed — write template without appendix
        if os.path.isfile(deployed_path):
            try:
                with open(deployed_path, "w") as f:
                    f.write(content)
            except OSError:
                pass
        return

    # Group actions by module
    module_actions = {}
    module_display = {}
    module_counts = {}
    for r in rows:
        mod = r["module_name"]
        module_actions.setdefault(mod, []).append(r["action_name"])
        module_display[mod] = r["display_name"]
        module_counts[mod] = r["action_count"] or len(module_actions[mod])

    # Read module descriptions from SKILL.md files for context
    def _get_module_desc(module_name):
        """Read first line of description from module's SKILL.md."""
        for base in [os.path.join(MODULES_DIR, module_name),
                     os.path.join(SCRIPT_DIR, "..", "..", module_name)]:
            skill_path = os.path.join(base, "SKILL.md")
            if os.path.isfile(skill_path):
                try:
                    with open(skill_path, "r") as f:
                        for line in f:
                            if line.startswith("description:"):
                                desc = line.split(":", 1)[1].strip().strip(">").strip()
                                if desc:
                                    return desc[:120]
                except OSError:
                    pass
        return ""

    # Build appendix
    appendix = f"\n\n{marker}\n"
    appendix += "<!-- AUTO-GENERATED — do not edit manually. Regenerated on module install/uninstall. -->\n\n"

    for mod in sorted(module_actions.keys()):
        actions = module_actions[mod]
        display = module_display.get(mod, mod)
        count = len(actions)
        desc = _get_module_desc(mod)

        appendix += f"### {display} ({count} actions)\n"
        if desc:
            appendix += f"{desc}\n"

        # Show key actions (up to 10)
        key_actions = actions[:10]
        appendix += f"Key actions: {', '.join(f'`{a}`' for a in key_actions)}"
        if len(actions) > 10:
            appendix += f", ... (+{len(actions) - 10} more)"
        appendix += "\n\n"

    # Write to deployed path
    deployed_dir = os.path.dirname(deployed_path)
    if os.path.isdir(deployed_dir):
        try:
            with open(deployed_path, "w") as f:
                f.write(content + appendix)
        except OSError:
            pass  # Non-fatal — skill still works, just without action discovery


# ---------------------------------------------------------------------------
# Action: install-module
# ---------------------------------------------------------------------------

def install_module(args):
    """Install a module from GitHub.

    Resolves dependencies first (auto-installing missing ones), clones the
    repo, runs init_db.py if present, reads module.json for metadata, builds
    the action cache, and registers in erpclaw_module.
    """
    module_name = args.module_name
    if not module_name:
        err("--module-name is required")

    registry = _load_registry()
    modules_by_name = _registry_to_dict(registry)

    if module_name not in modules_by_name:
        err(
            f"Module '{module_name}' not found in registry",
            suggestion="Run --action available-modules to see all available modules"
        )

    module_info = modules_by_name[module_name]
    conn = get_connection()

    # Check if already installed
    existing = conn.execute(
        "SELECT id, version, install_status FROM erpclaw_module WHERE name = ?",
        (module_name,)
    ).fetchone()
    if existing:
        if existing["install_status"] == "installed":
            err(
                f"Module '{module_name}' is already installed (version {existing['version']})",
                suggestion="Use --action update-modules to update, or --action remove-module to reinstall"
            )
        else:
            # Previous install failed — clean up and retry
            _cleanup_failed_install(conn, module_name)

    # Resolve dependencies
    requires = module_info.get("requires", [])
    if requires:
        installed = _get_installed_modules(conn)
        missing = [r for r in requires if r not in installed]
        if missing:
            auto_installed = []
            for dep in missing:
                if dep not in modules_by_name:
                    err(
                        f"Dependency '{dep}' for module '{module_name}' not found in registry",
                        suggestion="This module has an unresolvable dependency"
                    )
                # Recursively install dependency
                dep_args = argparse.Namespace(module_name=dep)
                try:
                    # Temporarily suppress ok() exit to allow chaining
                    _install_module_inner(dep_args, conn, modules_by_name, depth=1)
                    auto_installed.append(dep)
                except SystemExit:
                    # ok() calls sys.exit(0) — we need to re-open connection
                    conn = get_connection()
                    auto_installed.append(dep)

    # Perform the actual installation
    result = _install_module_inner(args, conn, modules_by_name, depth=0)
    ok(result)


def _install_module_inner(args, conn, modules_by_name, depth=0):
    """Inner installation logic. Returns result dict instead of calling ok().

    The depth parameter tracks recursive dependency installs to prevent
    infinite loops.
    """
    if depth > 10:
        err("Dependency resolution exceeded maximum depth (10) — circular dependency detected")

    module_name = args.module_name
    module_info = modules_by_name[module_name]

    # Check again (may have been installed as a dependency in this session)
    existing = conn.execute(
        "SELECT id FROM erpclaw_module WHERE name = ? AND install_status = 'installed'",
        (module_name,)
    ).fetchone()
    if existing:
        return {"module": module_name, "note": "already installed (as dependency)"}

    install_path = os.path.join(MODULES_DIR, module_name)
    github_repo = module_info.get("github", module_info.get("github_repo", ""))
    subdir = module_info.get("subdir", "")
    now = _now_iso()
    module_id = str(uuid4())

    # Mark as installing
    conn.execute(
        """INSERT INTO erpclaw_module
           (id, name, display_name, version, category, github_repo,
            install_path, installed_at, updated_at, install_status, requires_json)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'updating', ?)""",
        (module_id, module_name, module_info.get("display_name", module_name),
         module_info.get("version", "0.0.0"), module_info.get("category", "expansion"),
         github_repo, install_path, now, now,
         json.dumps(module_info.get("requires", [])))
    )
    conn.commit()

    # Clone the repository
    os.makedirs(MODULES_DIR, exist_ok=True)
    if os.path.isdir(install_path):
        shutil.rmtree(install_path)

    clone_url = f"https://github.com/{github_repo}.git"

    if subdir:
        # Grouped repo — use sparse checkout to get only the needed subdir
        import tempfile
        tmp_dir = tempfile.mkdtemp(prefix=f"erpclaw-install-{module_name}-")
        try:
            result = subprocess.run(
                ["git", "clone", "--depth", "1", "--filter=blob:none",
                 "--sparse", clone_url, tmp_dir],
                capture_output=True, text=True, timeout=120
            )
            if result.returncode != 0:
                _mark_failed(conn, module_name, f"git clone failed: {result.stderr.strip()}")
                err(f"Failed to clone {clone_url}: {result.stderr.strip()}")

            result = subprocess.run(
                ["git", "-C", tmp_dir, "sparse-checkout", "set", subdir],
                capture_output=True, text=True, timeout=30
            )
            if result.returncode != 0:
                _mark_failed(conn, module_name, f"sparse-checkout failed: {result.stderr.strip()}")
                err(f"Failed sparse-checkout for {subdir}: {result.stderr.strip()}")

            # Move the subdir to the install path
            subdir_path = os.path.join(tmp_dir, subdir)
            if not os.path.isdir(subdir_path):
                _mark_failed(conn, module_name, f"subdir '{subdir}' not found in repo")
                err(f"Subdir '{subdir}' not found in {github_repo}")
            shutil.copytree(subdir_path, install_path)
        except subprocess.TimeoutExpired:
            _mark_failed(conn, module_name, "git clone timed out after 120s")
            err(f"git clone timed out for {clone_url}")
        except FileNotFoundError:
            _mark_failed(conn, module_name, "git not found in PATH")
            err("git is not installed or not in PATH")
        finally:
            shutil.rmtree(tmp_dir, ignore_errors=True)
    else:
        # Standalone repo — clone directly
        try:
            result = subprocess.run(
                ["git", "clone", "--depth", "1", clone_url, install_path],
                capture_output=True, text=True, timeout=120
            )
            if result.returncode != 0:
                _mark_failed(conn, module_name, f"git clone failed: {result.stderr.strip()}")
                err(f"Failed to clone {clone_url}: {result.stderr.strip()}")
        except subprocess.TimeoutExpired:
            _mark_failed(conn, module_name, "git clone timed out after 120s")
            err(f"git clone timed out for {clone_url}")
        except FileNotFoundError:
            _mark_failed(conn, module_name, "git not found in PATH")
            err("git is not installed or not in PATH")

    # Read module.json if it exists
    module_json_path = os.path.join(install_path, "module.json")
    module_meta = {}
    if os.path.isfile(module_json_path):
        try:
            with open(module_json_path, "r") as f:
                module_meta = json.load(f)
        except (json.JSONDecodeError, OSError):
            pass  # Non-fatal — registry info is sufficient

    # Merge metadata: module.json overrides registry defaults
    display_name = module_meta.get("display_name", module_info.get("display_name", module_name))
    version = module_meta.get("version", module_info.get("version", "0.0.0"))

    # Run init_db.py if it exists
    init_db_path = os.path.join(install_path, "init_db.py")
    tables_created = 0
    if os.path.isfile(init_db_path):
        try:
            result = subprocess.run(
                [sys.executable, init_db_path],
                capture_output=True, text=True, timeout=60
            )
            if result.returncode != 0:
                _mark_failed(conn, module_name, f"init_db.py failed: {result.stderr.strip()}")
                err(f"init_db.py failed for {module_name}: {result.stderr.strip()}")
            # Try to parse table count from output
            if result.stdout:
                try:
                    init_result = json.loads(result.stdout)
                    tables_created = init_result.get("tables_created", 0)
                except json.JSONDecodeError:
                    # Many init_db.py scripts print human-readable text, not JSON
                    # Try to extract number from output like "Created 5 tables"
                    import re as _re
                    m = _re.search(r'(\d+)\s+tables?', result.stdout)
                    if m:
                        tables_created = int(m.group(1))
            # If still 0, count module-specific tables directly from DB
            if tables_created == 0:
                try:
                    data_db = os.path.expanduser("~/.openclaw/erpclaw/data.sqlite")
                    if os.path.isfile(data_db):
                        dconn = sqlite3.connect(data_db)
                        prefix = module_name.replace("-", "_") + "_"
                        cursor = dconn.execute(
                            "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE ?",
                            (prefix + "%",))
                        tables_created = cursor.fetchone()[0]
                        dconn.close()
                except Exception:
                    pass
        except subprocess.TimeoutExpired:
            _mark_failed(conn, module_name, "init_db.py timed out after 60s")
            err(f"init_db.py timed out for {module_name}")

    # Build action cache
    action_count = build_action_cache(conn, module_name, install_path)

    # If no actions found, try scanning subdirectories (grouped repos like erpclaw-ops)
    if action_count == 0:
        scripts_dir = os.path.join(install_path, "scripts")
        if os.path.isdir(scripts_dir):
            for subdir in os.listdir(scripts_dir):
                sub_script = os.path.join(scripts_dir, subdir, "db_query.py")
                if os.path.isfile(sub_script):
                    sub_actions = _extract_actions_via_ast(sub_script)
                    if not sub_actions:
                        sub_actions = _extract_actions_via_regex(sub_script)
                    if sub_actions:
                        conn.executemany(
                            "INSERT OR REPLACE INTO erpclaw_module_action (module_name, action_name) VALUES (?, ?)",
                            [(module_name, a) for a in sorted(sub_actions)]
                        )
                        action_count += len(sub_actions)
            if action_count > 0:
                conn.commit()

    # Get git commit hash
    git_commit = _get_git_commit(install_path)

    # Update module record to installed
    conn.execute(
        """UPDATE erpclaw_module
           SET display_name = ?, version = ?, install_status = 'installed',
               git_commit = ?, tables_created = ?, action_count = ?,
               updated_at = ?, error_log = NULL
           WHERE name = ?""",
        (display_name, version, git_commit, tables_created, action_count, now, module_name)
    )
    conn.commit()

    # Regenerate SKILL.md with new module actions
    _regenerate_skill_md(conn)

    return {
        "module": module_name,
        "display_name": display_name,
        "version": version,
        "action_count": action_count,
        "tables_created": tables_created,
        "install_path": install_path,
        "git_commit": git_commit,
        "installed_at": now,
    }


def _mark_failed(conn, module_name, error_msg):
    """Mark a module installation as failed."""
    conn.execute(
        "UPDATE erpclaw_module SET install_status = 'failed', error_log = ?, updated_at = ? WHERE name = ?",
        (error_msg, _now_iso(), module_name)
    )
    conn.commit()


def _cleanup_failed_install(conn, module_name):
    """Remove records from a previously failed installation."""
    row = conn.execute(
        "SELECT install_path FROM erpclaw_module WHERE name = ?",
        (module_name,)
    ).fetchone()
    if row and row["install_path"] and os.path.isdir(os.path.expanduser(row["install_path"])):
        shutil.rmtree(os.path.expanduser(row["install_path"]), ignore_errors=True)
    conn.execute("DELETE FROM erpclaw_module_action WHERE module_name = ?", (module_name,))
    conn.execute("DELETE FROM erpclaw_module WHERE name = ?", (module_name,))
    conn.commit()


# ---------------------------------------------------------------------------
# Action: remove-module
# ---------------------------------------------------------------------------

def remove_module(args):
    """Remove an installed module.

    Checks that no other installed modules depend on this one before removal.
    Deletes the module directory and database records, but preserves any
    tables/data the module created (no DROP TABLE).
    """
    module_name = args.module_name
    if not module_name:
        err("--module-name is required")

    conn = get_connection()

    # Check module exists
    row = conn.execute(
        "SELECT * FROM erpclaw_module WHERE name = ?",
        (module_name,)
    ).fetchone()
    if not row:
        err(f"Module '{module_name}' is not installed")

    # Check reverse dependencies — are any other installed modules depending on this one?
    installed = _get_installed_modules(conn)
    dependents = []
    for name, mod in installed.items():
        if name == module_name:
            continue
        requires = json.loads(mod.get("requires_json") or "[]")
        if module_name in requires:
            dependents.append(name)

    if dependents:
        err(
            f"Cannot remove '{module_name}': required by {', '.join(dependents)}",
            suggestion=f"Remove dependent modules first: {', '.join(dependents)}"
        )

    install_path = os.path.expanduser(row["install_path"])

    # Mark as removing
    conn.execute(
        "UPDATE erpclaw_module SET install_status = 'removing', updated_at = ? WHERE name = ?",
        (_now_iso(), module_name)
    )
    conn.commit()

    # Delete action cache
    conn.execute("DELETE FROM erpclaw_module_action WHERE module_name = ?", (module_name,))

    # Delete module record
    conn.execute("DELETE FROM erpclaw_module WHERE name = ?", (module_name,))
    conn.commit()

    # Remove directory
    if install_path and os.path.isdir(install_path):
        shutil.rmtree(install_path, ignore_errors=True)

    # Regenerate SKILL.md without removed module
    _regenerate_skill_md(conn)

    ok({
        "module": module_name,
        "removed": True,
        "note": "Module directory and records removed. Database tables are preserved.",
    })


# ---------------------------------------------------------------------------
# Action: update-modules
# ---------------------------------------------------------------------------

def update_modules(args):
    """Update all or a specific installed module.

    For each module: fetches from origin, compares HEAD with origin/main,
    pulls if different, re-runs init_db.py, and rebuilds the action cache.
    """
    conn = get_connection()
    target_name = getattr(args, "module_name", None)

    if target_name:
        rows = conn.execute(
            "SELECT * FROM erpclaw_module WHERE name = ? AND install_status = 'installed'",
            (target_name,)
        ).fetchall()
        if not rows:
            err(f"Module '{target_name}' is not installed or not in 'installed' state")
    else:
        rows = conn.execute(
            "SELECT * FROM erpclaw_module WHERE install_status = 'installed'"
        ).fetchall()

    if not rows:
        ok({"updated": [], "message": "No modules to update"})

    updated = []
    skipped = []
    failed = []

    for row in rows:
        module_name = row["name"]
        install_path = os.path.expanduser(row["install_path"])

        if not os.path.isdir(install_path):
            failed.append({"module": module_name, "error": "Install directory missing"})
            continue

        has_updates, local_commit, remote_commit = _check_remote_updates(install_path)

        if not has_updates:
            skipped.append({"module": module_name, "commit": local_commit, "reason": "already up to date"})
            continue

        # Mark as updating
        conn.execute(
            "UPDATE erpclaw_module SET install_status = 'updating', updated_at = ? WHERE name = ?",
            (_now_iso(), module_name)
        )
        conn.commit()

        # Pull latest
        try:
            result = subprocess.run(
                ["git", "pull", "origin", "main"],
                cwd=install_path, capture_output=True, text=True, timeout=60
            )
            if result.returncode != 0:
                _mark_failed(conn, module_name, f"git pull failed: {result.stderr.strip()}")
                failed.append({"module": module_name, "error": f"git pull failed: {result.stderr.strip()}"})
                continue
        except subprocess.TimeoutExpired:
            _mark_failed(conn, module_name, "git pull timed out")
            failed.append({"module": module_name, "error": "git pull timed out"})
            continue

        # Re-run init_db.py if it exists
        init_db_path = os.path.join(install_path, "init_db.py")
        if os.path.isfile(init_db_path):
            try:
                subprocess.run(
                    [sys.executable, init_db_path],
                    capture_output=True, text=True, timeout=60
                )
            except subprocess.TimeoutExpired:
                pass  # Non-fatal for updates

        # Read updated module.json
        module_json_path = os.path.join(install_path, "module.json")
        new_version = row["version"]
        if os.path.isfile(module_json_path):
            try:
                with open(module_json_path, "r") as f:
                    meta = json.load(f)
                new_version = meta.get("version", new_version)
            except (json.JSONDecodeError, OSError):
                pass

        # Rebuild action cache
        action_count = build_action_cache(conn, module_name, install_path)
        new_commit = _get_git_commit(install_path)
        now = _now_iso()

        conn.execute(
            """UPDATE erpclaw_module
               SET install_status = 'installed', version = ?, git_commit = ?,
                   action_count = ?, updated_at = ?, error_log = NULL
               WHERE name = ?""",
            (new_version, new_commit, action_count, now, module_name)
        )
        conn.commit()

        updated.append({
            "module": module_name,
            "old_commit": local_commit,
            "new_commit": new_commit,
            "version": new_version,
            "action_count": action_count,
        })

    ok({
        "updated": updated,
        "skipped": skipped,
        "failed": failed,
        "summary": f"{len(updated)} updated, {len(skipped)} up-to-date, {len(failed)} failed",
    })


# ---------------------------------------------------------------------------
# Action: list-modules
# ---------------------------------------------------------------------------

def list_modules(args):
    """List all installed and active modules."""
    conn = get_connection()
    rows = conn.execute(
        """SELECT name, display_name, version, category, action_count,
                  tables_created, installed_at, updated_at, git_commit, install_status
           FROM erpclaw_module
           WHERE is_active = 1
           ORDER BY category, name"""
    ).fetchall()

    modules = rows_to_list(rows)

    # Enrich with action count from cache (authoritative source)
    for mod in modules:
        count = conn.execute(
            "SELECT COUNT(*) as cnt FROM erpclaw_module_action WHERE module_name = ?",
            (mod["name"],)
        ).fetchone()
        mod["cached_actions"] = count["cnt"] if count else 0

    ok({
        "modules": modules,
        "total": len(modules),
    })


# ---------------------------------------------------------------------------
# Action: available-modules
# ---------------------------------------------------------------------------

def available_modules(args):
    """Browse the module catalog, cross-referenced with install status."""
    registry = _load_registry()
    conn = get_connection()
    installed = _get_installed_modules(conn)

    category_filter = getattr(args, "category", None)
    search_query = getattr(args, "search", None)

    results = []
    for mod in _registry_to_dict(registry).values():
        # Filter by category
        if category_filter and mod.get("category") != category_filter:
            continue

        # Filter by search query
        if search_query:
            query_lower = search_query.lower()
            searchable = " ".join([
                mod.get("name", ""),
                mod.get("display_name", ""),
                mod.get("description", ""),
                " ".join(mod.get("tags", [])),
            ]).lower()
            if query_lower not in searchable:
                continue

        entry = {
            "name": mod["name"],
            "display_name": mod.get("display_name", mod["name"]),
            "description": mod.get("description", ""),
            "category": mod.get("category", "expansion"),
            "version": mod.get("version", "0.0.0"),
            "tags": mod.get("tags", []),
            "requires": mod.get("requires", []),
        }

        # Cross-reference with installed modules
        if mod["name"] in installed:
            inst = installed[mod["name"]]
            entry["installed"] = True
            entry["installed_version"] = inst["version"]
            entry["install_status"] = inst["install_status"]
        else:
            entry["installed"] = False

        results.append(entry)

    ok({
        "modules": results,
        "total": len(results),
        "filters": {
            "category": category_filter,
            "search": search_query,
        },
    })


# ---------------------------------------------------------------------------
# Action: module-status
# ---------------------------------------------------------------------------

def module_status(args):
    """Show detailed status for a specific installed module."""
    module_name = args.module_name
    if not module_name:
        err("--module-name is required")

    conn = get_connection()

    row = conn.execute(
        "SELECT * FROM erpclaw_module WHERE name = ?",
        (module_name,)
    ).fetchone()
    if not row:
        err(f"Module '{module_name}' is not installed")

    mod = row_to_dict(row)
    install_path = os.path.expanduser(mod["install_path"])

    # Get cached actions
    actions = conn.execute(
        "SELECT action_name FROM erpclaw_module_action WHERE module_name = ? ORDER BY action_name",
        (module_name,)
    ).fetchall()
    mod["actions"] = [a["action_name"] for a in actions]
    mod["cached_action_count"] = len(mod["actions"])

    # Parse requires_json
    mod["requires"] = json.loads(mod.get("requires_json") or "[]")
    del mod["requires_json"]

    # Check for dependents (who depends on this module)
    installed = _get_installed_modules(conn)
    dependents = []
    for name, inst in installed.items():
        if name == module_name:
            continue
        requires = json.loads(inst.get("requires_json") or "[]")
        if module_name in requires:
            dependents.append(name)
    mod["dependents"] = dependents

    # Check git status
    if os.path.isdir(install_path):
        mod["directory_exists"] = True
        has_updates, local_commit, remote_commit = _check_remote_updates(install_path)
        mod["has_updates"] = has_updates
        mod["local_commit"] = local_commit
        mod["remote_commit"] = remote_commit
    else:
        mod["directory_exists"] = False
        mod["has_updates"] = None
        mod["local_commit"] = None
        mod["remote_commit"] = None

    ok(mod)


# ---------------------------------------------------------------------------
# Action: search-modules
# ---------------------------------------------------------------------------

def search_modules(args):
    """Search the module catalog by name, description, and tags."""
    search_query = getattr(args, "search", None)
    if not search_query:
        err("--search is required")

    registry = _load_registry()
    query_lower = search_query.lower()
    query_terms = query_lower.split()

    results = []
    for mod in _registry_to_dict(registry).values():
        searchable = " ".join([
            mod.get("name", ""),
            mod.get("display_name", ""),
            mod.get("description", ""),
            " ".join(mod.get("tags", [])),
        ]).lower()

        # All terms must match
        if all(term in searchable for term in query_terms):
            results.append({
                "name": mod["name"],
                "display_name": mod.get("display_name", mod["name"]),
                "description": mod.get("description", ""),
                "category": mod.get("category", "expansion"),
                "version": mod.get("version", "0.0.0"),
                "tags": mod.get("tags", []),
            })

    ok({
        "query": search_query,
        "results": results,
        "total": len(results),
    })


# ---------------------------------------------------------------------------
# Action: rebuild-action-cache
# ---------------------------------------------------------------------------

def rebuild_action_cache(args):
    """Rebuild the entire action cache from all installed modules.

    Truncates erpclaw_module_action and re-scans every installed module's
    db_query.py. Useful after migrations, manual changes, or cache corruption.
    """
    conn = get_connection()

    # Clear entire cache
    conn.execute("DELETE FROM erpclaw_module_action")
    conn.commit()

    rows = conn.execute(
        "SELECT name, install_path FROM erpclaw_module WHERE install_status = 'installed'"
    ).fetchall()

    rebuilt = []
    errors = []
    total_actions = 0

    for row in rows:
        module_name = row["name"]
        install_path = os.path.expanduser(row["install_path"])

        if not os.path.isdir(install_path):
            errors.append({"module": module_name, "error": "Install directory missing"})
            continue

        try:
            count = build_action_cache(conn, module_name, install_path)
            # Update the action_count in the module record
            conn.execute(
                "UPDATE erpclaw_module SET action_count = ?, updated_at = ? WHERE name = ?",
                (count, _now_iso(), module_name)
            )
            conn.commit()
            rebuilt.append({"module": module_name, "action_count": count})
            total_actions += count
        except Exception as e:
            errors.append({"module": module_name, "error": str(e)})

    # Regenerate SKILL.md with updated actions
    _regenerate_skill_md(conn)

    ok({
        "rebuilt": rebuilt,
        "errors": errors,
        "total_modules": len(rebuilt),
        "total_actions": total_actions,
        "summary": f"Rebuilt cache for {len(rebuilt)} modules ({total_actions} actions), {len(errors)} errors",
    })


# ---------------------------------------------------------------------------
# Action: list-all-actions
# ---------------------------------------------------------------------------

def list_all_actions(args):
    """Return all available actions — core + installed modules."""
    conn = get_connection()

    # Get core actions from the main ACTION_MAP
    # We need to read db_query.py to get the ACTION_MAP keys
    db_query_path = os.path.join(SCRIPT_DIR, "db_query.py")
    core_actions = _extract_actions_via_regex(db_query_path)
    # Also add MODULE_ACTIONS and ONBOARDING_ACTIONS
    core_actions |= {
        "install-module", "remove-module", "update-modules",
        "list-modules", "available-modules", "module-status",
        "search-modules", "rebuild-action-cache",
        "list-profiles", "onboard", "list-all-actions",
    }

    # Module actions from cache
    rows = conn.execute(
        """SELECT ma.action_name, ma.module_name
           FROM erpclaw_module_action ma
           JOIN erpclaw_module m ON m.name = ma.module_name
           WHERE m.install_status = 'installed' AND m.is_active = 1
           ORDER BY ma.module_name, ma.action_name"""
    ).fetchall()

    module_actions = {}
    for r in rows:
        module_actions.setdefault(r["module_name"], []).append(r["action_name"])

    ok({
        "core_actions": sorted(core_actions),
        "core_count": len(core_actions),
        "module_actions": module_actions,
        "module_count": len(module_actions),
        "total": len(core_actions) + sum(len(v) for v in module_actions.values()),
    })


# ---------------------------------------------------------------------------
# Action: regenerate-skill-md
# ---------------------------------------------------------------------------

def regenerate_skill_md_action(args):
    """Regenerate the deployed SKILL.md with installed module actions."""
    conn = get_connection()
    _regenerate_skill_md(conn)

    # Count what was generated
    rows = conn.execute(
        """SELECT m.name, COUNT(ma.action_name) as cnt
           FROM erpclaw_module m
           LEFT JOIN erpclaw_module_action ma ON m.name = ma.module_name
           WHERE m.install_status = 'installed' AND m.is_active = 1
           GROUP BY m.name"""
    ).fetchall()

    modules = [{"module": r["name"], "actions": r["cnt"]} for r in rows]
    ok({
        "regenerated": True,
        "modules": modules,
        "total_modules": len(modules),
        "deployed_path": os.path.expanduser("~/clawd/skills/erpclaw/SKILL.md"),
    })


# ---------------------------------------------------------------------------
# Action dispatch and CLI
# ---------------------------------------------------------------------------

ACTIONS = {
    "install-module": install_module,
    "remove-module": remove_module,
    "update-modules": update_modules,
    "list-modules": list_modules,
    "available-modules": available_modules,
    "module-status": module_status,
    "search-modules": search_modules,
    "rebuild-action-cache": rebuild_action_cache,
    "list-all-actions": list_all_actions,
    "regenerate-skill-md": regenerate_skill_md_action,
}


def main():
    parser = argparse.ArgumentParser(
        description="ERPClaw Module Manager — install, update, and manage expansion modules"
    )
    parser.add_argument(
        "--action", required=True, choices=sorted(ACTIONS.keys()),
        help="Action to perform"
    )
    parser.add_argument(
        "--module-name",
        help="Module name (for install, remove, update, status)"
    )
    parser.add_argument(
        "--category",
        choices=["core", "expansion", "infrastructure", "vertical", "sub-vertical", "regional"],
        help="Filter by category (for available-modules)"
    )
    parser.add_argument(
        "--search",
        help="Search query (for search-modules, available-modules)"
    )

    args = parser.parse_args()
    action_fn = ACTIONS.get(args.action)
    if not action_fn:
        err(f"Unknown action: {args.action}")

    try:
        action_fn(args)
    except SystemExit:
        raise
    except Exception as e:
        err(f"Unexpected error in {args.action}: {e}")


if __name__ == "__main__":
    main()


path: scripts/erpclaw-gl/db_query.py
content: #!/usr/bin/env python3
"""ERPClaw GL Skill — db_query.py

General Ledger operations: chart of accounts, GL entries, fiscal years,
cost centers, budgets, naming series.

Usage: python3 db_query.py --action <action-name> [--flags ...]
Output: JSON to stdout, exit 0 on success, exit 1 on error.
"""
import argparse
import json
import os
import sqlite3
import sys
import uuid
from datetime import datetime, timezone
from decimal import Decimal, InvalidOperation

# Add shared lib to path
try:
    sys.path.insert(0, os.path.expanduser("~/.openclaw/erpclaw/lib"))
    from erpclaw_lib.db import get_connection, ensure_db_exists, DEFAULT_DB_PATH
    from erpclaw_lib.decimal_utils import to_decimal
    from erpclaw_lib.validation import check_input_lengths
    from erpclaw_lib.gl_posting import (
        validate_gl_entries,
        insert_gl_entries,
        reverse_gl_entries,
        get_account_balance as _lib_get_account_balance,
    )
    from erpclaw_lib.naming import get_next_name, ENTITY_PREFIXES
    from erpclaw_lib.fx_posting import get_exchange_rate, convert_to_base
    from erpclaw_lib.response import ok, err, row_to_dict
    from erpclaw_lib.audit import audit
    from erpclaw_lib.dependencies import check_required_tables
    from erpclaw_lib.query_helpers import resolve_company_id
    from erpclaw_lib.query import Q, P, Table, Field, fn, Case, Order, Criterion, Not, NULL, DecimalSum, DecimalAbs, dynamic_update, now
    from erpclaw_lib.args import SafeArgumentParser, check_unknown_args
    from erpclaw_lib.vendor.pypika.terms import LiteralValue, ValueWrapper
except ImportError:
    import json as _json
    print(_json.dumps({"status": "error", "error": "ERPClaw foundation not installed. Install erpclaw first: clawhub install erpclaw", "suggestion": "clawhub install erpclaw"}))
    sys.exit(1)

REQUIRED_TABLES = ["company"]

SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
ASSETS_DIR = os.path.join(SKILL_DIR, "assets")
CHARTS_DIR = os.path.join(ASSETS_DIR, "charts")



# ---------------------------------------------------------------------------
# 1. setup-chart-of-accounts
# ---------------------------------------------------------------------------

def setup_chart_of_accounts(conn, args):
    template = args.template or "us_gaap"
    company_id = args.company_id
    if not company_id:
        # Auto-detect if only one company exists
        t_c = Table("company")
        q_c = Q.from_(t_c).select(t_c.id, t_c.name)
        companies = conn.execute(q_c.get_sql()).fetchall()
        if len(companies) == 1:
            company_id = companies[0]["id"]
        elif len(companies) > 1:
            company_list = ", ".join([f"'{c['name']}' ({c['id'][:8]}...)" for c in companies])
            err(f"Multiple companies found. Please specify --company-id. Available: {company_list}")
        else:
            err("No companies found. Create a company first with setup-company.")

    t_company = Table("company")
    t_account = Table("account")

    q = Q.from_(t_company).select(t_company.id).where(t_company.id == P())
    company = conn.execute(q.get_sql(), (company_id,)).fetchone()
    if not company:
        err(f"Company {company_id} not found")

    chart_path = os.path.join(CHARTS_DIR, f"{template}.json")
    if not os.path.exists(chart_path):
        err(f"Chart template '{template}' not found. Expected at assets/charts/{template}.json")

    with open(chart_path) as f:
        accounts = json.load(f)

    # Build account_number -> id map for parent resolution
    number_to_id = {}
    # Check existing accounts to skip duplicates
    existing = set()
    q = Q.from_(t_account).select(t_account.account_number).where(t_account.company_id == P())
    for row in conn.execute(q.get_sql(), (company_id,)).fetchall():
        existing.add(row["account_number"])

    created = 0
    for acct in accounts:
        acct_num = acct["account_number"]
        if acct_num in existing:
            # Already exists, but still need to map number -> id
            q = (Q.from_(t_account).select(t_account.id)
                 .where(t_account.account_number == P())
                 .where(t_account.company_id == P()))
            row = conn.execute(q.get_sql(), (acct_num, company_id)).fetchone()
            if row:
                number_to_id[acct_num] = row["id"]
            continue

        acct_id = str(uuid.uuid4())
        parent_id = None
        if acct.get("parent_number"):
            parent_id = number_to_id.get(acct["parent_number"])

        # Calculate depth from parent
        depth = 0
        if parent_id:
            q = Q.from_(t_account).select(t_account.depth).where(t_account.id == P())
            parent_row = conn.execute(q.get_sql(), (parent_id,)).fetchone()
            if parent_row:
                depth = parent_row["depth"] + 1

        q = (Q.into(t_account)
             .columns("id", "name", "account_number", "parent_id", "root_type",
                       "account_type", "is_group", "balance_direction", "company_id", "depth")
             .insert(P(), P(), P(), P(), P(), P(), P(), P(), P(), P()))
        conn.execute(q.get_sql(),
            (acct_id, acct["name"], acct_num, parent_id, acct["root_type"],
             acct.get("account_type"), acct.get("is_group", 0),
             acct.get("balance_direction", "debit_normal"), company_id, depth),
        )
        number_to_id[acct_num] = acct_id
        created += 1

    audit(conn, "erpclaw-gl", "import", "account", company_id,
           new_values={"template": template, "accounts_created": created})
    conn.commit()
    ok({"accounts_created": created, "template": template, "company_id": company_id})


# ---------------------------------------------------------------------------
# 2. add-account
# ---------------------------------------------------------------------------

def add_account(conn, args):
    name = args.name
    company_id = args.company_id
    if not name:
        err("--name is required")
    if not company_id:
        err("--company-id is required")

    root_type = args.root_type
    if not root_type:
        err("--root-type is required (asset|liability|equity|income|expense)")

    acct_id = str(uuid.uuid4())
    account_type = args.account_type
    account_number = args.account_number
    parent_id = args.parent_id
    currency = args.currency or "USD"
    is_group = 1 if args.is_group else 0

    # Guard: certain account_types must be leaf (posting) accounts, never groups
    LEAF_ONLY_TYPES = {
        "receivable", "payable", "bank", "cash", "tax",
        "cost_of_goods_sold", "stock", "depreciation",
        "accumulated_depreciation", "round_off",
    }
    if is_group and account_type and account_type.lower() in LEAF_ONLY_TYPES:
        err(f"Account type '{account_type}' must be a posting (leaf) account, "
            f"not a group. Remove --is-group to create a postable account.")

    # Default balance direction
    balance_direction = "debit_normal"
    if root_type in ("liability", "equity", "income"):
        balance_direction = "credit_normal"

    t_account = Table("account")

    depth = 0
    if parent_id:
        q = Q.from_(t_account).select(t_account.depth).where(t_account.id == P())
        parent_row = conn.execute(q.get_sql(), (parent_id,)).fetchone()
        if not parent_row:
            err(f"Parent account {parent_id} not found")
        depth = parent_row["depth"] + 1

    try:
        q = (Q.into(t_account)
             .columns("id", "name", "account_number", "parent_id", "root_type",
                       "account_type", "currency", "is_group", "balance_direction",
                       "company_id", "depth")
             .insert(P(), P(), P(), P(), P(), P(), P(), P(), P(), P(), P()))
        conn.execute(q.get_sql(),
            (acct_id, name, account_number, parent_id, root_type,
             account_type, currency, is_group, balance_direction, company_id, depth),
        )
        audit(conn, "erpclaw-gl", "create", "account", acct_id,
               new_values={"name": name, "root_type": root_type, "account_type": account_type})
        conn.commit()
    except sqlite3.IntegrityError as e:
        sys.stderr.write(f"[erpclaw-gl] {e}\n")
        err("Account creation failed — check for duplicates or invalid data")

    ok({"status": "created", "account_id": acct_id, "name": name,
         "account_number": account_number})


# ---------------------------------------------------------------------------
# 3. update-account
# ---------------------------------------------------------------------------

def update_account(conn, args):
    acct_id = args.account_id
    if not acct_id:
        err("--account-id is required")

    t_account = Table("account")
    t_gl = Table("gl_entry")

    q = Q.from_(t_account).select(t_account.star).where(t_account.id == P())
    old = row_to_dict(conn.execute(q.get_sql(), (acct_id,)).fetchone())
    if not old:
        err(f"Account {acct_id} not found",
             suggestion="Use 'list accounts' to see available accounts.")

    # Check if GL entries exist (restricts root_type/account_type changes)
    q = (Q.from_(t_gl).select(ValueWrapper(1))
         .where(t_gl.account_id == P())
         .where(t_gl.is_cancelled == 0)
         .limit(1))
    has_entries = conn.execute(q.get_sql(), (acct_id,)).fetchone()

    updatable = {"name": args.name, "account_number": args.account_number,
                 "parent_id": args.parent_id}
    # is_frozen handled by freeze/unfreeze actions, but allow via update too
    if args.is_frozen is not None:
        updatable["is_frozen"] = 1 if args.is_frozen else 0

    updates = {k: v for k, v in updatable.items() if v is not None}
    if not updates:
        err("No fields to update")

    data = dict(updates)
    data["updated_at"] = now()
    sql, values = dynamic_update("account", data, where={"id": acct_id})
    conn.execute(sql, values)

    audit(conn, "erpclaw-gl", "update", "account", acct_id,
           old_values={k: old.get(k) for k in updates},
           new_values=updates)
    conn.commit()
    ok({"status": "updated", "account_id": acct_id,
         "updated_fields": list(updates.keys())})


# ---------------------------------------------------------------------------
# 4. list-accounts
# ---------------------------------------------------------------------------

def list_accounts(conn, args):
    company_id = resolve_company_id(conn, getattr(args, 'company_id', None))

    limit_val = int(args.limit or 20)
    offset_val = int(args.offset or 0)

    t = Table("account")
    q = Q.from_(t).select(t.star).where(t.company_id == P())
    params = [company_id]

    if args.root_type:
        q = q.where(t.root_type == P())
        params.append(args.root_type)
    if args.account_type:
        q = q.where(t.account_type == P())
        params.append(args.account_type)
    if args.is_group:
        q = q.where(t.is_group == 1)
    if args.parent_id:
        q = q.where(t.parent_id == P())
        params.append(args.parent_id)
    if args.search:
        q = q.where(
            Criterion.any([t.name.like(P()), t.account_number.like(P())])
        )
        params.extend([f"%{args.search}%", f"%{args.search}%"])
    if not args.include_frozen:
        q = q.where(t.is_frozen == 0).where(t.disabled == 0)

    count_sql = q.get_sql().replace("SELECT *", 'SELECT COUNT(*) "cnt"', 1)
    total_count = conn.execute(count_sql, params).fetchone()["cnt"]

    q = q.orderby(t.account_number).orderby(t.name).limit(P()).offset(P())
    params.extend([limit_val, offset_val])
    rows = conn.execute(q.get_sql(), params).fetchall()
    ok({"accounts": [row_to_dict(r) for r in rows],
         "total_count": total_count, "limit": limit_val, "offset": offset_val,
         "has_more": offset_val + limit_val < total_count})


# ---------------------------------------------------------------------------
# 5. get-account
# ---------------------------------------------------------------------------

def get_account(conn, args):
    acct_id = args.account_id
    if not acct_id:
        err("--account-id is required")

    t_account = Table("account")
    t_gl = Table("gl_entry")

    q = Q.from_(t_account).select(t_account.star).where(t_account.id == P())
    row = conn.execute(q.get_sql(), (acct_id,)).fetchone()
    if not row:
        err(f"Account {acct_id} not found",
             suggestion="Use 'list accounts' to see available accounts.")

    acct = row_to_dict(row)

    # Include balance
    as_of = args.as_of_date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
    q = (Q.from_(t_gl)
         .select(fn.Coalesce(DecimalSum(t_gl.debit), ValueWrapper("0")).as_("debit_total"),
                 fn.Coalesce(DecimalSum(t_gl.credit), ValueWrapper("0")).as_("credit_total"))
         .where(t_gl.account_id == P())
         .where(t_gl.posting_date <= P())
         .where(t_gl.is_cancelled == 0))
    bal = conn.execute(q.get_sql(), (acct_id, as_of)).fetchone()

    debit_total = Decimal(str(bal["debit_total"]))
    credit_total = Decimal(str(bal["credit_total"]))
    if acct["balance_direction"] == "debit_normal":
        balance = debit_total - credit_total
    else:
        balance = credit_total - debit_total

    acct["balance"] = str(balance)
    acct["debit_total"] = str(debit_total)
    acct["credit_total"] = str(credit_total)

    ok({"account": acct})


# ---------------------------------------------------------------------------
# 6-7. freeze-account / unfreeze-account
# ---------------------------------------------------------------------------

def freeze_account(conn, args):
    acct_id = args.account_id
    if not acct_id:
        err("--account-id is required")
    t = Table("account")
    q = Q.from_(t).select(t.id).where(t.id == P())
    row = conn.execute(q.get_sql(), (acct_id,)).fetchone()
    if not row:
        err(f"Account {acct_id} not found")
    q = (Q.update(t)
         .set(Field("is_frozen"), 1)
         .set(Field("updated_at"), now())
         .where(t.id == P()))
    conn.execute(q.get_sql(), (acct_id,))
    audit(conn, "erpclaw-gl", "freeze", "account", acct_id, new_values={"is_frozen": 1})
    conn.commit()
    ok({"status": "updated", "account_id": acct_id, "is_frozen": True})


def unfreeze_account(conn, args):
    acct_id = args.account_id
    if not acct_id:
        err("--account-id is required")
    t = Table("account")
    q = Q.from_(t).select(t.id).where(t.id == P())
    row = conn.execute(q.get_sql(), (acct_id,)).fetchone()
    if not row:
        err(f"Account {acct_id} not found")
    q = (Q.update(t)
         .set(Field("is_frozen"), 0)
         .set(Field("updated_at"), now())
         .where(t.id == P()))
    conn.execute(q.get_sql(), (acct_id,))
    audit(conn, "erpclaw-gl", "unfreeze", "account", acct_id, new_values={"is_frozen": 0})
    conn.commit()
    ok({"status": "updated", "account_id": acct_id, "is_frozen": False})


# ---------------------------------------------------------------------------
# 8. post-gl-entries
# ---------------------------------------------------------------------------

def post_gl_entries(conn, args):
    voucher_type = args.voucher_type
    voucher_id = args.voucher_id
    posting_date = args.posting_date
    company_id = args.company_id
    entries_json = args.entries

    if not all([voucher_type, voucher_id, posting_date, company_id, entries_json]):
        err("--voucher-type, --voucher-id, --posting-date, --company-id, --entries are required")

    try:
        entries = json.loads(entries_json)
    except (json.JSONDecodeError, TypeError):
        err("--entries must be valid JSON array")

    if not isinstance(entries, list) or len(entries) == 0:
        err("--entries must be a non-empty JSON array")

    # Validate via shared lib (raises ValueError on failure)
    try:
        warnings = validate_gl_entries(conn, entries, company_id, posting_date)
    except ValueError as e:
        err(str(e))

    # Insert via shared lib
    try:
        gl_ids = insert_gl_entries(conn, entries, voucher_type, voucher_id,
                                   posting_date, company_id)
    except ValueError as e:
        err(str(e))

    audit(conn, "erpclaw-gl", "post", "gl_entry", voucher_id,
           new_values={"voucher_type": voucher_type, "entries_created": len(gl_ids)})
    conn.commit()

    ok({"status": "created", "gl_entry_ids": gl_ids, "entries_created": len(gl_ids),
         "warnings": warnings})


# ---------------------------------------------------------------------------
# 9. reverse-gl-entries
# ---------------------------------------------------------------------------

def reverse_gl_entries_action(conn, args):
    voucher_type = args.voucher_type
    voucher_id = args.voucher_id
    posting_date = args.posting_date

    if not voucher_type or not voucher_id:
        err("--voucher-type and --voucher-id are required")

    posting_date = posting_date or datetime.now(timezone.utc).strftime("%Y-%m-%d")

    try:
        reversal_ids = reverse_gl_entries(conn, voucher_type, voucher_id, posting_date)
    except ValueError as e:
        err(str(e))

    audit(conn, "erpclaw-gl", "reverse", "gl_entry", voucher_id,
           new_values={"voucher_type": voucher_type, "reversed_count": len(reversal_ids)})
    conn.commit()

    ok({"reversed_count": len(reversal_ids), "reversal_entry_ids": reversal_ids})


# ---------------------------------------------------------------------------
# 10. list-gl-entries
# ---------------------------------------------------------------------------

def list_gl_entries(conn, args):
    company_id = resolve_company_id(conn, getattr(args, 'company_id', None))

    g = Table("gl_entry").as_("g")
    a = Table("account").as_("a")

    q = (Q.from_(g).join(a).on(g.account_id == a.id)
         .select(g.star, a.name.as_("account_name")))
    params = []

    if company_id:
        q = q.where(a.company_id == P())
        params.append(company_id)
    if args.account_id:
        q = q.where(g.account_id == P())
        params.append(args.account_id)
    if args.voucher_type:
        q = q.where(g.voucher_type == P())
        params.append(args.voucher_type)
    if args.voucher_id:
        q = q.where(g.voucher_id == P())
        params.append(args.voucher_id)
    if args.party_type:
        q = q.where(g.party_type == P())
        params.append(args.party_type)
    if args.party_id:
        q = q.where(g.party_id == P())
        params.append(args.party_id)
    if args.from_date:
        q = q.where(g.posting_date >= P())
        params.append(args.from_date)
    if args.to_date:
        q = q.where(g.posting_date <= P())
        params.append(args.to_date)
    if args.is_cancelled is not None:
        q = q.where(g.is_cancelled == P())
        params.append(1 if args.is_cancelled else 0)

    # Count total before limit
    select_sql = q.get_sql()
    # Replace the SELECT columns with COUNT(*)
    from_pos = select_sql.index(" FROM ")
    count_sql = 'SELECT COUNT(*) "cnt"' + select_sql[from_pos:]
    total = conn.execute(count_sql, params).fetchone()["cnt"]

    limit_val = int(args.limit or 100)
    offset_val = int(args.offset or 0)
    q = q.orderby(g.posting_date, order=Order.desc).orderby(g.created_at, order=Order.desc)
    q = q.limit(P()).offset(P())
    params.extend([limit_val, offset_val])

    rows = conn.execute(q.get_sql(), params).fetchall()
    ok({"entries": [row_to_dict(r) for r in rows], "total_count": total,
         "limit": limit_val, "offset": offset_val,
         "has_more": offset_val + limit_val < total})


# ---------------------------------------------------------------------------
# 11. add-fiscal-year
# ---------------------------------------------------------------------------

def add_fiscal_year(conn, args):
    name = args.name
    start_date = args.start_date
    end_date = args.end_date
    company_id = args.company_id

    if not all([name, start_date, end_date, company_id]):
        err("--name, --start-date, --end-date, --company-id are required")

    # Validate no overlap
    t_fy = Table("fiscal_year")
    q = (Q.from_(t_fy).select(t_fy.id, t_fy.name)
         .where(t_fy.company_id == P())
         .where(t_fy.start_date <= P())
         .where(t_fy.end_date >= P()))
    overlap = conn.execute(q.get_sql(), (company_id, end_date, start_date)).fetchone()
    if overlap:
        err(f"Dates overlap with existing fiscal year '{overlap['name']}'")

    fy_id = str(uuid.uuid4())
    try:
        q = (Q.into(t_fy)
             .columns("id", "name", "start_date", "end_date", "company_id")
             .insert(P(), P(), P(), P(), P()))
        conn.execute(q.get_sql(), (fy_id, name, start_date, end_date, company_id))
        audit(conn, "erpclaw-gl", "create", "fiscal_year", fy_id,
               new_values={"name": name, "start_date": start_date, "end_date": end_date})
        conn.commit()
    except sqlite3.IntegrityError as e:
        sys.stderr.write(f"[erpclaw-gl] {e}\n")
        err("Fiscal year creation failed — check for duplicates or invalid data")

    ok({"status": "created", "fiscal_year_id": fy_id, "name": name})


# ---------------------------------------------------------------------------
# 12. list-fiscal-years
# ---------------------------------------------------------------------------

def list_fiscal_years(conn, args):
    company_id = resolve_company_id(conn, getattr(args, 'company_id', None))

    limit_val = int(args.limit or 20)
    offset_val = int(args.offset or 0)

    t = Table("fiscal_year")
    q_count = (Q.from_(t).select(fn.Count("*").as_("cnt"))
               .where(t.company_id == P()))
    total_count = conn.execute(q_count.get_sql(), (company_id,)).fetchone()["cnt"]

    q = (Q.from_(t).select(t.star)
         .where(t.company_id == P())
         .orderby(t.start_date, order=Order.desc)
         .limit(P()).offset(P()))
    rows = conn.execute(q.get_sql(), (company_id, limit_val, offset_val)).fetchall()
    ok({"fiscal_years": [row_to_dict(r) for r in rows],
         "total_count": total_count, "limit": limit_val, "offset": offset_val,
         "has_more": offset_val + limit_val < total_count})


# ---------------------------------------------------------------------------
# 13. validate-period-close
# ---------------------------------------------------------------------------

def validate_period_close(conn, args):
    fy_id = args.fiscal_year_id
    if not fy_id:
        err("--fiscal-year-id is required")

    t_fy = Table("fiscal_year")
    t_gl = Table("gl_entry").as_("g")
    t_acct = Table("account").as_("a")

    q = Q.from_(t_fy).select(t_fy.star).where(t_fy.id == P())
    fy = row_to_dict(conn.execute(q.get_sql(), (fy_id,)).fetchone())
    if not fy:
        err(f"Fiscal year {fy_id} not found")

    if fy["is_closed"]:
        err(f"Fiscal year '{fy['name']}' is already closed")

    company_id = fy["company_id"]

    # Calculate P&L for the period — raw SQL for expression arithmetic in SELECT
    # raw SQL — SELECT uses column arithmetic (decimal_sum(credit) - decimal_sum(debit))
    income = conn.execute(
        """SELECT COALESCE(decimal_sum(g.credit), '0') - COALESCE(decimal_sum(g.debit), '0') as total
           FROM gl_entry g JOIN account a ON g.account_id = a.id
           WHERE a.root_type = 'income' AND a.company_id = ?
             AND g.posting_date >= ? AND g.posting_date <= ?
             AND g.is_cancelled = 0""",
        (company_id, fy["start_date"], fy["end_date"]),
    ).fetchone()["total"]

    # raw SQL — SELECT uses column arithmetic (decimal_sum(debit) - decimal_sum(credit))
    expense = conn.execute(
        """SELECT COALESCE(decimal_sum(g.debit), '0') - COALESCE(decimal_sum(g.credit), '0') as total
           FROM gl_entry g JOIN account a ON g.account_id = a.id
           WHERE a.root_type = 'expense' AND a.company_id = ?
             AND g.posting_date >= ? AND g.posting_date <= ?
             AND g.is_cancelled = 0""",
        (company_id, fy["start_date"], fy["end_date"]),
    ).fetchone()["total"]

    net_income = Decimal(str(income)) - Decimal(str(expense))

    # Check trial balance
    # raw SQL — COALESCE(decimal_sum(...)) aggregate with JOIN
    totals = conn.execute(
        """SELECT COALESCE(decimal_sum(debit), '0') as total_debit,
                  COALESCE(decimal_sum(credit), '0') as total_credit
           FROM gl_entry g JOIN account a ON g.account_id = a.id
           WHERE a.company_id = ? AND g.is_cancelled = 0""",
        (company_id,),
    ).fetchone()
    balanced = abs(Decimal(str(totals["total_debit"])) - Decimal(str(totals["total_credit"]))) < Decimal("0.01")

    ok({
        "fiscal_year": fy["name"],
        "income_total": str(Decimal(str(income))),
        "expense_total": str(Decimal(str(expense))),
        "net_income": str(net_income),
        "trial_balance_balanced": balanced,
    })


# ---------------------------------------------------------------------------
# 14. close-fiscal-year
# ---------------------------------------------------------------------------

def close_fiscal_year(conn, args):
    fy_id = args.fiscal_year_id
    closing_account_id = args.closing_account_id
    posting_date = args.posting_date

    if not all([fy_id, closing_account_id, posting_date]):
        err("--fiscal-year-id, --closing-account-id, --posting-date are required")

    t_fy = Table("fiscal_year")
    t_account = Table("account")
    t_pcv = Table("period_closing_voucher")

    q = Q.from_(t_fy).select(t_fy.star).where(t_fy.id == P())
    fy = row_to_dict(conn.execute(q.get_sql(), (fy_id,)).fetchone())
    if not fy:
        err(f"Fiscal year {fy_id} not found")
    if fy["is_closed"]:
        err(f"Fiscal year '{fy['name']}' is already closed")

    # Verify closing account exists and is equity type
    q = Q.from_(t_account).select(t_account.star).where(t_account.id == P())
    closing_acct = conn.execute(q.get_sql(), (closing_account_id,)).fetchone()
    if not closing_acct:
        err(f"Closing account {closing_account_id} not found")
    if closing_acct["root_type"] != "equity":
        err("Closing account must be an equity account (retained earnings)")

    company_id = fy["company_id"]

    # raw SQL — SELECT uses column arithmetic (decimal_sum(credit) - decimal_sum(debit))
    income = conn.execute(
        """SELECT COALESCE(decimal_sum(g.credit), '0') - COALESCE(decimal_sum(g.debit), '0')
           FROM gl_entry g JOIN account a ON g.account_id = a.id
           WHERE a.root_type = 'income' AND a.company_id = ?
             AND g.posting_date >= ? AND g.posting_date <= ?
             AND g.is_cancelled = 0""",
        (company_id, fy["start_date"], fy["end_date"]),
    ).fetchone()[0]

    # raw SQL — SELECT uses column arithmetic (decimal_sum(debit) - decimal_sum(credit))
    expense = conn.execute(
        """SELECT COALESCE(decimal_sum(g.debit), '0') - COALESCE(decimal_sum(g.credit), '0')
           FROM gl_entry g JOIN account a ON g.account_id = a.id
           WHERE a.root_type = 'expense' AND a.company_id = ?
             AND g.posting_date >= ? AND g.posting_date <= ?
             AND g.is_cancelled = 0""",
        (company_id, fy["start_date"], fy["end_date"]),
    ).fetchone()[0]

    net_pl = Decimal(str(income)) - Decimal(str(expense))

    # Create period closing voucher
    pcv_id = str(uuid.uuid4())
    q = (Q.into(t_pcv)
         .columns("id", "fiscal_year_id", "posting_date", "closing_account_id",
                   "net_pl_amount", "status", "company_id")
         .insert(P(), P(), P(), P(), P(), ValueWrapper("submitted"), P()))
    conn.execute(q.get_sql(),
        (pcv_id, fy_id, posting_date, closing_account_id, str(net_pl), company_id))

    # Post closing GL entries: net P&L → retained earnings
    entries = []
    if net_pl > 0:
        # Net income: DR Income Summary, CR Retained Earnings
        entries = [
            {"account_id": closing_account_id, "debit": "0", "credit": str(net_pl)},
        ]
        # We need a balancing debit — use a temporary income summary or
        # directly post: DR income accounts, CR retained earnings
        # Simplified: single net entry
        # Find an income account to debit (or use closing account both sides)
        # Actually for period closing: DR P&L = CR Retained Earnings for net income
        # In ERPNext this posts a single pair:
        entries = [
            {"account_id": closing_account_id, "debit": "0", "credit": str(net_pl)},
        ]
        # Need a balancing entry — use income total account or an expense placeholder
        # Simplest correct approach: post the net as a pair
        # DR: Temporary/P&L summary account → CR: Retained Earnings
        # For simplicity, we'll use the closing account itself (debit & credit net to the amount)
        # Actually the standard approach is to zero out all income and expense accounts
        # into retained earnings. Let's do net approach:
        # If net_pl positive (profit): CR retained earnings, DR comes from zeroing income/expense
        # For a simple implementation: one closing entry
        pass
    elif net_pl < 0:
        pass

    # Simple net approach: post a single journal-style pair
    # The closing entry transfers net P&L to retained earnings
    # We need SOME account on the other side. Use a "Period Closing" voucher type.
    # ERPNext creates entries per-account, but our simplified approach:
    # Post entries only if there's a net amount
    gl_entries_created = 0
    if net_pl != 0:
        abs_pl = abs(net_pl)
        # Find any income account for balancing
        if net_pl > 0:
            # Profit: credit retained earnings, need a debit somewhere
            # Standard: debit each income account, credit each expense account → net to RE
            # Simplified: use period_closing voucher_type with closing_account on both sides
            # is wrong. Let's collect income/expense totals per account and close them.
            gl_ids = _close_pl_accounts(conn, company_id, fy, closing_account_id,
                                         pcv_id, posting_date)
            gl_entries_created = len(gl_ids)
        else:
            gl_ids = _close_pl_accounts(conn, company_id, fy, closing_account_id,
                                         pcv_id, posting_date)
            gl_entries_created = len(gl_ids)

    # Close the fiscal year
    q = (Q.update(t_fy)
         .set(Field("is_closed"), 1)
         .set(Field("updated_at"), now())
         .where(t_fy.id == P()))
    conn.execute(q.get_sql(), (fy_id,))

    audit(conn, "erpclaw-gl", "close", "fiscal_year", fy_id,
           new_values={"pcv_id": pcv_id, "net_pl": str(net_pl)})
    conn.commit()

    ok({"status": "submitted", "pcv_id": pcv_id, "net_pl_transferred": str(net_pl),
         "gl_entries_created": gl_entries_created, "fiscal_year_closed": True})


def _close_pl_accounts(conn, company_id, fy, closing_account_id, pcv_id, posting_date):
    """Zero out all income and expense accounts into retained earnings."""
    gl_ids = []

    # raw SQL — LEFT JOIN with conditions, GROUP BY, HAVING with != comparison
    pl_accounts = conn.execute(
        """SELECT a.id, a.root_type,
                  COALESCE(decimal_sum(g.debit), '0') as total_debit,
                  COALESCE(decimal_sum(g.credit), '0') as total_credit
           FROM account a
           LEFT JOIN gl_entry g ON g.account_id = a.id
             AND g.posting_date >= ? AND g.posting_date <= ?
             AND g.is_cancelled = 0
           WHERE a.root_type IN ('income', 'expense') AND a.company_id = ?
             AND a.is_group = 0
           GROUP BY a.id
           HAVING total_debit != 0 OR total_credit != 0""",
        (fy["start_date"], fy["end_date"], company_id),
    ).fetchall()

    t_gl = Table("gl_entry")
    _gl_cols = ("id", "posting_date", "account_id", "debit", "credit",
                "debit_base", "credit_base", "currency", "exchange_rate",
                "fiscal_year", "voucher_type", "voucher_id")
    _gl_insert = (Q.into(t_gl).columns(*_gl_cols)
                  .insert(P(), P(), P(), P(), P(), P(), P(), P(), P(), P(), P(), P())
                  .get_sql())

    for acct in pl_accounts:
        net = Decimal(str(acct["total_debit"])) - Decimal(str(acct["total_credit"]))
        if net == 0:
            continue

        entry_id = str(uuid.uuid4())
        re_entry_id = str(uuid.uuid4())

        if acct["root_type"] == "income":
            # Income accounts have credit balance -> debit to zero
            # Income net = credit - debit (positive = credit balance)
            income_net = Decimal(str(acct["total_credit"])) - Decimal(str(acct["total_debit"]))
            if income_net == 0:
                continue
            if income_net > 0:
                # DR income account, CR retained earnings
                conn.execute(_gl_insert,
                    (entry_id, posting_date, acct["id"], str(income_net), "0",
                     str(income_net), "0", "USD", "1", fy["name"], "period_closing", pcv_id))
                conn.execute(_gl_insert,
                    (re_entry_id, posting_date, closing_account_id, "0", str(income_net),
                     "0", str(income_net), "USD", "1", fy["name"], "period_closing", pcv_id))
                gl_ids.extend([entry_id, re_entry_id])
            else:
                # Unusual: income account has debit balance -> credit to zero
                abs_net = abs(income_net)
                conn.execute(_gl_insert,
                    (entry_id, posting_date, acct["id"], "0", str(abs_net),
                     "0", str(abs_net), "USD", "1", fy["name"], "period_closing", pcv_id))
                conn.execute(_gl_insert,
                    (re_entry_id, posting_date, closing_account_id, str(abs_net), "0",
                     str(abs_net), "0", "USD", "1", fy["name"], "period_closing", pcv_id))
                gl_ids.extend([entry_id, re_entry_id])
        else:
            # Expense accounts have debit balance -> credit to zero
            expense_net = Decimal(str(acct["total_debit"])) - Decimal(str(acct["total_credit"]))
            if expense_net == 0:
                continue
            if expense_net > 0:
                # CR expense account, DR retained earnings
                conn.execute(_gl_insert,
                    (entry_id, posting_date, acct["id"], "0", str(expense_net),
                     "0", str(expense_net), "USD", "1", fy["name"], "period_closing", pcv_id))
                conn.execute(_gl_insert,
                    (re_entry_id, posting_date, closing_account_id, str(expense_net), "0",
                     str(expense_net), "0", "USD", "1", fy["name"], "period_closing", pcv_id))
                gl_ids.extend([entry_id, re_entry_id])
            else:
                # Unusual: expense has credit balance
                abs_net = abs(expense_net)
                conn.execute(_gl_insert,
                    (entry_id, posting_date, acct["id"], str(abs_net), "0",
                     str(abs_net), "0", "USD", "1", fy["name"], "period_closing", pcv_id))
                conn.execute(_gl_insert,
                    (re_entry_id, posting_date, closing_account_id, "0", str(abs_net),
                     "0", str(abs_net), "USD", "1", fy["name"], "period_closing", pcv_id))
                gl_ids.extend([entry_id, re_entry_id])

    return gl_ids


# ---------------------------------------------------------------------------
# 15. reopen-fiscal-year
# ---------------------------------------------------------------------------

def reopen_fiscal_year(conn, args):
    fy_id = args.fiscal_year_id
    if not fy_id:
        err("--fiscal-year-id is required")

    t_fy = Table("fiscal_year")
    t_gl = Table("gl_entry")
    t_pcv = Table("period_closing_voucher")

    q = Q.from_(t_fy).select(t_fy.star).where(t_fy.id == P())
    fy = row_to_dict(conn.execute(q.get_sql(), (fy_id,)).fetchone())
    if not fy:
        err(f"Fiscal year {fy_id} not found")
    if not fy["is_closed"]:
        err(f"Fiscal year '{fy['name']}' is not closed")

    # Reverse the period closing voucher entries
    q = (Q.from_(t_pcv).select(t_pcv.id)
         .where(t_pcv.fiscal_year_id == P())
         .where(t_pcv.status == ValueWrapper("submitted")))
    pcv = conn.execute(q.get_sql(), (fy_id,)).fetchone()

    pcv_reversed = False
    if pcv:
        # Cancel all GL entries for this PCV
        q = (Q.update(t_gl)
             .set(Field("is_cancelled"), 1)
             .where(t_gl.voucher_type == ValueWrapper("period_closing"))
             .where(t_gl.voucher_id == P()))
        conn.execute(q.get_sql(), (pcv["id"],))

        q = (Q.update(t_pcv)
             .set(Field("status"), ValueWrapper("cancelled"))
             .set(Field("updated_at"), now())
             .where(t_pcv.id == P()))
        conn.execute(q.get_sql(), (pcv["id"],))
        pcv_reversed = True

    q = (Q.update(t_fy)
         .set(Field("is_closed"), 0)
         .set(Field("updated_at"), now())
         .where(t_fy.id == P()))
    conn.execute(q.get_sql(), (fy_id,))

    audit(conn, "erpclaw-gl", "reopen", "fiscal_year", fy_id,
           new_values={"is_closed": 0, "pcv_reversed": pcv_reversed})
    conn.commit()

    ok({"fiscal_year_id": fy_id, "is_closed": False, "pcv_reversed": pcv_reversed})


# ---------------------------------------------------------------------------
# 16. add-cost-center
# ---------------------------------------------------------------------------

def add_cost_center(conn, args):
    name = args.name
    company_id = args.company_id
    if not name:
        err("--name is required")
    if not company_id:
        err("--company-id is required")

    cc_id = str(uuid.uuid4())
    parent_id = args.parent_id
    is_group = 1 if args.is_group else 0

    t_cc = Table("cost_center")
    try:
        q = (Q.into(t_cc)
             .columns("id", "name", "parent_id", "company_id", "is_group")
             .insert(P(), P(), P(), P(), P()))
        conn.execute(q.get_sql(), (cc_id, name, parent_id, company_id, is_group))
        audit(conn, "erpclaw-gl", "create", "cost_center", cc_id, new_values={"name": name})
        conn.commit()
    except sqlite3.IntegrityError as e:
        sys.stderr.write(f"[erpclaw-gl] {e}\n")
        err("Cost center creation failed — check for duplicates or invalid data")

    ok({"status": "created", "cost_center_id": cc_id, "name": name})


# ---------------------------------------------------------------------------
# 17. list-cost-centers
# ---------------------------------------------------------------------------

def list_cost_centers(conn, args):
    company_id = resolve_company_id(conn, getattr(args, 'company_id', None))

    limit_val = int(args.limit or 20)
    offset_val = int(args.offset or 0)

    t = Table("cost_center")
    q = Q.from_(t).select(t.star).where(t.company_id == P())
    params = [company_id]
    if args.parent_id:
        q = q.where(t.parent_id == P())
        params.append(args.parent_id)

    count_sql = q.get_sql().replace("SELECT *", 'SELECT COUNT(*) "cnt"', 1)
    total_count = conn.execute(count_sql, params).fetchone()["cnt"]

    q = q.orderby(t.name).limit(P()).offset(P())
    params.extend([limit_val, offset_val])

    rows = conn.execute(q.get_sql(), params).fetchall()
    ok({"cost_centers": [row_to_dict(r) for r in rows],
         "total_count": total_count, "limit": limit_val, "offset": offset_val,
         "has_more": offset_val + limit_val < total_count})


# ---------------------------------------------------------------------------
# 18. add-budget
# ---------------------------------------------------------------------------

def add_budget(conn, args):
    fy_id = args.fiscal_year_id
    budget_amount = args.budget_amount
    if not fy_id:
        err("--fiscal-year-id is required")
    if not budget_amount:
        err("--budget-amount is required")

    account_id = args.account_id
    cost_center_id = args.cost_center_id
    if not account_id and not cost_center_id:
        err("At least one of --account-id or --cost-center-id is required")

    # Get company_id from fiscal year
    t_fy = Table("fiscal_year")
    t_budget = Table("budget")
    q = Q.from_(t_fy).select(t_fy.company_id).where(t_fy.id == P())
    fy = conn.execute(q.get_sql(), (fy_id,)).fetchone()
    if not fy:
        err(f"Fiscal year {fy_id} not found")

    action_if_exceeded = args.action_if_exceeded or "warn"
    budget_id = str(uuid.uuid4())

    try:
        q = (Q.into(t_budget)
             .columns("id", "fiscal_year_id", "account_id", "cost_center_id",
                       "budget_amount", "action_if_exceeded", "company_id")
             .insert(P(), P(), P(), P(), P(), P(), P()))
        conn.execute(q.get_sql(),
            (budget_id, fy_id, account_id, cost_center_id,
             budget_amount, action_if_exceeded, fy["company_id"]))
        audit(conn, "erpclaw-gl", "create", "budget", budget_id,
               new_values={"budget_amount": budget_amount, "action_if_exceeded": action_if_exceeded})
        conn.commit()
    except sqlite3.IntegrityError as e:
        sys.stderr.write(f"[erpclaw-gl] {e}\n")
        err("Budget creation failed — check for duplicates or invalid data")

    ok({"status": "created", "budget_id": budget_id})


# ---------------------------------------------------------------------------
# 19. list-budgets
# ---------------------------------------------------------------------------

def list_budgets(conn, args):
    fy_id = args.fiscal_year_id
    if not fy_id:
        err("--fiscal-year-id is required")
    company_id = resolve_company_id(conn, getattr(args, 'company_id', None))

    limit = int(args.limit or 20)
    offset = int(args.offset or 0)

    t_fy = Table("fiscal_year")
    t_budget = Table("budget")

    q = Q.from_(t_fy).select(t_fy.star).where(t_fy.id == P())
    fy = row_to_dict(conn.execute(q.get_sql(), (fy_id,)).fetchone())
    if not fy:
        err(f"Fiscal year {fy_id} not found")

    q_count = (Q.from_(t_budget).select(fn.Count("*").as_("cnt"))
               .where(t_budget.fiscal_year_id == P())
               .where(t_budget.company_id == P()))
    total_count = conn.execute(q_count.get_sql(), (fy_id, company_id)).fetchone()["cnt"]

    q = (Q.from_(t_budget).select(t_budget.star)
         .where(t_budget.fiscal_year_id == P())
         .where(t_budget.company_id == P())
         .limit(P()).offset(P()))
    budgets = conn.execute(q.get_sql(), (fy_id, company_id, limit, offset)).fetchall()

    result = []
    for b in budgets:
        bd = row_to_dict(b)
        # Compute actual from GL
        t_gl = Table("gl_entry").as_("g")
        q_actual = (Q.from_(t_gl)
                    .select(fn.Coalesce(DecimalSum(t_gl.debit), ValueWrapper("0")).as_("total"))
                    .where(t_gl.is_cancelled == 0)
                    .where(t_gl.posting_date >= P())
                    .where(t_gl.posting_date <= P()))
        actual_params = [fy["start_date"], fy["end_date"]]

        if bd["account_id"]:
            q_actual = q_actual.where(t_gl.account_id == P())
            actual_params.append(bd["account_id"])
        if bd["cost_center_id"]:
            q_actual = q_actual.where(t_gl.cost_center_id == P())
            actual_params.append(bd["cost_center_id"])

        actual = Decimal(str(conn.execute(q_actual.get_sql(), actual_params).fetchone()["total"]))
        budget_amt = Decimal(bd["budget_amount"])
        bd["actual_amount"] = str(actual)
        bd["variance"] = str(budget_amt - actual)
        result.append(bd)

    ok({"budgets": result, "total_count": total_count, "limit": limit, "offset": offset,
         "has_more": offset + limit < total_count})


# ---------------------------------------------------------------------------
# 20. seed-naming-series
# ---------------------------------------------------------------------------

def seed_naming_series(conn, args):
    company_id = args.company_id
    if not company_id:
        err("--company-id is required")

    t_ns = Table("naming_series")

    year = datetime.now(timezone.utc).year
    created = 0
    for entity_type, prefix in ENTITY_PREFIXES.items():
        year_prefix = f"{prefix}{year}-"
        q = (Q.from_(t_ns).select(t_ns.id)
             .where(t_ns.entity_type == P())
             .where(t_ns.prefix == P())
             .where(t_ns.company_id == P()))
        existing = conn.execute(q.get_sql(), (entity_type, year_prefix, company_id)).fetchone()
        if not existing:
            q = (Q.into(t_ns)
                 .columns("id", "entity_type", "prefix", "current_value", "company_id")
                 .insert(P(), P(), P(), 0, P()))
            conn.execute(q.get_sql(), (str(uuid.uuid4()), entity_type, year_prefix, company_id))
            created += 1

    conn.commit()
    ok({"series_created": created, "total_types": len(ENTITY_PREFIXES)})


# ---------------------------------------------------------------------------
# 21. next-series
# ---------------------------------------------------------------------------

def next_series(conn, args):
    entity_type = args.entity_type
    company_id = args.company_id
    if not entity_type:
        err("--entity-type is required")
    if not company_id:
        err("--company-id is required")

    try:
        name = get_next_name(conn, entity_type, company_id=company_id)
        conn.commit()
    except ValueError as e:
        err(str(e))

    ok({"series": name, "entity_type": entity_type})


# ---------------------------------------------------------------------------
# 22. check-gl-integrity
# ---------------------------------------------------------------------------

def check_gl_integrity(conn, args):
    """Verify GL balance and SHA-256 chain integrity for a company."""
    import hashlib

    company_id = resolve_company_id(conn, getattr(args, 'company_id', None))

    g = Table("gl_entry").as_("g")
    a = Table("account").as_("a")

    # 1. Balance check
    q = (Q.from_(g).join(a).on(g.account_id == a.id)
         .select(fn.Coalesce(DecimalSum(g.debit), ValueWrapper("0")).as_("total_debit"),
                 fn.Coalesce(DecimalSum(g.credit), ValueWrapper("0")).as_("total_credit"))
         .where(a.company_id == P())
         .where(g.is_cancelled == 0))
    totals = conn.execute(q.get_sql(), (company_id,)).fetchone()

    total_debit = Decimal(str(totals["total_debit"]))
    total_credit = Decimal(str(totals["total_credit"]))
    difference = abs(total_debit - total_credit)

    # 2. Chain hash verification
    q = (Q.from_(g).join(a).on(g.account_id == a.id)
         .select(g.posting_date, g.account_id, g.debit, g.credit,
                 g.voucher_type, g.voucher_id, g.gl_checksum)
         .where(a.company_id == P())
         .orderby(g.created_at).orderby(LiteralValue('"g"."rowid"')))
    entries = conn.execute(q.get_sql(), (company_id,)).fetchall()

    chain_intact = True
    broken_links = 0
    total_entries = len(entries)
    prev_hash = "GENESIS"

    for row in entries:
        if not row["gl_checksum"]:
            # Entries created before chain was enabled — skip
            continue

        hash_input = "|".join([
            row["posting_date"],
            row["account_id"],
            str(row["debit"]),
            str(row["credit"]),
            row["voucher_type"],
            row["voucher_id"],
            prev_hash,
        ])
        expected = hashlib.sha256(hash_input.encode("utf-8")).hexdigest()

        if row["gl_checksum"] != expected:
            chain_intact = False
            broken_links += 1

        prev_hash = row["gl_checksum"]

    ok({
        "balanced": difference < Decimal("0.01"),
        "total_debit": str(total_debit),
        "total_credit": str(total_credit),
        "difference": str(difference),
        "chain_intact": chain_intact,
        "broken_links": broken_links,
        "total_entries": total_entries,
    })


# ---------------------------------------------------------------------------
# 23. get-account-balance
# ---------------------------------------------------------------------------

def get_account_balance_action(conn, args):
    acct_id = args.account_id
    as_of = args.as_of_date
    if not acct_id:
        err("--account-id is required")
    if not as_of:
        err("--as-of-date is required")

    t_account = Table("account")
    t_gl = Table("gl_entry")

    q = Q.from_(t_account).select(t_account.star).where(t_account.id == P())
    acct = conn.execute(q.get_sql(), (acct_id,)).fetchone()
    if not acct:
        err(f"Account {acct_id} not found")

    q = (Q.from_(t_gl)
         .select(fn.Coalesce(DecimalSum(t_gl.debit), ValueWrapper("0")).as_("debit_total"),
                 fn.Coalesce(DecimalSum(t_gl.credit), ValueWrapper("0")).as_("credit_total"))
         .where(t_gl.account_id == P())
         .where(t_gl.posting_date <= P())
         .where(t_gl.is_cancelled == 0))
    params = [acct_id, as_of]

    if args.party_type:
        q = q.where(t_gl.party_type == P())
        params.append(args.party_type)
    if args.party_id:
        q = q.where(t_gl.party_id == P())
        params.append(args.party_id)

    result = conn.execute(q.get_sql(), params).fetchone()
    debit_total = Decimal(str(result["debit_total"]))
    credit_total = Decimal(str(result["credit_total"]))

    if acct["balance_direction"] == "debit_normal":
        balance = debit_total - credit_total
    else:
        balance = credit_total - debit_total

    ok({
        "balance": str(balance),
        "debit_total": str(debit_total),
        "credit_total": str(credit_total),
        "currency": acct["currency"],
    })


# ---------------------------------------------------------------------------
# 24. status
# ---------------------------------------------------------------------------

def status(conn, args):
    t_company = Table("company")
    t_account = Table("account")
    t_fy = Table("fiscal_year")
    t_gl = Table("gl_entry")

    company_id = getattr(args, 'company_id', None)
    # Auto-detect: if single company, use it; if multiple, show global stats
    if not company_id:
        q = Q.from_(t_company).select(t_company.id).limit(2)
        rows = conn.execute(q.get_sql()).fetchall()
        if len(rows) == 1:
            company_id = rows[0]["id"]

    g = Table("gl_entry").as_("g")
    a = Table("account").as_("a")

    if company_id:
        q = Q.from_(t_account).select(fn.Count("*").as_("cnt")).where(t_account.company_id == P())
        accounts = conn.execute(q.get_sql(), (company_id,)).fetchone()["cnt"]

        q = Q.from_(t_fy).select(fn.Count("*").as_("cnt")).where(t_fy.company_id == P())
        fiscal_years = conn.execute(q.get_sql(), (company_id,)).fetchone()["cnt"]

        q = (Q.from_(g).join(a).on(g.account_id == a.id)
             .select(fn.Count("*").as_("cnt"))
             .where(a.company_id == P())
             .where(g.is_cancelled == 0))
        gl_entries = conn.execute(q.get_sql(), (company_id,)).fetchone()["cnt"]

        q = (Q.from_(g).join(a).on(g.account_id == a.id)
             .select(fn.Max(g.posting_date).as_("latest"))
             .where(a.company_id == P())
             .where(g.is_cancelled == 0))
        latest = conn.execute(q.get_sql(), (company_id,)).fetchone()["latest"]
    else:
        q = Q.from_(t_account).select(fn.Count("*").as_("cnt"))
        accounts = conn.execute(q.get_sql()).fetchone()["cnt"]

        q = Q.from_(t_fy).select(fn.Count("*").as_("cnt"))
        fiscal_years = conn.execute(q.get_sql()).fetchone()["cnt"]

        q = (Q.from_(t_gl).select(fn.Count("*").as_("cnt"))
             .where(t_gl.is_cancelled == 0))
        gl_entries = conn.execute(q.get_sql()).fetchone()["cnt"]

        q = (Q.from_(t_gl).select(fn.Max(t_gl.posting_date).as_("latest"))
             .where(t_gl.is_cancelled == 0))
        latest = conn.execute(q.get_sql()).fetchone()["latest"]

    q = Q.from_(t_company).select(fn.Count("*").as_("cnt"))
    companies = conn.execute(q.get_sql()).fetchone()["cnt"]

    ok({
        "companies": companies,
        "accounts": accounts,
        "fiscal_years": fiscal_years,
        "gl_entries": gl_entries,
        "latest_posting_date": latest,
    })


# ---------------------------------------------------------------------------
# 25. revalue-foreign-balances
# ---------------------------------------------------------------------------

def revalue_foreign_balances(conn, args):
    """Revalue foreign-currency monetary accounts at period-end rate.

    Finds all accounts with currency != company base currency,
    computes unrealized gain/loss at the as-of-date exchange rate,
    and posts revaluation journal entries.
    """
    company_id = args.company_id
    as_of_date = args.as_of_date
    if not company_id:
        err("--company-id is required")
    if not as_of_date:
        err("--as-of-date is required")

    # Get company base currency
    t_company = Table("company")
    q = (Q.from_(t_company)
         .select(t_company.default_currency, t_company.exchange_gain_loss_account_id)
         .where(t_company.id == P()))
    company = conn.execute(q.get_sql(), (company_id,)).fetchone()
    if not company:
        err(f"Company {company_id} not found")

    base_currency = company["default_currency"] or "USD"
    fx_account_id = company["exchange_gain_loss_account_id"]
    if not fx_account_id:
        err("Company has no exchange_gain_loss_account_id configured")

    # Find all foreign-currency accounts (AR, AP, Bank) with balances
    t_acct = Table("account").as_("a")
    q = (Q.from_(t_acct)
         .select(t_acct.id, t_acct.name, t_acct.currency, t_acct.account_type, t_acct.root_type)
         .where(t_acct.company_id == P())
         .where(t_acct.currency != P())
         .where(t_acct.is_group == 0)
         .where(t_acct.disabled == 0)
         .where(t_acct.currency.isnotnull())
         .where(t_acct.account_type.isin(["receivable", "payable", "bank", "cash"])))
    accounts = conn.execute(q.get_sql(), (company_id, base_currency)).fetchall()

    if not accounts:
        ok({"revaluations": [], "message": "No foreign currency accounts found"})

    revaluations = []
    total_gain_loss = Decimal("0")

    for acct in accounts:
        acct_currency = acct["currency"]

        # raw SQL — uses CAST(column AS NUMERIC) which PyPika doesn't support cleanly
        bal = conn.execute(
            """SELECT
                COALESCE(SUM(CAST(debit AS NUMERIC)), 0) as total_debit,
                COALESCE(SUM(CAST(credit AS NUMERIC)), 0) as total_credit,
                COALESCE(SUM(CAST(debit_base AS NUMERIC)), 0) as total_debit_base,
                COALESCE(SUM(CAST(credit_base AS NUMERIC)), 0) as total_credit_base
               FROM gl_entry
               WHERE account_id = ? AND is_cancelled = 0 AND posting_date <= ?""",
            (acct["id"], as_of_date),
        ).fetchone()

        txn_balance = to_decimal(str(bal["total_debit"] - bal["total_credit"]))
        current_base_balance = to_decimal(str(bal["total_debit_base"] - bal["total_credit_base"]))

        if txn_balance == 0:
            continue

        # Get exchange rate at as-of-date
        new_rate = get_exchange_rate(conn, acct_currency, base_currency, as_of_date)
        if new_rate is None:
            revaluations.append({
                "account": acct["name"],
                "currency": acct_currency,
                "skipped": True,
                "reason": f"No exchange rate found for {acct_currency}/{base_currency} on {as_of_date}",
            })
            continue

        # Revalued base balance
        new_base_balance = convert_to_base(txn_balance, new_rate)
        gain_loss = new_base_balance - current_base_balance

        if gain_loss == 0:
            continue

        total_gain_loss += gain_loss
        abs_gl = abs(gain_loss)

        # Post revaluation GL entries (in base currency)
        # Revaluation is a base-currency adjustment, so entries use base amounts
        if gain_loss > 0:
            # Gain: DR account (increase), CR FX gain/loss
            gl_entries = [
                {"account_id": acct["id"],
                 "debit": str(abs_gl), "credit": "0"},
                {"account_id": fx_account_id,
                 "debit": "0", "credit": str(abs_gl)},
            ]
        else:
            # Loss: DR FX gain/loss, CR account (decrease)
            gl_entries = [
                {"account_id": acct["id"],
                 "debit": "0", "credit": str(abs_gl)},
                {"account_id": fx_account_id,
                 "debit": str(abs_gl), "credit": "0"},
            ]

        # FX account needs cost center (P&L account)
        q_cc = (Q.from_(t_company).select(t_company.default_cost_center_id)
                .where(t_company.id == P()))
        default_cc = conn.execute(q_cc.get_sql(), (company_id,)).fetchone()
        if default_cc and default_cc["default_cost_center_id"]:
            gl_entries[-1]["cost_center_id"] = default_cc["default_cost_center_id"]

        voucher_id = str(uuid.uuid4())
        try:
            gl_ids = insert_gl_entries(
                conn, gl_entries,
                voucher_type="exchange_rate_revaluation",
                voucher_id=voucher_id,
                posting_date=as_of_date,
                company_id=company_id,
                remarks=f"FX Revaluation: {acct['name']} ({acct_currency})",
            )
            revaluations.append({
                "account": acct["name"],
                "currency": acct_currency,
                "txn_balance": str(txn_balance),
                "old_base_balance": str(current_base_balance),
                "new_base_balance": str(new_base_balance),
                "gain_loss": str(gain_loss),
                "exchange_rate": str(new_rate),
                "gl_entries": len(gl_ids),
            })
        except ValueError as e:
            revaluations.append({
                "account": acct["name"],
                "currency": acct_currency,
                "skipped": True,
                "reason": str(e),
            })

    conn.commit()

    ok({
        "revaluations": revaluations,
        "total_gain_loss": str(total_gain_loss),
        "accounts_processed": len(revaluations),
    })


# ---------------------------------------------------------------------------
# 26. import-chart-of-accounts
# ---------------------------------------------------------------------------

def import_chart_of_accounts(conn, args):
    """Import accounts from CSV. Complements setup-chart-of-accounts.

    CSV columns: name, root_type, account_number (opt), account_type (opt),
    parent_name (opt), currency (opt), is_group (opt).
    """
    csv_path = args.csv_path
    company_id = args.company_id
    if not csv_path:
        err("--csv-path is required")
    if not company_id:
        err("--company-id is required")

    # Path safety: resolve symlinks, require .csv extension, must be a regular file
    csv_real = os.path.realpath(csv_path)
    if not csv_real.lower().endswith(".csv"):
        err("--csv-path must point to a .csv file")
    if not os.path.isfile(csv_real):
        err(f"File not found: {csv_path}")

    from erpclaw_lib.csv_import import validate_csv, parse_csv_rows

    errors = validate_csv(csv_real, "account")
    if errors:
        err(f"CSV validation failed: {'; '.join(errors)}")

    rows = parse_csv_rows(csv_real, "account")
    if not rows:
        err("CSV file is empty")

    imported = 0
    skipped = 0
    for row in rows:
        name = row.get("name", "")
        root_type = row.get("root_type", "")

        # Check for duplicate
        t_account = Table("account")
        q = (Q.from_(t_account).select(t_account.id)
             .where(t_account.name == P())
             .where(t_account.company_id == P()))
        existing = conn.execute(q.get_sql(), (name, company_id)).fetchone()
        if existing:
            skipped += 1
            continue

        # Resolve parent
        parent_id = None
        parent_name = row.get("parent_name")
        if parent_name:
            parent = conn.execute(q.get_sql(), (parent_name, company_id)).fetchone()
            if parent:
                parent_id = parent["id"]

        is_group = 1 if row.get("is_group", "0") in ("1", "true", "True") else 0
        balance_dir = "debit_normal"
        if root_type in ("liability", "equity", "income"):
            balance_dir = "credit_normal"

        acct_id = str(uuid.uuid4())
        q_ins = (Q.into(t_account)
                 .columns("id", "name", "account_number", "parent_id", "root_type",
                           "account_type", "currency", "is_group", "balance_direction",
                           "company_id", "depth")
                 .insert(P(), P(), P(), P(), P(), P(), P(), P(), P(), P(), 0))
        conn.execute(q_ins.get_sql(),
            (acct_id, name, row.get("account_number"), parent_id, root_type,
             row.get("account_type"), row.get("currency", "USD"),
             is_group, balance_dir, company_id))
        imported += 1

    conn.commit()
    ok({"imported": imported, "skipped": skipped, "total_rows": len(rows)})


# ---------------------------------------------------------------------------
# 27. import-opening-balances
# ---------------------------------------------------------------------------

def import_opening_balances(conn, args):
    """Import opening balances from CSV, posting as opening journal entries.

    CSV columns: account_number, debit, credit, party_type (opt), party_name (opt).
    """
    csv_path = args.csv_path
    company_id = args.company_id
    posting_date = args.posting_date
    if not csv_path:
        err("--csv-path is required")
    if not company_id:
        err("--company-id is required")
    if not posting_date:
        err("--posting-date is required")

    # Path safety: resolve symlinks, require .csv extension, must be a regular file
    csv_real = os.path.realpath(csv_path)
    if not csv_real.lower().endswith(".csv"):
        err("--csv-path must point to a .csv file")
    if not os.path.isfile(csv_real):
        err(f"File not found: {csv_path}")

    from erpclaw_lib.csv_import import validate_csv, parse_csv_rows
    from erpclaw_lib.args import SafeArgumentParser, check_unknown_args

    errors = validate_csv(csv_real, "opening_balance")
    if errors:
        err(f"CSV validation failed: {'; '.join(errors)}")

    rows = parse_csv_rows(csv_real, "opening_balance")
    if not rows:
        err("CSV file is empty")

    # Build GL entries
    t_account = Table("account")
    t_customer = Table("customer")
    t_supplier = Table("supplier")

    gl_entries = []
    for row in rows:
        acct_num = row.get("account_number", "")
        q = (Q.from_(t_account).select(t_account.id, t_account.root_type)
             .where(t_account.account_number == P())
             .where(t_account.company_id == P()))
        acct = conn.execute(q.get_sql(), (acct_num, company_id)).fetchone()
        if not acct:
            err(f"Account not found: {acct_num}")

        debit = row.get("debit", "0")
        credit = row.get("credit", "0")
        entry = {"account_id": acct["id"], "debit": debit, "credit": credit}

        # Handle party for AR/AP
        if row.get("party_type") and row.get("party_name"):
            entry["party_type"] = row["party_type"]
            # Look up party ID
            if row["party_type"] == "customer":
                q_party = (Q.from_(t_customer).select(t_customer.id)
                           .where(t_customer.name == P())
                           .where(t_customer.company_id == P()))
            else:
                q_party = (Q.from_(t_supplier).select(t_supplier.id)
                           .where(t_supplier.name == P())
                           .where(t_supplier.company_id == P()))
            party = conn.execute(q_party.get_sql(), (row["party_name"], company_id)).fetchone()
            if party:
                entry["party_id"] = party["id"]

        gl_entries.append(entry)

    if not gl_entries:
        err("No valid entries found in CSV")

    voucher_id = str(uuid.uuid4())
    try:
        gl_ids = insert_gl_entries(
            conn, gl_entries,
            voucher_type="journal_entry",
            voucher_id=voucher_id,
            posting_date=posting_date,
            company_id=company_id,
            remarks="Opening Balance Import",
            is_opening=True,
        )
    except ValueError as e:
        err(f"GL posting failed: {e}")

    conn.commit()
    ok({"gl_entries_created": len(gl_ids), "voucher_id": voucher_id,
         "rows_processed": len(rows)})


# ---------------------------------------------------------------------------
# Action routing
# ---------------------------------------------------------------------------

ACTIONS = {
    "setup-chart-of-accounts": setup_chart_of_accounts,
    "add-account": add_account,
    "update-account": update_account,
    "list-accounts": list_accounts,
    "get-account": get_account,
    "freeze-account": freeze_account,
    "unfreeze-account": unfreeze_account,
    "post-gl-entries": post_gl_entries,
    "reverse-gl-entries": reverse_gl_entries_action,
    "list-gl-entries": list_gl_entries,
    "add-fiscal-year": add_fiscal_year,
    "list-fiscal-years": list_fiscal_years,
    "validate-period-close": validate_period_close,
    "close-fiscal-year": close_fiscal_year,
    "reopen-fiscal-year": reopen_fiscal_year,
    "add-cost-center": add_cost_center,
    "list-cost-centers": list_cost_centers,
    "add-budget": add_budget,
    "list-budgets": list_budgets,
    "seed-naming-series": seed_naming_series,
    "next-series": next_series,
    "check-gl-integrity": check_gl_integrity,
    "get-account-balance": get_account_balance_action,
    "revalue-foreign-balances": revalue_foreign_balances,
    "import-chart-of-accounts": import_chart_of_accounts,
    "import-opening-balances": import_opening_balances,
    "status": status,
}


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main():
    parser = SafeArgumentParser(description="ERPClaw GL Skill")
    parser.add_argument("--action", required=True, choices=sorted(ACTIONS.keys()))
    parser.add_argument("--db-path", default=None)

    # Chart of accounts
    parser.add_argument("--template", default=None)
    parser.add_argument("--company-id", default=None)

    # Account
    parser.add_argument("--account-id", default=None)
    parser.add_argument("--name", default=None)
    parser.add_argument("--account-number", default=None)
    parser.add_argument("--parent-id", default=None)
    parser.add_argument("--root-type", default=None)
    parser.add_argument("--account-type", default=None)
    parser.add_argument("--currency", default=None)
    parser.add_argument("--is-group", action="store_true", default=False)
    parser.add_argument("--is-frozen", default=None, type=lambda x: x.lower() == "true")
    parser.add_argument("--include-frozen", action="store_true", default=False)
    parser.add_argument("--search", default=None)

    # GL entries
    parser.add_argument("--voucher-type", default=None)
    parser.add_argument("--voucher-id", default=None)
    parser.add_argument("--posting-date", default=None)
    parser.add_argument("--entries", default=None)
    parser.add_argument("--is-cancelled", default=None, type=lambda x: x.lower() == "true")
    parser.add_argument("--party-type", default=None)
    parser.add_argument("--party-id", default=None)

    # Fiscal year
    parser.add_argument("--start-date", default=None)
    parser.add_argument("--end-date", default=None)
    parser.add_argument("--fiscal-year-id", default=None)
    parser.add_argument("--closing-account-id", default=None)

    # Budget
    parser.add_argument("--budget-amount", default=None)
    parser.add_argument("--action-if-exceeded", default=None)
    parser.add_argument("--cost-center-id", default=None)

    # Naming series
    parser.add_argument("--entity-type", default=None)

    # Balance / dates
    parser.add_argument("--as-of-date", default=None)
    parser.add_argument("--from-date", default=None)
    parser.add_argument("--to-date", default=None)
    parser.add_argument("--limit", type=int, default=None)
    parser.add_argument("--offset", type=int, default=None)

    # CSV import
    parser.add_argument("--csv-path", default=None)

    args, unknown = parser.parse_known_args()
    check_unknown_args(parser, unknown)
    check_input_lengths(args)

    db_path = args.db_path or DEFAULT_DB_PATH
    ensure_db_exists(db_path)
    conn = get_connection(db_path)

    # Dependency check
    _dep = check_required_tables(conn, REQUIRED_TABLES)
    if _dep:
        _dep["suggestion"] = "clawhub install " + " ".join(_dep.get("missing_skills", []))
        print(json.dumps(_dep, indent=2))
        conn.close()
        sys.exit(1)

    try:
        ACTIONS[args.action](conn, args)
    except Exception as e:
        err(str(e))
    finally:
        conn.close()


if __name__ == "__main__":
    main()


path: scripts/erpclaw-setup/lib/erpclaw_lib/db.py
content: """Database connection helper for ERPClaw.

Provides a standard way to get a SQLite connection with the correct
PRAGMAs (WAL mode, FK enforcement, busy timeout) applied.
"""
import os
import sqlite3
import stat
from decimal import Decimal


DEFAULT_DB_PATH = os.path.expanduser("~/.openclaw/erpclaw/data.sqlite")


def get_dialect():
    """Return the configured database dialect."""
    return os.environ.get("ERPCLAW_DB_DIALECT", "sqlite")


def setup_pragmas(conn):
    """Apply vendor-specific connection settings.

    For SQLite: WAL mode, FK enforcement, busy timeout.
    For PostgreSQL: lock timeout (via cursor for psycopg2 compatibility).
    For MySQL: no equivalent needed (InnoDB handles these).
    """
    dialect = get_dialect()
    if dialect == "sqlite":
        conn.execute("PRAGMA journal_mode=WAL")
        conn.execute("PRAGMA foreign_keys=ON")
        conn.execute("PRAGMA busy_timeout=5000")
    elif dialect == "postgresql":
        cur = conn.cursor() if hasattr(conn, 'cursor') else conn
        try:
            cur.execute("SET lock_timeout = '5s'")
        finally:
            if cur is not conn:
                cur.close()


class _DecimalSum:
    """Custom SQLite aggregate: SUM using Python Decimal for precision.

    SQLite's built-in SUM uses IEEE 754 float, which can lose precision
    on financial amounts stored as TEXT. This aggregate sums values using
    Python's Decimal type and returns the result as TEXT.

    Usage in SQL: decimal_sum(column) instead of SUM(CAST(column AS REAL))
    """

    def __init__(self):
        self.total = Decimal("0")

    def step(self, value):
        if value is not None:
            self.total += Decimal(str(value))

    def finalize(self):
        return str(self.total)


class ConnectionWrapper:
    """Wrapper around sqlite3.Connection that allows setting custom attributes.

    Python 3.12+ disallows setting arbitrary attributes on sqlite3.Connection.
    This wrapper delegates all sqlite3 methods to the underlying connection
    while allowing custom attributes (e.g., conn.company_id) that ERPClaw
    skills use for naming series resolution.
    """

    def __init__(self, conn: sqlite3.Connection):
        object.__setattr__(self, "_conn", conn)

    def __getattr__(self, name):
        return getattr(self._conn, name)

    def __setattr__(self, name, value):
        try:
            setattr(self._conn, name, value)
        except AttributeError:
            object.__setattr__(self, name, value)

    def __enter__(self):
        self._conn.__enter__()
        return self

    def __exit__(self, *args):
        return self._conn.__exit__(*args)

    def __call__(self, *args, **kwargs):
        return self._conn(*args, **kwargs)


def get_connection(db_path=None) -> "ConnectionWrapper":
    """Get a SQLite connection with ERPClaw standard PRAGMAs.

    Creates the parent directory if it doesn't exist.
    Applies:
      - PRAGMA journal_mode=WAL  (concurrent reads during writes)
      - PRAGMA foreign_keys=ON   (enforce FK constraints)
      - PRAGMA busy_timeout=5000 (wait 5s on lock contention)

    Args:
        db_path: Path to the SQLite database file.
                 Defaults to ~/.openclaw/erpclaw/data.sqlite.
                 Also checks ERPCLAW_DB_PATH environment variable.

    Returns:
        ConnectionWrapper around sqlite3.Connection with row_factory=sqlite3.Row.
    """
    path = db_path or os.environ.get("ERPCLAW_DB_PATH", DEFAULT_DB_PATH)
    ensure_db_exists(path)
    is_new = not os.path.exists(path)
    conn = sqlite3.connect(path)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA foreign_keys=ON")
    conn.execute("PRAGMA busy_timeout=5000")
    conn.create_aggregate("decimal_sum", 1, _DecimalSum)
    if is_new:
        try:
            os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)  # 0o600
        except OSError:
            pass  # non-fatal on some platforms
    return ConnectionWrapper(conn)


def ensure_db_exists(db_path=None) -> str:
    """Ensure the database directory exists.

    Creates parent directories if needed. Does not create the DB file
    itself — sqlite3.connect() handles that.

    Args:
        db_path: Path to the database file.

    Returns:
        The resolved database path.
    """
    path = db_path or DEFAULT_DB_PATH
    parent = os.path.dirname(path)
    if parent:
        os.makedirs(parent, exist_ok=True)
    return path


path: scripts/erpclaw-setup/lib/erpclaw_lib/audit.py
content: """Shared audit logging for ERPClaw skill scripts.

Replaces the _audit() function that was duplicated (with only the skill
name differing) across all 24 skill db_query.py files.

Usage:
    from erpclaw_lib.audit import audit
    audit(conn, "erpclaw-selling", "add-customer", "customer", cust_id,
          new_values={"name": "Acme"}, description="Created customer")
"""
import json
import os
import uuid


def audit(conn, skill: str, action: str, entity_type: str, entity_id: str,
          old_values=None, new_values=None, description: str = ""):
    """Write an audit log entry.

    Args:
        conn: Active sqlite3 connection (caller manages the transaction).
        skill: Skill name, e.g. 'erpclaw-selling'.
        action: Action that triggered the audit, e.g. 'add-customer'.
        entity_type: Type of entity affected, e.g. 'customer'.
        entity_id: Primary key of the affected entity.
        old_values: Dict of previous values (optional, JSON-serialized).
        new_values: Dict of new values (optional, JSON-serialized).
        description: Human-readable description of the change.
    """
    conn.execute(
        """INSERT INTO audit_log (id, user_id, skill, action, entity_type, entity_id,
           old_values, new_values, description)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
        (
            str(uuid.uuid4()),
            os.environ.get("OPENCLAW_USER"),
            skill,
            action,
            entity_type,
            entity_id,
            json.dumps(old_values) if old_values else None,
            json.dumps(new_values) if new_values else None,
            description,
        ),
    )


path: scripts/erpclaw-setup/lib/erpclaw_lib/__init__.py
content: """ERPClaw shared library — used by all ERPClaw skills."""

__version__ = "0.1.0"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)