Renumbered all 128 Nexa accounts from 6-digit (l10n_ca style) to clean
4-digit codes for readability:
1000-1999 Assets
1120 Due From Shareholder
1210 HST/GST ITC Receivable
1510-1750 Capital assets + accumulated depreciation
2000-2999 Liabilities
2110 HST/GST Collected
2510 Due To Shareholder
3000-3999 Equity
3010 Common Shares
3510 Retained Earnings — Current
4000-4999 Revenue
4010-4050 Recurring (SaaS, Hosting, Support, ...)
4110-4160 Project work
4210-4230 Hourly services
4310-4320 Reseller
5000-5999 COGS
5010-5120 Infrastructure & APIs
5210-5250 Project direct costs
5310-5320 Resold goods
6000-6999 Operating expenses
6010-6092 Personnel (T4)
6110-6120 Contract labour
6210-6960 Office/Tech/Marketing/Professional/Insurance/Travel/Training/Banking
7000+ Other (bad debt, donations, FX, depreciation)
Applied to prod via scripts/convert_to_4digit.py (now committed). XML
codes updated in 01_account_account.xml; XMLIDs preserved so existing
ir.model.data rows on prod stay valid.
Hook constants updated:
- _TAX_REPARTITION_REMAP targets: 118100 -> 1210, 213100 -> 2110, etc.
- _LEGACY_RENAMES new_name strings: 're-class to NNNN' guidance updated
to 4-digit targets.
Verified -u on prod completes cleanly + all 4 test invoices still post:
ON -> 4010 SaaS, total 113.00
US -> 4010 SaaS, total 100.00 (zero-rated)
QC -> 4010 SaaS, total 114.98
Westin -> 4210 Consulting, total 169.50
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
556 lines
22 KiB
Python
556 lines
22 KiB
Python
# -*- 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,
|
|
)
|