diff --git a/nexa_coa_setup/hooks.py b/nexa_coa_setup/hooks.py index 3ddad7bc..0f6028de 100644 --- a/nexa_coa_setup/hooks.py +++ b/nexa_coa_setup/hooks.py @@ -80,22 +80,89 @@ def post_init_hook(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) _lock_fiscal_year_2025(env) _logger.info("nexa_coa_setup: post_init_hook complete") +# 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 + "118100.OLD": "118100", + "118200.OLD": "118300", # PST/QST receivable → QST refund receivable + "118300.OLD": "118100", + "118400": "118100", # 14% HST receivable + "118500": "118100", # 15% HST receivable + # Payable / collected side + "231000": "213100", # GST to pay + "232000": "213500", # PST/QST to pay + "233000": "213100", # HST 13% to pay + "234000": "213100", # HST 14% to pay + "235000": "213100", # HST 15% to pay +} + + +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): - """For each Nexa fiscal position, set up tax substitution from default - '5% GST' to the appropriate provincial tax. + """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 - - Effective map is computed on the fly from both sides. - Idempotent: writes are 'replace' style (6, 0, [ids]) so re-running cleans - up prior runs. + Idempotent: writes are 'replace' style so re-running cleans up prior runs. """ def find_tax(name, use): return env["account.tax"].search( @@ -103,42 +170,67 @@ def _configure_fiscal_position_tax_maps(env): limit=1, ) - GST_5_sale = find_tax("5% GST", "sale") - GST_5_purchase = find_tax("5% GST", "purchase") 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") - # (fp_xmlid, [(source_tax, destination_tax), ...]) + # 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", [ - (GST_5_sale, HST_13_sale), - (GST_5_purchase, HST_13_purchase), + (HST_13_sale, HST_13_sale), + (HST_13_purchase, HST_13_purchase), ]), ("nexa_coa_setup.fp_ca_atlantic", [ - (GST_5_sale, HST_15_sale), - (GST_5_purchase, HST_15_purchase), + (HST_13_sale, HST_15_sale), + (HST_13_purchase, HST_15_purchase), ]), ("nexa_coa_setup.fp_ca_quebec", [ - (GST_5_sale, QST_sale), - (GST_5_purchase, QST_purchase), + (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), ]), - # CA-BC and CA-Prairies/Territories: default is already 5% GST, no substitution - ("nexa_coa_setup.fp_ca_bc", []), - ("nexa_coa_setup.fp_ca_prairies_territories", []), ("nexa_coa_setup.fp_export_us", [ - (GST_5_sale, ZERO_sale), + (HST_13_sale, ZERO_sale), ]), ("nexa_coa_setup.fp_export_intl", [ - (GST_5_sale, ZERO_sale), + (HST_13_sale, ZERO_sale), ]), ("nexa_coa_setup.fp_tax_exempt", [ - (GST_5_sale, ZERO_sale), + (HST_13_sale, ZERO_sale), ]), ] @@ -148,16 +240,16 @@ def _configure_fiscal_position_tax_maps(env): if not fp: _logger.warning("nexa_coa_setup: fiscal position not found: %s", fp_xmlid) continue - # Clear existing destination taxes on the FP fp.tax_ids = [(5, 0, 0)] - # For each (src, dst) pair, add dst to FP's tax_ids and src to dst's - # original_tax_ids for src, dst in pairs: - if not src or not dst or src == dst: + 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)] - existing_sources = dst.original_tax_ids.ids - if src.id not in existing_sources: + # 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) diff --git a/nexa_coa_setup/scripts/test_invoices.py b/nexa_coa_setup/scripts/test_invoices.py new file mode 100644 index 00000000..602386ef --- /dev/null +++ b/nexa_coa_setup/scripts/test_invoices.py @@ -0,0 +1,59 @@ +"""Test invoices using Form to emulate UI onchange behavior.""" +from odoo.tests.common import Form +import json + +results = [] + +def test_invoice(label, partner_vals, product_name, product_cat_xmlid, price=100.0): + env.cr.execute("SAVEPOINT test") + try: + partner = env['res.partner'].search([('name', '=', partner_vals['name'])], limit=1) + if not partner: + partner = env['res.partner'].create(partner_vals) + cat = env.ref(product_cat_xmlid) + product = env['product.product'].search([('name', '=', product_name)], limit=1) + if not product: + product = env['product.product'].create({ + 'name': product_name, 'type': 'service', + 'list_price': price, 'categ_id': cat.id, + }) + with Form(env['account.move'].with_context(default_move_type='out_invoice')) as inv_form: + inv_form.partner_id = partner + with inv_form.invoice_line_ids.new() as line: + line.product_id = product + line.quantity = 1 + line.price_unit = price + inv = inv_form.save() + line = inv.invoice_line_ids[0] + results.append({ + 'label': label, + 'fiscal_position': inv.fiscal_position_id.name or '(none)', + 'taxes': line.tax_ids.mapped('name'), + 'income_account': f"{line.account_id.code} {line.account_id.name}", + 'subtotal': inv.amount_untaxed, + 'tax_total': inv.amount_tax, + 'total': inv.amount_total, + 'partner_tags': [c.name for c in partner.category_id], + }) + finally: + env.cr.execute("ROLLBACK TO SAVEPOINT test") + +test_invoice("Ontario (HST 13%)", + {'name': 'TEST CUST ON', 'country_id': env.ref('base.ca').id, 'state_id': env.ref('base.state_ca_on').id, 'customer_rank': 1}, + 'TEST SaaS', 'nexa_coa_setup.pc_saas', 100.0) + +test_invoice("US (Zero-rated)", + {'name': 'TEST CUST US', 'country_id': env.ref('base.us').id, 'customer_rank': 1}, + 'TEST SaaS', 'nexa_coa_setup.pc_saas', 100.0) + +test_invoice("Quebec (GST+QST)", + {'name': 'TEST CUST QC', 'country_id': env.ref('base.ca').id, 'state_id': env.ref('base.state_ca_qc').id, 'customer_rank': 1}, + 'TEST SaaS', 'nexa_coa_setup.pc_saas', 100.0) + +test_invoice("Intercompany -> Westin", + {'name': 'Westin Healthcare Inc'}, + 'TEST Consulting', 'nexa_coa_setup.pc_consulting', 150.0) + +for r in results: + print("---") + print(json.dumps(r, indent=2, default=str))