# -*- coding: utf-8 -*- import logging _logger = logging.getLogger(__name__) # l10n_ca account codes that collide with the Nexa CoA design. Each is # checked at pre_init: if it has zero postings we suffix its code with # '.OLD' and archive it so our XML can claim the code. # Codes with postings are LEFT ALONE — we renumbered the Nexa account. # Currently 115100 stays as l10n_ca 'Customers Account' (240 postings, AR # control) — Nexa shareholder receivable sits at 115200 instead. _L10N_CA_COLLISION_CODES = [ "118100", "118200", "118300", "213100", "214100", "221200", "311100", "311200", "311300", "411100", "411200", "411300", "413100", "413200", "413300", "511100", "511110", "511120", "511130", "511140", "511200", "511210", "512100", "512110", "512200", "611100", "611200", "611300", "612100", "612200", ] # Codes that MUST be cleared even if they have postings (force-suffix to .OLD). # Use sparingly — historical reports lose the original name. Only for codes # where the Nexa account at that code is the canonical one going forward and # any prior posting is a misclassification the user will re-class later. _L10N_CA_FORCE_CLEAR_CODES = {"511100"} def pre_init_hook(env): """Run BEFORE XML data is loaded. Clear l10n_ca account codes that would collide with Nexa's chart of accounts.""" _logger.info("nexa_coa_setup: pre_init_hook starting") _clear_l10n_ca_collisions(env) _logger.info("nexa_coa_setup: pre_init_hook complete") def _clear_l10n_ca_collisions(env): """For each colliding code: if it has zero postings, rename to NNNNNN.OLD and set inactive. If it has postings, leave alone (Nexa code was renumbered in the XML to avoid the conflict).""" cleared = 0 kept_with_postings = 0 not_found = 0 for code in _L10N_CA_COLLISION_CODES: acc = env["account.account"].search([("code", "=", code)], limit=1) if not acc: not_found += 1 continue usage = env["account.move.line"].search_count([("account_id", "=", acc.id)]) force = code in _L10N_CA_FORCE_CLEAR_CODES if usage > 0 and not force: _logger.info( "nexa_coa_setup: keeping l10n_ca account %s (%s) — %d postings exist", code, acc.name, usage, ) kept_with_postings += 1 continue new_code = f"{code}.OLD" # Skip if already suffixed (idempotency) if acc.code.endswith(".OLD"): continue acc.write({ "code": new_code, "name": f"(l10n_ca LEGACY) {acc.name or acc.display_name}", "active": False, }) cleared += 1 if force and usage > 0: _logger.info( "nexa_coa_setup: force-cleared %s despite %d postings (in FORCE_CLEAR set)", code, usage, ) _logger.info( "nexa_coa_setup: collision sweep — cleared %d, kept-with-postings %d, not-found %d", cleared, kept_with_postings, not_found, ) def post_init_hook(env): """Imperative one-shot operations after module data is loaded. Each helper is idempotent — safe to re-run on -u. """ _logger.info("nexa_coa_setup: post_init_hook starting") _normalize_company_hst_number(env) _archive_unused_l10n_ca_accounts(env) _rename_legacy_accounts(env) _archive_unused_taxes(env) _migrate_tax_repartition_accounts(env) _configure_fiscal_position_tax_maps(env) _delete_unused_accounts(env) _lock_fiscal_year_2025(env) _logger.info("nexa_coa_setup: post_init_hook complete") def _delete_unused_accounts(env): """Hard-delete every account that's safe to remove. 'Safe' = not owned by nexa_coa_setup AND not referenced by: - account.move.line (posted entries) - account.tax.repartition.line - account.journal default/suspense/profit/loss accounts - account.fiscal.position.account maps - product.category property_account_income_categ_id / property_account_expense_categ_id - product.template property_account_income_id / property_account_expense_id - res.partner property_account_payable_id / property_account_receivable_id - res.company income_currency_exchange_account_id / expense_currency_exchange_account_id / transfer_account_id Properties in Odoo 19 are stored as JSONB (e.g. {"1": 1234}) per company. Tries unlink in batches; if a batch fails, falls back to per-record so the rest still get cleaned. Logs successes and skipped (still-referenced) ids. """ # Collect every account-id we must keep keep = set() # 1. Nexa-owned env.cr.execute( "SELECT res_id FROM ir_model_data WHERE model='account.account' AND module='nexa_coa_setup'" ) keep.update(r[0] for r in env.cr.fetchall()) # 2. Anything with posted move lines env.cr.execute( "SELECT DISTINCT account_id FROM account_move_line WHERE account_id IS NOT NULL" ) keep.update(r[0] for r in env.cr.fetchall()) # 3. Tax repartition lines env.cr.execute( "SELECT DISTINCT account_id FROM account_tax_repartition_line WHERE account_id IS NOT NULL" ) keep.update(r[0] for r in env.cr.fetchall()) # 4. Journal default/suspense/profit/loss accounts env.cr.execute(""" SELECT default_account_id FROM account_journal WHERE default_account_id IS NOT NULL UNION SELECT suspense_account_id FROM account_journal WHERE suspense_account_id IS NOT NULL UNION SELECT profit_account_id FROM account_journal WHERE profit_account_id IS NOT NULL UNION SELECT loss_account_id FROM account_journal WHERE loss_account_id IS NOT NULL """) keep.update(r[0] for r in env.cr.fetchall()) # 5. Fiscal position account substitution maps env.cr.execute( "SELECT account_src_id FROM account_fiscal_position_account WHERE account_src_id IS NOT NULL " "UNION SELECT account_dest_id FROM account_fiscal_position_account WHERE account_dest_id IS NOT NULL" ) keep.update(r[0] for r in env.cr.fetchall()) # 6. JSONB property fields on product_category and product_template for table, col in [ ("product_category", "property_account_income_categ_id"), ("product_category", "property_account_expense_categ_id"), ("product_category", "property_stock_valuation_account_id"), ("product_category", "property_price_difference_account_id"), ("product_template", "property_account_income_id"), ("product_template", "property_account_expense_id"), ]: env.cr.execute( f"SELECT DISTINCT (jsonb_each_text({col})).value::int " f"FROM {table} WHERE {col} IS NOT NULL" ) keep.update(r[0] for r in env.cr.fetchall() if r[0]) # 7. JSONB property fields on res_partner for col in ("property_account_payable_id", "property_account_receivable_id"): env.cr.execute( f"SELECT DISTINCT (jsonb_each_text({col})).value::int " f"FROM res_partner WHERE {col} IS NOT NULL" ) keep.update(r[0] for r in env.cr.fetchall() if r[0]) # 8. Company exchange/transfer accounts (regular int columns, not JSONB) env.cr.execute(""" SELECT income_currency_exchange_account_id FROM res_company WHERE income_currency_exchange_account_id IS NOT NULL UNION SELECT expense_currency_exchange_account_id FROM res_company WHERE expense_currency_exchange_account_id IS NOT NULL UNION SELECT transfer_account_id FROM res_company WHERE transfer_account_id IS NOT NULL UNION SELECT account_default_pos_receivable_account_id FROM res_company WHERE account_default_pos_receivable_account_id IS NOT NULL """) keep.update(r[0] for r in env.cr.fetchall()) # Pick deletion candidates candidates = env["account.account"].with_context(active_test=False).search([ ("id", "not in", list(keep) or [0]), ]) if not candidates: _logger.info("nexa_coa_setup: no accounts to delete") return _logger.info( "nexa_coa_setup: attempting to delete %d unused accounts (keeping %d protected)", len(candidates), len(keep), ) # Try bulk first, fall back to per-record on error deleted = 0 skipped = 0 try: with env.cr.savepoint(): candidates.unlink() deleted = len(candidates) except Exception: # Per-record fallback for acc in candidates: try: with env.cr.savepoint(): acc.unlink() deleted += 1 except Exception as e: _logger.debug( "nexa_coa_setup: cannot delete %s (%s): %s", acc.code, acc.name, e, ) skipped += 1 _logger.info("nexa_coa_setup: deleted %d accounts, skipped %d (still referenced)", deleted, skipped) # Map of legacy l10n_ca account codes (some now suffixed '.OLD', some still # raw codes that were archived) → Nexa consolidated tax accounts. # Used by _migrate_tax_repartition_accounts to repoint tax repartition lines # at the new accounts, so when an invoice creates tax journal items they hit # 118100 (ITC) and 213100 (HST collected) instead of the archived legacy ones. _TAX_REPARTITION_REMAP = { # ITC / receivable side → Nexa 1210 HST/GST ITC Receivable (or 1230 QST refund) "118100.OLD": "1210", "118200.OLD": "1230", "118300.OLD": "1210", "118400": "1210", "118500": "1210", # Payable / collected side → Nexa 2110 HST/GST Collected (or 2120 QST collected) "231000": "2110", "232000": "2120", "233000": "2110", "234000": "2110", "235000": "2110", } def _migrate_tax_repartition_accounts(env): """Repoint account_tax_repartition_line.account_id from legacy l10n_ca accounts to Nexa's consolidated tax accounts (118100 ITC, 213100 HST collected, 213500 QST collected). Without this, posting an invoice that uses an active tax (e.g., 13% HST) fails because the repartition still points at archived accounts. Idempotent: only touches repartition lines whose current account is in the legacy set. """ legacy_codes = list(_TAX_REPARTITION_REMAP.keys()) legacy_accounts = env["account.account"].with_context(active_test=False).search([ ("code", "in", legacy_codes), ]) if not legacy_accounts: _logger.info("nexa_coa_setup: no legacy tax accounts found; repartition migration skipped") return # Build target ID lookup (target accounts must exist and be active) target_ids = {} for legacy_code, target_code in _TAX_REPARTITION_REMAP.items(): target = env["account.account"].search([("code", "=", target_code), ("active", "=", True)], limit=1) if target: target_ids[legacy_code] = target.id migrated = 0 for legacy in legacy_accounts: target_id = target_ids.get(legacy.code) if not target_id: continue rep_lines = env["account.tax.repartition.line"].search([("account_id", "=", legacy.id)]) if rep_lines: rep_lines.write({"account_id": target_id}) migrated += len(rep_lines) _logger.info("nexa_coa_setup: migrated %d tax repartition lines to Nexa accounts", migrated) def _configure_fiscal_position_tax_maps(env): """Configure default Ontario taxes on the company + tax substitution maps on each Nexa fiscal position. Design: Nexa is Ontario-based, so: - Default sale tax = '13% HST' (sale) - Default purchase tax = '13% HST' (purchase) - Fiscal positions substitute OUT to other rates per customer location: ON → no substitution (already 13%) Atlantic → 15% HST Quebec → 14.975% GST+QST BC/Prairies/Territories → 5% GST only US/Export/Exempt → 0% GST Odoo 19 fiscal position model: - account.fiscal.position has tax_ids (M2M) — destination taxes - account.tax has original_tax_ids (M2M) — source taxes it replaces Idempotent: writes are 'replace' style so re-running cleans up prior runs. """ def find_tax(name, use): return env["account.tax"].search( [("name", "=", name), ("type_tax_use", "=", use), ("active", "=", True)], limit=1, ) HST_13_sale = find_tax("13% HST", "sale") HST_13_purchase = find_tax("13% HST", "purchase") HST_15_sale = find_tax("15% HST", "sale") HST_15_purchase = find_tax("15% HST", "purchase") QST_sale = find_tax("14.975% GST+QST", "sale") QST_purchase = find_tax("14.975% GST+QST", "purchase") GST_5_sale = find_tax("5% GST", "sale") GST_5_purchase = find_tax("5% GST", "purchase") ZERO_sale = find_tax("0% GST", "sale") ZERO_purchase = find_tax("0% GST", "purchase") # Set company-default sale/purchase taxes to Ontario HST 13% (the clean # named ones, not the l10n_ca 'HST for sales - 13%' legacy). company = env.ref("base.main_company", raise_if_not_found=False) if company: if HST_13_sale: company.account_sale_tax_id = HST_13_sale.id if HST_13_purchase: company.account_purchase_tax_id = HST_13_purchase.id _logger.info( "nexa_coa_setup: set company default sale tax = %s, purchase tax = %s", HST_13_sale.name if HST_13_sale else "(none)", HST_13_purchase.name if HST_13_purchase else "(none)", ) # Each entry: (fp_xmlid, [(source, destination), ...]) # Note: Odoo 19's account.fiscal.position.map_tax treats EMPTY tax_ids as # "remove ALL taxes" (tax-unit semantics). To get the legacy "pass-through" # behavior we have to put at least one destination tax in tax_ids — even # if no substitution is needed. We use a self-mapping (13% HST → 13% HST) # which is filtered out below as no-op but still populates tax_ids. fp_map = [ # Ontario is the home: pass-through via self-mapping placeholder ("nexa_coa_setup.fp_ca_ontario", [ (HST_13_sale, HST_13_sale), (HST_13_purchase, HST_13_purchase), ]), ("nexa_coa_setup.fp_ca_atlantic", [ (HST_13_sale, HST_15_sale), (HST_13_purchase, HST_15_purchase), ]), ("nexa_coa_setup.fp_ca_quebec", [ (HST_13_sale, QST_sale), (HST_13_purchase, QST_purchase), ]), ("nexa_coa_setup.fp_ca_bc", [ (HST_13_sale, GST_5_sale), (HST_13_purchase, GST_5_purchase), ]), ("nexa_coa_setup.fp_ca_prairies_territories", [ (HST_13_sale, GST_5_sale), (HST_13_purchase, GST_5_purchase), ]), ("nexa_coa_setup.fp_export_us", [ (HST_13_sale, ZERO_sale), ]), ("nexa_coa_setup.fp_export_intl", [ (HST_13_sale, ZERO_sale), ]), ("nexa_coa_setup.fp_tax_exempt", [ (HST_13_sale, ZERO_sale), ]), ] configured = 0 for fp_xmlid, pairs in fp_map: fp = env.ref(fp_xmlid, raise_if_not_found=False) if not fp: _logger.warning("nexa_coa_setup: fiscal position not found: %s", fp_xmlid) continue fp.tax_ids = [(5, 0, 0)] for src, dst in pairs: if not src or not dst: continue # Always add dst to fp.tax_ids so the FP isn't "empty" # (Odoo 19 treats empty tax_ids as remove-all-taxes). fp.tax_ids = [(4, dst.id, 0)] # If src == dst, no original_tax_ids link needed — the map_tax # fallback returns the source unchanged when no mapping is found. if src.id != dst.id and src.id not in dst.original_tax_ids.ids: dst.original_tax_ids = [(4, src.id, 0)] configured += 1 _logger.info("nexa_coa_setup: configured tax maps on %d fiscal positions", configured) # Tax names to keep ACTIVE (covers GST/HST/QST/PST across provinces + zero-rated # export + exempt). Everything else gets archived if it has zero usage on # existing journal entries. _KEEP_TAX_NAMES = { "5% GST", "13% HST", "14% HST", "15% HST", "11% GST+PST SK", "12% GST+PST BC", "12% GST+PST MB", "14.975% GST+QST", "9.975% QST", "7% PST BC", "8% PST MB", "6% PST SK", "5% PST SK", "0% GST", "0% Exempt", "0% Int", } def _archive_unused_taxes(env): """Archive active taxes whose name is NOT in _KEEP_TAX_NAMES AND that have no usage on existing move lines. Preserves audit trail for historical moves; just hides duplicates and unused defaults from the active set. """ keep_names = list(_KEEP_TAX_NAMES) env.cr.execute( """ SELECT t.id FROM account_tax t WHERE t.active = true AND COALESCE(t.name->>'en_US', '') != ALL(%s) AND NOT EXISTS ( SELECT 1 FROM account_move_line_account_tax_rel r WHERE r.account_tax_id = t.id ) """, (keep_names,), ) ids = [r[0] for r in env.cr.fetchall()] if not ids: _logger.info("nexa_coa_setup: no unused taxes to archive") return env["account.tax"].browse(ids).write({"active": False}) _logger.info( "nexa_coa_setup: archived %d unused taxes (kept set: %d names)", len(ids), len(keep_names), ) def _normalize_company_hst_number(env): """Convert '741224877' to '741224877 RT0001' if not already in full form.""" company = env.ref("base.main_company", raise_if_not_found=False) if not company: return vat = (company.partner_id.vat or "").strip() if vat == "741224877": company.partner_id.vat = "741224877 RT0001" _logger.info("nexa_coa_setup: normalized HST# to '741224877 RT0001'") def _archive_unused_l10n_ca_accounts(env): """Archive l10n_ca accounts that have zero postings and don't belong to nexa_coa_setup. Preserves history (active=False, never delete). Idempotent: re-running has no effect on already-archived accounts. """ env.cr.execute( """ SELECT a.id FROM account_account a WHERE a.active = true AND NOT EXISTS ( SELECT 1 FROM account_move_line aml WHERE aml.account_id = a.id ) AND NOT EXISTS ( SELECT 1 FROM ir_model_data d WHERE d.model = 'account.account' AND d.res_id = a.id AND d.module = 'nexa_coa_setup' ) """ ) ids = [r[0] for r in env.cr.fetchall()] if not ids: _logger.info("nexa_coa_setup: no unused accounts to archive") return env["account.account"].browse(ids).write({"active": False}) _logger.info("nexa_coa_setup: archived %d unused accounts", len(ids)) # Legacy accounts (from Gurpreet's prior bookkeeping) to rename + archive. # These all have postings, so we mark them "(LEGACY)" so they stop appearing # in regular dropdowns but their history is preserved for future # accountant-driven reconciliation. _LEGACY_RENAMES = [ # (code, new_name, archive_after) ("1400", "(LEGACY) Transferred to Gurpreet — re-class to 2510", True), ("1505", "(LEGACY) Sent to India — re-class to 6120", True), ("1580", "(LEGACY) Transferred to Westin — Westin is now a partner", True), ("1590", "(LEGACY) Transferred to Divine — Divine is now a partner", True), ("1600", "(LEGACY) Transferred to Manpreet — non-related; archive", True), ("1500", "(LEGACY) Food & Entertainment — re-class to 6720", True), ("1501", "(LEGACY) Office Expenses — re-class to 6250", True), ("411000", "(LEGACY) Inside Sales — re-class to 4xxx specific lines", True), ("412000", "(LEGACY) Harmonized Provinces Sales — handled by tax codes", True), ("413000", "(LEGACY) Non-Harmonized Provinces Sales — handled by tax", True), ("414000", "(LEGACY) International Sales — handled by Zero-rated Export", True), ("12000", "(LEGACY) Abdul & Future Mobility — use partner subledger", True), ("12001", "(LEGACY) MSI Account — use partner subledger", True), ("110010", "(LEGACY) Bank Fee — re-class to 6910", True), ] def _rename_legacy_accounts(env): """Rename + archive the legacy accounts from prior bookkeeping. Idempotent: accounts already prefixed with '(LEGACY)' are skipped. """ renamed = 0 archived = 0 for code, new_name, archive in _LEGACY_RENAMES: # active_test=False so we also rename accounts that were already # archived by _archive_unused_l10n_ca_accounts (e.g., 413000 sales bucket). accs = env["account.account"].with_context(active_test=False).search([("code", "=", code)]) for acc in accs: if (acc.name or "").startswith("(LEGACY)"): continue acc.name = new_name renamed += 1 if archive and acc.active: acc.active = False archived += 1 _logger.info( "nexa_coa_setup: renamed %d legacy accounts, archived %d", renamed, archived, ) def _lock_fiscal_year_2025(env): """Try to set fiscalyear_lock_date = 2025-12-31 on main company. If Odoo blocks the lock because unreconciled bank statement lines or other open items exist in the period, log a clear warning and continue. The user can set the lock manually via Accounting > Configuration > Settings > Lock Dates once those items are cleaned up. """ from datetime import date from odoo.exceptions import RedirectWarning, UserError, ValidationError company = env.ref("base.main_company", raise_if_not_found=False) if not company: return target = date(2025, 12, 31) if company.fiscalyear_lock_date and company.fiscalyear_lock_date >= target: _logger.info("nexa_coa_setup: fiscalyear_lock_date already at or after 2025-12-31") return try: company.fiscalyear_lock_date = target _logger.info("nexa_coa_setup: fiscalyear_lock_date set to 2025-12-31") except (RedirectWarning, UserError, ValidationError) as exc: _logger.warning( "nexa_coa_setup: could not auto-lock fiscal year 2025-12-31. " "Reason: %s. Set the lock manually via Accounting > Configuration > " "Settings > Lock Dates after the unreconciled items in the period " "are cleaned up.", exc, )