Files
Odoo-Modules/docs/superpowers/plans/2026-05-12-nexa-coa-setup.md
gsinghpal c85a9bbf82 docs: nexa_coa_setup implementation plan
Bite-sized task plan to implement the CoA design against odoo-nexa
nexamain database. 12 phases:
0. Safety backup + staging clone
1. Module skeleton (nexa_coa_setup)
2. Chart of accounts (~110 new accounts across 1-6xxxxx)
3. Analytic plans (Project, Department, SR&ED Tag)
4. Hooks for archive-unused / rename-legacy
5. Tax cleanup
6. 8 fiscal positions with auto-detect
7. Service product categories
8. Westin/Divine partner records (RP-Associated tag)
9. 8 bank reconciliation rules
10. End-to-end test invoices (ON, US, intercompany)
11. Apply to production (with explicit GO/NO-GO gate)
12. Operating runbook

Each task has a verify-before / change / verify-after / commit cycle.
Staging clone (nexamain_staging) used for every phase before prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:05:33 -04:00

113 KiB
Raw Permalink Blame History

Nexa Systems — Chart of Accounts Setup Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement the CoA design in docs/superpowers/specs/2026-05-12-nexa-coa-design.md against the live odoo-nexa nexamain database — clean account structure, automated taxes, SR&ED-ready analytic plans, intercompany-aware partner setup.

Architecture: Build a new Odoo module nexa_coa_setup that declares new accounts/taxes/fiscal-positions/analytic-plans via XML data files (declarative, idempotent, version-controlled), and uses post-install Python hooks for imperative one-shot operations (archive unused accounts, rename legacy accounts, normalize HST#, lock fiscal year). Apply to nexamain via standard odoo -i nexa_coa_setup. Take a pg_dump before any destructive step.

Tech Stack:

  • Odoo 19 Enterprise on odoo-nexa (192.168.1.111), Docker container odoo-nexa-app, DB nexamain
  • Postgres 16 (pgvector image) on container odoo-nexa-db
  • Python 3.12 (Odoo's runtime)
  • l10n_ca localization (already loaded — we extend, not replace)
  • Local dev: /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/
  • Server addons path: /opt/odoo/custom-addons/ (bind-mounted as /mnt/extra-addons in container)
  • Deploy: rsync from Mac → odoo-nexa, then docker exec ... odoo -u nexa_coa_setup

Safety rules (read before every destructive step):

  1. Always pg_dump before any phase that archives, renames, or locks data
  2. Never delete an account that has postings — archive (active=False) only
  3. Lock period BEFORE archive sweep so prior years can't be retroactively damaged
  4. Test on a staging clone first for any phase that touches >50 records
  5. Each phase commits independently — no batch commits across phases

File Structure

Create the following under /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/:

nexa_coa_setup/
├── __init__.py                            # imports models, hooks
├── __manifest__.py                        # module metadata + data file list
├── hooks.py                               # post_init_hook for imperative operations
├── data/
│   ├── 01_account_account.xml             # New chart of accounts entries (~70 accounts)
│   ├── 02_account_journal.xml             # New journals (EXP if missing)
│   ├── 03_account_tax.xml                 # Curated tax set (additions if any)
│   ├── 04_account_fiscal_position.xml     # 8 fiscal positions
│   ├── 05_account_analytic_plan.xml       # Project / Department / SR&ED Tag plans
│   ├── 06_account_analytic_account.xml    # Seed analytic accounts (departments, SR&ED tags)
│   ├── 07_product_category.xml            # Service product categories
│   ├── 08_res_partner_category.xml        # 'RP-Associated' partner tag
│   ├── 09_res_partner.xml                 # Westin & Divine partner records
│   └── 10_account_reconcile_model.xml     # Bank reconciliation rules
├── models/
│   └── __init__.py                        # (no custom models needed; placeholder)
├── security/
│   └── ir.model.access.csv                # (empty; no new models)
└── README.md                              # operating runbook

Each XML data file:

  • Uses <odoo><data noupdate="0"> so re-running -u updates them (no noupdate="1")
  • Uses stable XMLIDs prefixed nexa_coa_setup. so future updates can find records
  • Sets forcecreate="True" where appropriate

hooks.py responsibilities (idempotent — safe to re-run):

  • pre_init_hook(env): pg_dump verification reminder; safety bail-out if pre-conditions not met
  • post_init_hook(env): normalize HST# format, archive unused l10n_ca accounts, rename legacy 14xx/15xx accounts, lock fiscal year at 2025-12-31

Phase 0 — Safety, Backup, and Staging Clone

Task 0.1: Take a full pg_dump of nexamain BEFORE anything

Files: none

  • Step 1: Verify the database is reachable and quiet

Run from local Mac:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain -c 'SELECT count(*) FROM account_move;'"

Expected: a single number around 776 (the current entry count).

  • Step 2: pg_dump to a timestamped file on the server

Run:

ssh odoo-nexa "docker exec odoo-nexa-db pg_dump -U odoo -d nexamain -F c -Z 9 -f /tmp/nexamain_pre_coa_$(date +%Y%m%d_%H%M%S).dump && ls -lh /tmp/nexamain_pre_coa_*.dump | tail -1"

Expected: file size in the tens of MB.

  • Step 3: Copy the dump to your Mac for off-server safety

Run:

mkdir -p ~/Backups/odoo-nexa && scp odoo-nexa:/tmp/nexamain_pre_coa_*.dump ~/Backups/odoo-nexa/ && ls -lh ~/Backups/odoo-nexa/ | tail -1

Expected: the dump file on local Mac.

  • Step 4: Document backup location in a NOTE file (one line)

Run:

echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) — pg_dump before nexa_coa_setup install — $(ls -1 ~/Backups/odoo-nexa/nexamain_pre_coa_*.dump | tail -1)" >> ~/Backups/odoo-nexa/RESTORE_LOG.md
cat ~/Backups/odoo-nexa/RESTORE_LOG.md

Expected: log entry visible.

Task 0.2: Create a staging clone for dry-run testing

Files: none — creates DB nexamain_staging

  • Step 1: Drop any existing staging DB and create fresh from dump

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d postgres -c 'DROP DATABASE IF EXISTS nexamain_staging;' && docker exec odoo-nexa-db psql -U odoo -d postgres -c 'CREATE DATABASE nexamain_staging OWNER odoo;'"

Expected: DROP DATABASE then CREATE DATABASE confirmations.

  • Step 2: Restore the dump into staging

Run:

ssh odoo-nexa "DUMP=\$(ls -1t /tmp/nexamain_pre_coa_*.dump | head -1); docker exec odoo-nexa-db pg_restore -U odoo -d nexamain_staging -j 4 \$DUMP 2>&1 | tail -20"

Expected: restore completes; some "constraint already exists" warnings are OK.

  • Step 3: Verify staging matches prod row counts

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c 'SELECT (SELECT count(*) FROM account_account) AS accounts, (SELECT count(*) FROM account_move) AS moves, (SELECT count(*) FROM account_tax) AS taxes;'"

Expected: same counts as prod (~426 accounts, ~776 moves, ~49 active taxes).

  • Step 4: Set staging fiscalyear_lock_date to match prod (none) and commit nothing — staging is a working copy

No git commit; this is operational state.


Phase 1 — Module Skeleton

Task 1.1: Create the nexa_coa_setup module skeleton

Files:

  • Create: nexa_coa_setup/__init__.py

  • Create: nexa_coa_setup/__manifest__.py

  • Create: nexa_coa_setup/hooks.py

  • Create: nexa_coa_setup/models/__init__.py

  • Create: nexa_coa_setup/security/ir.model.access.csv

  • Create: nexa_coa_setup/README.md

  • Step 1: Create module directory tree

Run from local Mac:

cd /Users/gurpreet/Github/Odoo-Modules
mkdir -p nexa_coa_setup/data nexa_coa_setup/models nexa_coa_setup/security
ls nexa_coa_setup/

Expected: data/, models/, security/.

  • Step 2: Write __init__.py

Create file /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/__init__.py with content:

from . import models
from .hooks import post_init_hook
  • Step 3: Write __manifest__.py

Create file /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/__manifest__.py with content:

# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1
{
    "name": "Nexa Systems — Chart of Accounts Setup",
    "version": "19.0.1.0.0",
    "category": "Accounting/Localizations/Chart of Accounts",
    "summary": "Custom CoA, taxes, fiscal positions, analytic plans, and intercompany partner setup for Nexa Systems Inc.",
    "author": "Nexa Systems Inc.",
    "website": "https://nexasystems.ca",
    "license": "OPL-1",
    "depends": [
        "account",
        "account_accountant",
        "l10n_ca",
        "analytic",
        "sale_management",
        "purchase",
        "sale_subscription",
    ],
    "data": [
        "security/ir.model.access.csv",
        "data/01_account_account.xml",
        "data/02_account_journal.xml",
        "data/03_account_tax.xml",
        "data/04_account_fiscal_position.xml",
        "data/05_account_analytic_plan.xml",
        "data/06_account_analytic_account.xml",
        "data/07_product_category.xml",
        "data/08_res_partner_category.xml",
        "data/09_res_partner.xml",
        "data/10_account_reconcile_model.xml",
    ],
    "post_init_hook": "post_init_hook",
    "installable": True,
    "application": False,
    "auto_install": False,
}
  • Step 4: Write models/__init__.py (empty placeholder)

Create file /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/models/__init__.py with content:

# no custom models — placeholder for future extensions
  • Step 5: Write security/ir.model.access.csv (header only — no new models)

Create file /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/security/ir.model.access.csv with content:

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
  • Step 6: Write hooks.py with empty post_init_hook (filled in Phase 4)

Create file /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/hooks.py with content:

# -*- coding: utf-8 -*-
import logging

_logger = logging.getLogger(__name__)


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)
    _lock_fiscal_year_2025(env)
    _logger.info("nexa_coa_setup: post_init_hook complete")


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):
    """Stub — filled in Phase 4. Archives ~370 unused accounts."""
    pass


def _rename_legacy_accounts(env):
    """Stub — filled in Phase 4. Renames the 14xx/15xx legacy accounts."""
    pass


def _lock_fiscal_year_2025(env):
    """Set fiscalyear_lock_date = 2025-12-31 on main company."""
    from datetime import date
    company = env.ref("base.main_company", raise_if_not_found=False)
    if not company:
        return
    target = date(2025, 12, 31)
    if not company.fiscalyear_lock_date or company.fiscalyear_lock_date < target:
        company.fiscalyear_lock_date = target
        _logger.info("nexa_coa_setup: fiscalyear_lock_date set to 2025-12-31")
  • Step 7: Write minimal README.md

Create file /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/README.md with content:

# Nexa Systems — Chart of Accounts Setup

Custom Odoo 19 module that configures the chart of accounts, taxes,
fiscal positions, analytic plans, and partner records for Nexa Systems Inc.

## Install

docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain
-i nexa_coa_setup --no-http --stop-after-init


## Update

docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain
-u nexa_coa_setup --no-http --stop-after-init


## Design reference

See `docs/superpowers/specs/2026-05-12-nexa-coa-design.md`.

## Safety

Always take a pg_dump BEFORE running `-i` or `-u`. See `docs/superpowers/plans/2026-05-12-nexa-coa-setup.md` Phase 0.
  • Step 8: Create empty data files (placeholders, filled in later phases)

Run:

cd /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/data
for f in 01_account_account 02_account_journal 03_account_tax 04_account_fiscal_position \
         05_account_analytic_plan 06_account_analytic_account 07_product_category \
         08_res_partner_category 09_res_partner 10_account_reconcile_model; do
  printf '<?xml version="1.0" encoding="utf-8"?>\n<odoo>\n    <data noupdate="0">\n    </data>\n</odoo>\n' > ${f}.xml
done
ls -la

Expected: 10 empty XML stub files.

  • Step 9: Commit the skeleton

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/
git commit -m "feat(nexa_coa_setup): module skeleton with hooks stub"

Task 1.2: Deploy skeleton to odoo-nexa and install

Files: none (deploys to server)

  • Step 1: rsync the module to the server

Run from Mac:

rsync -avz --delete /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/

Expected: rsync output showing file transfers.

  • Step 2: Install on staging first

Run:

ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -i nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -20"

Expected: Modules loaded. line at end, no traceback.

  • Step 3: Verify install on staging

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT name, state FROM ir_module_module WHERE name = 'nexa_coa_setup';\""

Expected: nexa_coa_setup | installed.

  • Step 4: Verify HST# was normalized on staging

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT vat FROM res_partner WHERE id = (SELECT partner_id FROM res_company WHERE id = 1);\""

Expected: 741224877 RT0001.

  • Step 5: Verify fiscal year lock on staging

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT name, fiscalyear_lock_date FROM res_company WHERE id = 1;\""

Expected: Nexa Systems Inc | 2025-12-31.

  • Step 6: Do NOT install on prod yet — staging only at this stage

The module is just a skeleton; later phases fill it.


Phase 2 — Chart of Accounts (Additions)

Task 2.1: Define new asset accounts (1xxxxx)

Files:

  • Modify: nexa_coa_setup/data/01_account_account.xml

  • Step 1: Write XML for new asset accounts

Replace content of nexa_coa_setup/data/01_account_account.xml with:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="0">

        <!-- ============================================================
             1xxxxx  ASSETS
             ============================================================ -->

        <!-- 115xxx  Due From Shareholder / Associated Corps -->
        <record id="acct_115100" model="account.account">
            <field name="code">115100</field>
            <field name="name">Due From Shareholder — Gurpreet</field>
            <field name="account_type">asset_current</field>
            <field name="reconcile" eval="True"/>
        </record>

        <record id="acct_115900" model="account.account">
            <field name="code">115900</field>
            <field name="name">Due From Associated Corporations</field>
            <field name="account_type">asset_current</field>
            <field name="reconcile" eval="True"/>
        </record>

        <!-- 118xxx  Tax Assets -->
        <record id="acct_118100" model="account.account">
            <field name="code">118100</field>
            <field name="name">HST/GST Input Tax Credit (ITC) Receivable</field>
            <field name="account_type">asset_current</field>
        </record>

        <record id="acct_118200" model="account.account">
            <field name="code">118200</field>
            <field name="name">HST/GST Instalments Paid</field>
            <field name="account_type">asset_current</field>
        </record>

        <record id="acct_118300" model="account.account">
            <field name="code">118300</field>
            <field name="name">QST Input Tax Refund Receivable</field>
            <field name="account_type">asset_current</field>
        </record>

        <!-- 151xxx  Capital Assets — Cost -->
        <record id="acct_151100" model="account.account">
            <field name="code">151100</field>
            <field name="name">Computer Hardware &amp; Equipment (CCA Class 50)</field>
            <field name="account_type">asset_fixed</field>
        </record>

        <record id="acct_151200" model="account.account">
            <field name="code">151200</field>
            <field name="name">Office Furniture &amp; Equipment (CCA Class 8)</field>
            <field name="account_type">asset_fixed</field>
        </record>

        <record id="acct_151300" model="account.account">
            <field name="code">151300</field>
            <field name="name">Vehicles (CCA Class 10/10.1)</field>
            <field name="account_type">asset_fixed</field>
        </record>

        <record id="acct_151400" model="account.account">
            <field name="code">151400</field>
            <field name="name">Leasehold Improvements (CCA Class 13)</field>
            <field name="account_type">asset_fixed</field>
        </record>

        <record id="acct_151500" model="account.account">
            <field name="code">151500</field>
            <field name="name">Acquired Software &amp; Intangibles (CCA Class 14.1)</field>
            <field name="account_type">asset_fixed</field>
        </record>

        <record id="acct_151600" model="account.account">
            <field name="code">151600</field>
            <field name="name">Tools &amp; Small Equipment &lt;$500 (CCA Class 12)</field>
            <field name="account_type">asset_fixed</field>
        </record>

        <!-- 154xxx  Accumulated Depreciation (contra) -->
        <record id="acct_154100" model="account.account">
            <field name="code">154100</field>
            <field name="name">Acc. Depreciation — Computer Hardware</field>
            <field name="account_type">asset_fixed</field>
        </record>

        <record id="acct_154200" model="account.account">
            <field name="code">154200</field>
            <field name="name">Acc. Depreciation — Office Furniture</field>
            <field name="account_type">asset_fixed</field>
        </record>

        <record id="acct_154300" model="account.account">
            <field name="code">154300</field>
            <field name="name">Acc. Depreciation — Vehicles</field>
            <field name="account_type">asset_fixed</field>
        </record>

        <record id="acct_154400" model="account.account">
            <field name="code">154400</field>
            <field name="name">Acc. Depreciation — Leasehold Improvements</field>
            <field name="account_type">asset_fixed</field>
        </record>

        <record id="acct_154500" model="account.account">
            <field name="code">154500</field>
            <field name="name">Acc. Depreciation — Acquired Software</field>
            <field name="account_type">asset_fixed</field>
        </record>

    </data>
</odoo>
  • Step 2: Deploy and update on staging

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -10"

Expected: Modules loaded. no traceback.

  • Step 3: Verify accounts exist on staging

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT code_store->>'1' AS code, name->>'en_US' AS name FROM account_account WHERE code_store->>'1' IN ('115100','115900','118100','118200','118300','151100','151200','151300','151400','151500','151600','154100','154200','154300','154400','154500') ORDER BY code_store->>'1';\""

Expected: 16 rows with correct codes and names.

  • Step 4: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/01_account_account.xml
git commit -m "feat(nexa_coa_setup): asset accounts (115xxx, 118xxx, 151xxx, 154xxx)"

Task 2.2: Define new liability accounts (2xxxxx)

Files:

  • Modify: nexa_coa_setup/data/01_account_account.xml

  • Step 1: Append liability accounts before </data> closing tag

Add the following block just before the </data> line in 01_account_account.xml:

        <!-- ============================================================
             2xxxxx  LIABILITIES
             ============================================================ -->

        <!-- 213xxx  Tax Liabilities -->
        <record id="acct_213100" model="account.account">
            <field name="code">213100</field>
            <field name="name">HST/GST Collected on Sales</field>
            <field name="account_type">liability_current</field>
        </record>

        <record id="acct_213500" model="account.account">
            <field name="code">213500</field>
            <field name="name">QST Collected on Sales</field>
            <field name="account_type">liability_current</field>
        </record>

        <record id="acct_214100" model="account.account">
            <field name="code">214100</field>
            <field name="name">Net HST/GST Payable</field>
            <field name="account_type">liability_current</field>
        </record>

        <!-- 215xxx  Source Deductions Payable (T4 payroll) -->
        <record id="acct_215100" model="account.account">
            <field name="code">215100</field>
            <field name="name">Source Deductions Payable — Federal Tax</field>
            <field name="account_type">liability_current</field>
        </record>

        <record id="acct_215200" model="account.account">
            <field name="code">215200</field>
            <field name="name">Source Deductions Payable — CPP</field>
            <field name="account_type">liability_current</field>
        </record>

        <record id="acct_215300" model="account.account">
            <field name="code">215300</field>
            <field name="name">Source Deductions Payable — EI</field>
            <field name="account_type">liability_current</field>
        </record>

        <!-- 216xxx  Corporate Income Tax -->
        <record id="acct_216100" model="account.account">
            <field name="code">216100</field>
            <field name="name">Corporate Income Tax — Federal Payable</field>
            <field name="account_type">liability_current</field>
        </record>

        <record id="acct_216200" model="account.account">
            <field name="code">216200</field>
            <field name="name">Corporate Income Tax — Provincial Payable</field>
            <field name="account_type">liability_current</field>
        </record>

        <record id="acct_216300" model="account.account">
            <field name="code">216300</field>
            <field name="name">Corporate Tax Instalments Paid</field>
            <field name="account_type">asset_current</field>
        </record>

        <!-- 221xxx  Due To Shareholder -->
        <record id="acct_221100" model="account.account">
            <field name="code">221100</field>
            <field name="name">Due To Shareholder — Gurpreet (short-term)</field>
            <field name="account_type">liability_current</field>
            <field name="reconcile" eval="True"/>
        </record>

        <record id="acct_221200" model="account.account">
            <field name="code">221200</field>
            <field name="name">Shareholder Loan — Gurpreet (long-term)</field>
            <field name="account_type">liability_non_current</field>
            <field name="reconcile" eval="True"/>
        </record>

        <!-- 222xxx  Due To Associated Corps (intercompany loans only) -->
        <record id="acct_222900" model="account.account">
            <field name="code">222900</field>
            <field name="name">Due To Associated Corporations</field>
            <field name="account_type">liability_current</field>
            <field name="reconcile" eval="True"/>
        </record>
  • Step 2: Deploy and update on staging

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -5"

Expected: no errors.

  • Step 3: Verify

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT count(*) FROM account_account WHERE code_store->>'1' IN ('213100','213500','214100','215100','215200','215300','216100','216200','216300','221100','221200','222900');\""

Expected: 12.

  • Step 4: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/01_account_account.xml
git commit -m "feat(nexa_coa_setup): liability accounts (213xxx, 215xxx, 216xxx, 221xxx, 222xxx)"

Task 2.3: Define new equity accounts (3xxxxx)

Files:

  • Modify: nexa_coa_setup/data/01_account_account.xml

  • Step 1: Append equity accounts before </data>

Add this block before </data>:

        <!-- ============================================================
             3xxxxx  EQUITY
             ============================================================ -->

        <record id="acct_311100" model="account.account">
            <field name="code">311100</field>
            <field name="name">Share Capital — Common Shares</field>
            <field name="account_type">equity</field>
        </record>

        <record id="acct_311200" model="account.account">
            <field name="code">311200</field>
            <field name="name">Share Capital — Preferred Shares</field>
            <field name="account_type">equity</field>
        </record>

        <record id="acct_311300" model="account.account">
            <field name="code">311300</field>
            <field name="name">Contributed Surplus</field>
            <field name="account_type">equity</field>
        </record>

        <record id="acct_321100" model="account.account">
            <field name="code">321100</field>
            <field name="name">Retained Earnings — Current Year</field>
            <field name="account_type">equity</field>
        </record>

        <record id="acct_321200" model="account.account">
            <field name="code">321200</field>
            <field name="name">Retained Earnings — Prior Years</field>
            <field name="account_type">equity</field>
        </record>

        <record id="acct_321900" model="account.account">
            <field name="code">321900</field>
            <field name="name">Dividends Declared</field>
            <field name="account_type">equity</field>
        </record>
  • Step 2: Deploy, update, verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT count(*) FROM account_account WHERE code_store->>'1' IN ('311100','311200','311300','321100','321200','321900');\""

Expected: 6.

  • Step 3: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/01_account_account.xml
git commit -m "feat(nexa_coa_setup): equity accounts (311xxx, 321xxx)"

Task 2.4: Define revenue accounts (4xxxxx) — 15 service-line accounts

Files:

  • Modify: nexa_coa_setup/data/01_account_account.xml

  • Step 1: Append revenue accounts before </data>

Add this block before </data>:

        <!-- ============================================================
             4xxxxx  REVENUE  (one account per service line; jurisdiction via tax)
             ============================================================ -->

        <!-- Recurring Revenue -->
        <record id="acct_411100" model="account.account">
            <field name="code">411100</field>
            <field name="name">SaaS Subscription Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_411200" model="account.account">
            <field name="code">411200</field>
            <field name="name">Hosting &amp; Infrastructure Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_411300" model="account.account">
            <field name="code">411300</field>
            <field name="name">Support &amp; Maintenance Contracts Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_411400" model="account.account">
            <field name="code">411400</field>
            <field name="name">Domain/SSL/Renewal Pass-through Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_411500" model="account.account">
            <field name="code">411500</field>
            <field name="name">Setup / Onboarding Fees Revenue</field>
            <field name="account_type">income</field>
        </record>

        <!-- Project Revenue -->
        <record id="acct_412100" model="account.account">
            <field name="code">412100</field>
            <field name="name">Custom Software Development Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_412200" model="account.account">
            <field name="code">412200</field>
            <field name="name">Custom Web Application Development Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_412300" model="account.account">
            <field name="code">412300</field>
            <field name="name">Custom Website Development Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_412400" model="account.account">
            <field name="code">412400</field>
            <field name="name">ERP Implementation &amp; Customization Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_412500" model="account.account">
            <field name="code">412500</field>
            <field name="name">Mobile App Development Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_412600" model="account.account">
            <field name="code">412600</field>
            <field name="name">Business App / Integration Revenue</field>
            <field name="account_type">income</field>
        </record>

        <!-- Services -->
        <record id="acct_413100" model="account.account">
            <field name="code">413100</field>
            <field name="name">Consulting &amp; Advisory Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_413200" model="account.account">
            <field name="code">413200</field>
            <field name="name">Training &amp; Workshops Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_413300" model="account.account">
            <field name="code">413300</field>
            <field name="name">Technical Support — Per-incident / Hourly Revenue</field>
            <field name="account_type">income</field>
        </record>

        <!-- Reseller -->
        <record id="acct_414100" model="account.account">
            <field name="code">414100</field>
            <field name="name">Third-party Software Resale Revenue</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_414200" model="account.account">
            <field name="code">414200</field>
            <field name="name">Hardware Resale Revenue</field>
            <field name="account_type">income</field>
        </record>

        <!-- Adjustments (contra) -->
        <record id="acct_419100" model="account.account">
            <field name="code">419100</field>
            <field name="name">Sales Discounts</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_419200" model="account.account">
            <field name="code">419200</field>
            <field name="name">Sales Returns &amp; Refunds</field>
            <field name="account_type">income</field>
        </record>
        <record id="acct_419300" model="account.account">
            <field name="code">419300</field>
            <field name="name">Bad Debt Recovery</field>
            <field name="account_type">income_other</field>
        </record>
  • Step 2: Deploy, update, verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT count(*) FROM account_account WHERE code_store->>'1' LIKE '41%' AND code_store->>'1' >= '411100';\""

Expected: 19 (5 recurring + 6 project + 3 services + 2 reseller + 3 adjustments).

  • Step 3: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/01_account_account.xml
git commit -m "feat(nexa_coa_setup): revenue accounts (4xxxxx) — 19 service-line accounts"

Task 2.5: Define COGS accounts (5xxxxx) — 16 direct cost accounts

Files:

  • Modify: nexa_coa_setup/data/01_account_account.xml

  • Step 1: Append COGS accounts before </data>

Add this block:

        <!-- ============================================================
             5xxxxx  DIRECT COSTS (COGS)
             ============================================================ -->

        <!-- Infrastructure & Hosting -->
        <record id="acct_511100" model="account.account">
            <field name="code">511100</field>
            <field name="name">Cloud Infrastructure (AWS, Hetzner, OVH, DigitalOcean, Linode)</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_511110" model="account.account">
            <field name="code">511110</field>
            <field name="name">CDN &amp; Edge Services (Cloudflare, Fastly)</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_511120" model="account.account">
            <field name="code">511120</field>
            <field name="name">Backup &amp; Storage Services</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_511130" model="account.account">
            <field name="code">511130</field>
            <field name="name">Database &amp; Backend Services (Supabase, hosted Postgres, Redis)</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_511140" model="account.account">
            <field name="code">511140</field>
            <field name="name">Monitoring &amp; Observability (customer-facing only)</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_511150" model="account.account">
            <field name="code">511150</field>
            <field name="name">SSL Certificates &amp; Domains (wholesale for resale)</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_511160" model="account.account">
            <field name="code">511160</field>
            <field name="name">DNS &amp; Email Hosting (wholesale for resale)</field>
            <field name="account_type">expense_direct_cost</field>
        </record>

        <!-- Third-party APIs -->
        <record id="acct_511200" model="account.account">
            <field name="code">511200</field>
            <field name="name">Third-party API Costs (Twilio, SendGrid, OpenAI)</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_511210" model="account.account">
            <field name="code">511210</field>
            <field name="name">Per-customer Licensing &amp; Royalties</field>
            <field name="account_type">expense_direct_cost</field>
        </record>

        <!-- Project Direct Costs -->
        <record id="acct_512100" model="account.account">
            <field name="code">512100</field>
            <field name="name">Subcontracted Labour — Canadian (T4A) — SR&amp;ED-eligible</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_512110" model="account.account">
            <field name="code">512110</field>
            <field name="name">Subcontracted Labour — Foreign — NOT SR&amp;ED-eligible</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_512200" model="account.account">
            <field name="code">512200</field>
            <field name="name">Project-specific Software &amp; Licenses</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_512300" model="account.account">
            <field name="code">512300</field>
            <field name="name">Project Travel &amp; Onsite (rebilled)</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_512400" model="account.account">
            <field name="code">512400</field>
            <field name="name">Project Hardware (passed through)</field>
            <field name="account_type">expense_direct_cost</field>
        </record>

        <!-- Resold Goods -->
        <record id="acct_513100" model="account.account">
            <field name="code">513100</field>
            <field name="name">Cost of Software Resold</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
        <record id="acct_513200" model="account.account">
            <field name="code">513200</field>
            <field name="name">Cost of Hardware Resold</field>
            <field name="account_type">expense_direct_cost</field>
        </record>

        <!-- Adjustments -->
        <record id="acct_519100" model="account.account">
            <field name="code">519100</field>
            <field name="name">COGS Adjustments / Write-offs</field>
            <field name="account_type">expense_direct_cost</field>
        </record>
  • Step 2: Deploy, update, verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT count(*) FROM account_account WHERE code_store->>'1' LIKE '5%' AND code_store->>'1' < '520000';\""

Expected: 17 (16 COGS + 1 adjustments).

  • Step 3: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/01_account_account.xml
git commit -m "feat(nexa_coa_setup): COGS accounts (5xxxxx) — 17 direct cost accounts"

Task 2.6: Define operating expense accounts (6xxxxx) — 52 accounts

Files:

  • Modify: nexa_coa_setup/data/01_account_account.xml

  • Step 1: Append OpEx accounts before </data>

Add this block:

        <!-- ============================================================
             6xxxxx  OPERATING EXPENSES
             ============================================================ -->

        <!-- 611xxx  Personnel — Internal Staff (T4) -->
        <record id="acct_611100" model="account.account">
            <field name="code">611100</field>
            <field name="name">Salaries &amp; Wages — Development (SR&amp;ED-eligible)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_611200" model="account.account">
            <field name="code">611200</field>
            <field name="name">Salaries &amp; Wages — Sales &amp; Marketing</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_611300" model="account.account">
            <field name="code">611300</field>
            <field name="name">Salaries &amp; Wages — Admin &amp; Operations</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_611400" model="account.account">
            <field name="code">611400</field>
            <field name="name">Salary — Shareholder/Officer (Gurpreet)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_611500" model="account.account">
            <field name="code">611500</field>
            <field name="name">Employer CPP / QPP Contributions</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_611600" model="account.account">
            <field name="code">611600</field>
            <field name="name">Employer EI Premiums</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_611700" model="account.account">
            <field name="code">611700</field>
            <field name="name">Employer Health Tax (EHT/QHST)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_611800" model="account.account">
            <field name="code">611800</field>
            <field name="name">WCB / WSIB Premiums</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_611900" model="account.account">
            <field name="code">611900</field>
            <field name="name">Employee Benefits (health, dental, group)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_611950" model="account.account">
            <field name="code">611950</field>
            <field name="name">Bonuses &amp; Incentives</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_611960" model="account.account">
            <field name="code">611960</field>
            <field name="name">Vacation Pay Accrual</field>
            <field name="account_type">expense</field>
        </record>

        <!-- 612xxx  Personnel — Contract (non-project) -->
        <record id="acct_612100" model="account.account">
            <field name="code">612100</field>
            <field name="name">Contract Labour — Canadian (admin/marketing/freelance)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_612200" model="account.account">
            <field name="code">612200</field>
            <field name="name">Contract Labour — Foreign</field>
            <field name="account_type">expense</field>
        </record>

        <!-- 621xxx  Office & Facilities -->
        <record id="acct_621100" model="account.account">
            <field name="code">621100</field>
            <field name="name">Rent — Commercial Office</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_621200" model="account.account">
            <field name="code">621200</field>
            <field name="name">Home Office — Business Portion</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_621300" model="account.account">
            <field name="code">621300</field>
            <field name="name">Utilities — Commercial</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_621400" model="account.account">
            <field name="code">621400</field>
            <field name="name">Internet &amp; Phone — Business</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_621500" model="account.account">
            <field name="code">621500</field>
            <field name="name">Office Supplies &amp; Consumables</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_621600" model="account.account">
            <field name="code">621600</field>
            <field name="name">Cleaning &amp; Maintenance</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_621700" model="account.account">
            <field name="code">621700</field>
            <field name="name">Office Snacks &amp; Refreshments</field>
            <field name="account_type">expense</field>
        </record>

        <!-- 631xxx  Technology — Operating -->
        <record id="acct_631100" model="account.account">
            <field name="code">631100</field>
            <field name="name">Software — Productivity (M365, Slack, Notion, Linear, GitHub)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_631200" model="account.account">
            <field name="code">631200</field>
            <field name="name">Software — Development Tools (Cursor, Figma, IDEs)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_631300" model="account.account">
            <field name="code">631300</field>
            <field name="name">Software — Internal Infrastructure</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_631400" model="account.account">
            <field name="code">631400</field>
            <field name="name">Software — Security &amp; IT</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_631500" model="account.account">
            <field name="code">631500</field>
            <field name="name">Software — Sales &amp; Marketing</field>
            <field name="account_type">expense</field>
        </record>

        <!-- 641xxx  Marketing & Sales -->
        <record id="acct_641100" model="account.account">
            <field name="code">641100</field>
            <field name="name">Advertising — Digital Ads</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_641200" model="account.account">
            <field name="code">641200</field>
            <field name="name">Advertising — Content / SEO</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_641300" model="account.account">
            <field name="code">641300</field>
            <field name="name">Trade Shows &amp; Conferences</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_641400" model="account.account">
            <field name="code">641400</field>
            <field name="name">Promotional Items / Branded Swag</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_641500" model="account.account">
            <field name="code">641500</field>
            <field name="name">Website — Own (nexasystems.ca)</field>
            <field name="account_type">expense</field>
        </record>

        <!-- 651xxx  Professional Fees -->
        <record id="acct_651100" model="account.account">
            <field name="code">651100</field>
            <field name="name">Legal Fees — General</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_651200" model="account.account">
            <field name="code">651200</field>
            <field name="name">Accounting &amp; Bookkeeping</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_651300" model="account.account">
            <field name="code">651300</field>
            <field name="name">Tax Preparation (T2, T1, GST/HST)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_651400" model="account.account">
            <field name="code">651400</field>
            <field name="name">Business Consulting</field>
            <field name="account_type">expense</field>
        </record>

        <!-- 661xxx  Insurance -->
        <record id="acct_661100" model="account.account">
            <field name="code">661100</field>
            <field name="name">Insurance — Commercial General Liability</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_661200" model="account.account">
            <field name="code">661200</field>
            <field name="name">Insurance — Professional Liability / E&amp;O</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_661300" model="account.account">
            <field name="code">661300</field>
            <field name="name">Insurance — Cyber Liability</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_661400" model="account.account">
            <field name="code">661400</field>
            <field name="name">Insurance — Property</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_661500" model="account.account">
            <field name="code">661500</field>
            <field name="name">Insurance — Directors &amp; Officers</field>
            <field name="account_type">expense</field>
        </record>

        <!-- 671xxx  Travel & Entertainment -->
        <record id="acct_671100" model="account.account">
            <field name="code">671100</field>
            <field name="name">Travel — Flights, Hotels, Ground Transport</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_671200" model="account.account">
            <field name="code">671200</field>
            <field name="name">Meals &amp; Entertainment — 50% Deductible</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_671300" model="account.account">
            <field name="code">671300</field>
            <field name="name">Vehicle — Operating (gas, insurance, repairs, parking)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_671400" model="account.account">
            <field name="code">671400</field>
            <field name="name">Mileage Reimbursement — Personal Vehicle</field>
            <field name="account_type">expense</field>
        </record>

        <!-- 681xxx  Training & Development -->
        <record id="acct_681100" model="account.account">
            <field name="code">681100</field>
            <field name="name">Conferences &amp; Seminars (registration)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_681200" model="account.account">
            <field name="code">681200</field>
            <field name="name">Courses &amp; Certifications</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_681300" model="account.account">
            <field name="code">681300</field>
            <field name="name">Books &amp; Publications</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_681400" model="account.account">
            <field name="code">681400</field>
            <field name="name">Professional Memberships &amp; Dues</field>
            <field name="account_type">expense</field>
        </record>

        <!-- 691xxx  Banking & Finance -->
        <record id="acct_691100" model="account.account">
            <field name="code">691100</field>
            <field name="name">Bank Service Charges</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_691200" model="account.account">
            <field name="code">691200</field>
            <field name="name">Merchant Processing Fees (Stripe, PayPal, Square)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_691300" model="account.account">
            <field name="code">691300</field>
            <field name="name">Wire Transfer &amp; FX Fees</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_691400" model="account.account">
            <field name="code">691400</field>
            <field name="name">Interest Expense — Bank Loans / LOC</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_691500" model="account.account">
            <field name="code">691500</field>
            <field name="name">Interest Expense — Credit Cards</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_691600" model="account.account">
            <field name="code">691600</field>
            <field name="name">Late Payment Penalties — Non-deductible</field>
            <field name="account_type">expense</field>
        </record>

        <!-- 699xxx  Other -->
        <record id="acct_699100" model="account.account">
            <field name="code">699100</field>
            <field name="name">Bad Debt Expense</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_699200" model="account.account">
            <field name="code">699200</field>
            <field name="name">Donations &amp; Sponsorships (deductible)</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_699300" model="account.account">
            <field name="code">699300</field>
            <field name="name">Penalties &amp; Fines — Non-deductible</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_699400" model="account.account">
            <field name="code">699400</field>
            <field name="name">Realized FX Losses</field>
            <field name="account_type">expense</field>
        </record>
        <record id="acct_699500" model="account.account">
            <field name="code">699500</field>
            <field name="name">Depreciation / CCA Expense</field>
            <field name="account_type">expense</field>
        </record>
  • Step 2: Deploy, update, verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT count(*) FROM account_account WHERE code_store->>'1' LIKE '6%' AND code_store->>'1' < '700000';\""

Expected: at least 52 (might be more if l10n_ca already has some in this range).

  • Step 3: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/01_account_account.xml
git commit -m "feat(nexa_coa_setup): operating expense accounts (6xxxxx) — 52 accounts"

Phase 3 — Analytic Plans (the SR&ED engine)

Task 3.1: Create Project / Department / SR&ED Tag analytic plans

Files:

  • Modify: nexa_coa_setup/data/05_account_analytic_plan.xml

  • Step 1: Write the plans XML

Replace content of nexa_coa_setup/data/05_account_analytic_plan.xml with:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="0">

        <record id="plan_project" model="account.analytic.plan">
            <field name="name">Project</field>
            <field name="default_applicability">mandatory</field>
        </record>

        <record id="plan_department" model="account.analytic.plan">
            <field name="name">Department</field>
            <field name="default_applicability">mandatory</field>
        </record>

        <record id="plan_sred_tag" model="account.analytic.plan">
            <field name="name">SR&amp;ED Tag</field>
            <field name="default_applicability">optional</field>
        </record>

    </data>
</odoo>
  • Step 2: Deploy, update, verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT id, name, default_applicability FROM account_analytic_plan ORDER BY id;\""

Expected: at least 3 plans (Project, Department, SR&ED Tag).

  • Step 3: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/05_account_analytic_plan.xml
git commit -m "feat(nexa_coa_setup): analytic plans — Project, Department, SR&ED Tag"

Task 3.2: Seed Department analytic accounts

Files:

  • Modify: nexa_coa_setup/data/06_account_analytic_account.xml

  • Step 1: Write the Department analytic accounts

Replace content of nexa_coa_setup/data/06_account_analytic_account.xml with:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="0">

        <!-- Departments -->
        <record id="aa_dept_dev" model="account.analytic.account">
            <field name="name">Development</field>
            <field name="code">DEPT-DEV</field>
            <field name="plan_id" ref="plan_department"/>
        </record>

        <record id="aa_dept_sales" model="account.analytic.account">
            <field name="name">Sales &amp; Marketing</field>
            <field name="code">DEPT-SALES</field>
            <field name="plan_id" ref="plan_department"/>
        </record>

        <record id="aa_dept_admin" model="account.analytic.account">
            <field name="name">Admin &amp; Operations</field>
            <field name="code">DEPT-ADMIN</field>
            <field name="plan_id" ref="plan_department"/>
        </record>

        <record id="aa_dept_hosting" model="account.analytic.account">
            <field name="name">Hosting Operations</field>
            <field name="code">DEPT-HOSTING</field>
            <field name="plan_id" ref="plan_department"/>
        </record>

        <!-- SR&ED tag values (one analytic account per tag) -->
        <record id="aa_sred_t4_dev" model="account.analytic.account">
            <field name="name">T4 Dev Salary — full proxy</field>
            <field name="code">SRED-T4-DEV-SALARY</field>
            <field name="plan_id" ref="plan_sred_tag"/>
        </record>

        <record id="aa_sred_specified" model="account.analytic.account">
            <field name="name">Specified Employee Salary — 75% cap</field>
            <field name="code">SRED-SPECIFIED-EMPLOYEE</field>
            <field name="plan_id" ref="plan_sred_tag"/>
        </record>

        <record id="aa_sred_contr_ca_arm" model="account.analytic.account">
            <field name="name">Contractor CA Arm's Length — 80% eligible</field>
            <field name="code">SRED-CONTRACTOR-CA-ARM-LENGTH</field>
            <field name="plan_id" ref="plan_sred_tag"/>
        </record>

        <record id="aa_sred_contr_ca_naf" model="account.analytic.account">
            <field name="name">Contractor CA Non-Arm's Length</field>
            <field name="code">SRED-CONTRACTOR-CA-NON-ARM-LENGTH</field>
            <field name="plan_id" ref="plan_sred_tag"/>
        </record>

        <record id="aa_sred_materials" model="account.analytic.account">
            <field name="name">Materials Consumed in R&amp;D</field>
            <field name="code">SRED-MATERIALS-CONSUMED</field>
            <field name="plan_id" ref="plan_sred_tag"/>
        </record>

        <record id="aa_sred_overhead_basis" model="account.analytic.account">
            <field name="name">Overhead Proxy Basis (direct labour basis)</field>
            <field name="code">SRED-OVERHEAD-PROXY-BASIS</field>
            <field name="plan_id" ref="plan_sred_tag"/>
        </record>

        <record id="aa_sred_not_eligible" model="account.analytic.account">
            <field name="name">Not Eligible (default)</field>
            <field name="code">NOT-ELIGIBLE</field>
            <field name="plan_id" ref="plan_sred_tag"/>
        </record>

    </data>
</odoo>
  • Step 2: Deploy, update, verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT a.code, a.name, p.name AS plan FROM account_analytic_account a JOIN account_analytic_plan p ON p.id = a.plan_id WHERE a.code LIKE 'DEPT-%' OR a.code LIKE 'SRED-%' OR a.code = 'NOT-ELIGIBLE' ORDER BY p.name, a.code;\""

Expected: 4 departments + 7 SR&ED tag values = 11 rows.

  • Step 3: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/06_account_analytic_account.xml
git commit -m "feat(nexa_coa_setup): seed analytic accounts — departments + SR&ED tags"

Phase 4 — Hooks: Archive Unused, Rename Legacy

Task 4.1: Implement _archive_unused_l10n_ca_accounts

Files:

  • Modify: nexa_coa_setup/hooks.py

  • Step 1: Replace the stub function

In nexa_coa_setup/hooks.py, replace the _archive_unused_l10n_ca_accounts function with:

def _archive_unused_l10n_ca_accounts(env):
    """Archive l10n_ca accounts that have zero postings.

    We never delete (preserves historical integrity); active=False removes them
    from dropdowns and reports. Idempotent — re-running has no effect.
    """
    # Find accounts with no journal items
    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_to_archive = [r[0] for r in env.cr.fetchall()]
    if not ids_to_archive:
        _logger.info("nexa_coa_setup: no unused accounts to archive")
        return

    accounts = env['account.account'].browse(ids_to_archive)
    accounts.write({'active': False})
    _logger.info("nexa_coa_setup: archived %d unused accounts", len(accounts))
  • Step 2: Deploy, update on staging

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | grep -E 'archived|ERROR' | tail -5"

Expected: log line "nexa_coa_setup: archived N unused accounts" where N is around 300-370.

  • Step 3: Verify on staging

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT (SELECT count(*) FROM account_account WHERE active=true) AS active_now, (SELECT count(*) FROM account_account WHERE active=false) AS archived_now;\""

Expected: active_now around 130-150, archived_now around 280-370.

  • Step 4: Spot-check that all OUR new accounts are still active

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT count(*) FROM account_account a JOIN ir_model_data d ON d.model='account.account' AND d.res_id=a.id WHERE d.module='nexa_coa_setup' AND a.active=false;\""

Expected: 0 (none of ours archived).

  • Step 5: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/hooks.py
git commit -m "feat(nexa_coa_setup): hook to archive unused l10n_ca accounts"

Task 4.2: Implement _rename_legacy_accounts

Files:

  • Modify: nexa_coa_setup/hooks.py

  • Step 1: Replace the stub

In nexa_coa_setup/hooks.py, replace _rename_legacy_accounts with:

def _rename_legacy_accounts(env):
    """Re-map legacy bookkeeping accounts to clean targets, then archive originals.

    We rename rather than delete to preserve audit trail. Each entry has:
      old_code: the code on the existing legacy account
      new_name: the rename target
      archive: if True, set active=False after rename
    """
    legacy_map = [
        # (old_code, new_name, archive_after)
        ("1400",  "(LEGACY) Transferred to Gurpreet — re-class to 221100",        True),
        ("1505",  "(LEGACY) Sent to India — re-class to 612200",                  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 671200",           True),
        ("1501",  "(LEGACY) Office Expenses — re-class to 621500",                True),
        ("411000", "(LEGACY) Inside Sales — re-class to 412xxx 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 691100",                      True),
        ("511100", "(LEGACY) Inside Purchases — re-class to specific 5xxxxx",     True),
    ]

    renamed = 0
    archived = 0
    for old_code, new_name, archive in legacy_map:
        # In Odoo 19 the code lives in jsonb code_store; we search via the code property
        accounts = env['account.account'].search([('code', '=', old_code)])
        if not accounts:
            continue
        # Skip if name already starts with "(LEGACY)" — idempotent
        for acc in accounts:
            if acc.name and acc.name.startswith("(LEGACY)"):
                continue
            acc.name = new_name
            renamed += 1
            if archive:
                acc.active = False
                archived += 1
    _logger.info("nexa_coa_setup: renamed %d legacy accounts, archived %d", renamed, archived)

IMPORTANT NOTE FOR ENGINEER: account 511100 in legacy_map collides with the new COGS account 511100 Cloud Infrastructure we created in Task 2.5. Before deploying, manually verify:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT a.id, a.name->>'en_US' AS name, a.code_store->>'1' AS code, d.module FROM account_account a LEFT JOIN ir_model_data d ON d.model='account.account' AND d.res_id=a.id WHERE a.code_store->>'1' = '511100';\""

If only ONE row exists and it's our new XML record (module=nexa_coa_setup), then the legacy 511100 Inside Purchases was already absorbed/replaced — REMOVE that line from legacy_map before proceeding.

If TWO rows exist (legacy + new), Odoo prevented loading the new one because of code uniqueness. In that case:

  • Manually re-code the legacy 511100 to 511100-LEGACY via odoo-shell first, then re-run -u to create our new account at 511100, then re-add the rename step.

  • Step 2: Resolve the 511100 collision (per the note above)

Run the verify query. Based on result:

  • If only nexa_coa_setup's record exists: edit hooks.py to delete the ("511100", ...) line from legacy_map. Commit the edit. Proceed.

  • If both exist: open an odoo-shell session and re-code the legacy:

    ssh odoo-nexa "docker exec -i odoo-nexa-app odoo shell -c /etc/odoo/odoo.conf -d nexamain_staging --no-http --stop-after-init" <<'PYEOF'
    legacy = env['account.account'].search([('code','=','511100'),('name','ilike','inside purchases')])
    if legacy:
        legacy.code = '511100-LEGACY'
        env.cr.commit()
        print(f"Renamed legacy account {legacy.id} to code 511100-LEGACY")
    PYEOF
    

    Then leave hooks.py as-is.

  • Step 3: Deploy, update on staging

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | grep -E 'renamed|ERROR' | tail -5"

Expected: log line "nexa_coa_setup: renamed N legacy accounts, archived N".

  • Step 4: Verify legacy accounts are renamed and archived

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT code_store->>'1' AS code, name->>'en_US' AS name, active FROM account_account WHERE name->>'en_US' LIKE '(LEGACY)%' ORDER BY code_store->>'1';\""

Expected: 15 rows (or 14 if 511100 was removed) all with active=false and (LEGACY) prefix.

  • Step 5: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/hooks.py
git commit -m "feat(nexa_coa_setup): hook to rename and archive legacy accounts"

Phase 5 — Tax Cleanup

Task 5.1: Archive duplicate / unused taxes

Files:

  • Modify: nexa_coa_setup/hooks.py

  • Step 1: Add a tax-archive helper to hooks.py

Append to nexa_coa_setup/hooks.py:

def _archive_unused_taxes(env):
    """Archive taxes with type_tax_use='none' or with zero usage on existing moves.

    Keep only the curated set listed in KEEP_TAX_NAMES. This is conservative —
    we only archive taxes that have NEVER been used on any move line.
    """
    KEEP_TAX_NAMES = {
        # Sales taxes we keep active
        '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',
        '0% GST', '0% Exempt', '0% Int',
        # Purchase taxes (same names, type_tax_use='purchase')
    }

    # Build the keep ID set: taxes whose name (en_US) is in KEEP_TAX_NAMES, active or not
    env.cr.execute("""
        SELECT id FROM account_tax
        WHERE name->>'en_US' = ANY(%s)
    """, (list(KEEP_TAX_NAMES),))
    keep_ids = {r[0] for r in env.cr.fetchall()}

    # Candidates to archive: active taxes with no usage and NOT in keep set
    env.cr.execute("""
        SELECT t.id
        FROM account_tax t
        WHERE t.active = true
          AND NOT EXISTS (SELECT 1 FROM account_move_line_account_tax_rel r WHERE r.account_tax_id = t.id)
          AND t.id != ALL(%s)
    """, (list(keep_ids) or [0],))
    ids = [r[0] for r in env.cr.fetchall()]
    if ids:
        env['account.tax'].browse(ids).write({'active': False})
    _logger.info("nexa_coa_setup: archived %d unused taxes (kept %d)", len(ids), len(keep_ids))

Then add to post_init_hook body, after _archive_unused_l10n_ca_accounts(env):

    _archive_unused_taxes(env)
  • Step 2: Deploy, update on staging

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | grep -E 'taxes|ERROR' | tail -5"

Expected: log line "nexa_coa_setup: archived N unused taxes (kept M)".

  • Step 3: Verify active tax count

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT type_tax_use, count(*) FROM account_tax WHERE active=true GROUP BY type_tax_use;\""

Expected: a manageable count, ideally 14-25 active taxes across sale and purchase.

  • Step 4: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/hooks.py
git commit -m "feat(nexa_coa_setup): archive duplicate/unused taxes"

Phase 6 — Fiscal Positions

Task 6.1: Define 8 fiscal positions with country/state auto-detection

Files:

  • Modify: nexa_coa_setup/data/04_account_fiscal_position.xml

  • Step 1: Discover the tax IDs we'll reference

Run on staging:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT id, name->>'en_US' AS name, type_tax_use, amount FROM account_tax WHERE active=true ORDER BY type_tax_use, amount, name;\""

Note the IDs of the sales-side taxes we want to map TO from each fiscal position. Save them for the XML.

  • Step 2: Write the fiscal position XML

Replace content of nexa_coa_setup/data/04_account_fiscal_position.xml with:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="0">

        <!-- Helper note: tax substitutions use xmlids from l10n_ca module.
             If those xmlids change between Odoo versions, regenerate this file. -->

        <record id="fp_ca_ontario" model="account.fiscal.position">
            <field name="name">CA — Ontario (Default)</field>
            <field name="auto_apply" eval="True"/>
            <field name="country_id" ref="base.ca"/>
            <field name="state_ids" eval="[(6, 0, [ref('base.state_ca_on')])]"/>
        </record>

        <record id="fp_ca_atlantic" model="account.fiscal.position">
            <field name="name">CA — Atlantic (HST 15%)</field>
            <field name="auto_apply" eval="True"/>
            <field name="country_id" ref="base.ca"/>
            <field name="state_ids" eval="[(6, 0, [ref('base.state_ca_nb'), ref('base.state_ca_ns'), ref('base.state_ca_pe'), ref('base.state_ca_nl')])]"/>
        </record>

        <record id="fp_ca_quebec" model="account.fiscal.position">
            <field name="name">CA — Quebec (GST + QST)</field>
            <field name="auto_apply" eval="True"/>
            <field name="country_id" ref="base.ca"/>
            <field name="state_ids" eval="[(6, 0, [ref('base.state_ca_qc')])]"/>
        </record>

        <record id="fp_ca_bc" model="account.fiscal.position">
            <field name="name">CA — British Columbia (GST 5%, PST per-product)</field>
            <field name="auto_apply" eval="True"/>
            <field name="country_id" ref="base.ca"/>
            <field name="state_ids" eval="[(6, 0, [ref('base.state_ca_bc')])]"/>
        </record>

        <record id="fp_ca_prairies_territories" model="account.fiscal.position">
            <field name="name">CA — Prairies / Territories (GST 5% only)</field>
            <field name="auto_apply" eval="True"/>
            <field name="country_id" ref="base.ca"/>
            <field name="state_ids" eval="[(6, 0, [ref('base.state_ca_ab'), ref('base.state_ca_mb'), ref('base.state_ca_sk'), ref('base.state_ca_yt'), ref('base.state_ca_nt'), ref('base.state_ca_nu')])]"/>
        </record>

        <record id="fp_export_us" model="account.fiscal.position">
            <field name="name">Export — United States (Zero-rated)</field>
            <field name="auto_apply" eval="True"/>
            <field name="country_id" ref="base.us"/>
        </record>

        <record id="fp_export_intl" model="account.fiscal.position">
            <field name="name">Export — International (Zero-rated)</field>
            <field name="auto_apply" eval="False"/>
            <field name="note">Manually applied for non-CA/non-US customers. Auto-apply by country group requires custom rule.</field>
        </record>

        <record id="fp_tax_exempt" model="account.fiscal.position">
            <field name="name">Tax Exempt (cert-holder)</field>
            <field name="auto_apply" eval="False"/>
            <field name="note">Apply manually to customers with valid exemption certificate on file. Document the certificate in the partner's notes.</field>
        </record>

    </data>
</odoo>
  • Step 3: Deploy, update on staging

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT name, auto_apply FROM account_fiscal_position WHERE name LIKE 'CA %' OR name LIKE 'Export %' OR name LIKE 'Tax Exempt%' ORDER BY name;\""

Expected: 8 fiscal positions, first 6 with auto_apply=true (CA + US), last 2 manual.

  • Step 4: Add tax substitutions via odoo-shell

The XML approach for account.fiscal.position.tax lines is verbose and fragile (depends on exact tax IDs from l10n_ca). Use a one-time script:

Create file /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/scripts/configure_fp_tax_maps.py:

"""Configure tax substitutions on fiscal positions.

Default tax on a product is set to "5% GST" (federal-only base).
Each fiscal position substitutes it for the appropriate provincial tax.
"""
env = self.env

def tax(name, use='sale'):
    """Find a single tax by name and type."""
    rec = env['account.tax'].search([
        ('name', '=', name),
        ('type_tax_use', '=', use),
        ('active', '=', True),
    ], limit=1)
    if not rec:
        raise ValueError(f"Tax not found: {name!r} ({use})")
    return rec

# Base "default" tax that products get
GST_5 = tax('5% GST', 'sale')

# Provincial replacements
HST_13 = tax('13% HST', 'sale')
HST_15 = tax('15% HST', 'sale')
QST_GROUP = tax('14.975% GST+QST', 'sale')
ZERO_EXPORT = tax('0% GST', 'sale')  # Zero-rated for export

FP_MAP = [
    # (fp_xmlid, [(from_tax, to_tax)])
    ('nexa_coa_setup.fp_ca_ontario',  [(GST_5, HST_13)]),
    ('nexa_coa_setup.fp_ca_atlantic', [(GST_5, HST_15)]),
    ('nexa_coa_setup.fp_ca_quebec',   [(GST_5, QST_GROUP)]),
    ('nexa_coa_setup.fp_ca_bc',       [(GST_5, GST_5)]),  # GST only; PST handled per-product
    ('nexa_coa_setup.fp_ca_prairies_territories', [(GST_5, GST_5)]),  # GST only
    ('nexa_coa_setup.fp_export_us',   [(GST_5, ZERO_EXPORT)]),
    ('nexa_coa_setup.fp_export_intl', [(GST_5, ZERO_EXPORT)]),
    ('nexa_coa_setup.fp_tax_exempt',  [(GST_5, ZERO_EXPORT)]),
]

for fp_xmlid, pairs in FP_MAP:
    fp = env.ref(fp_xmlid, raise_if_not_found=False)
    if not fp:
        print(f"SKIP missing fiscal position: {fp_xmlid}")
        continue
    # Clear existing tax maps for clean idempotent re-run
    fp.tax_ids.unlink()
    for from_tax, to_tax in pairs:
        env['account.fiscal.position.tax'].create({
            'position_id': fp.id,
            'tax_src_id': from_tax.id,
            'tax_dest_id': to_tax.id,
        })
    print(f"OK {fp_xmlid}: {len(pairs)} mapping(s)")

env.cr.commit()
print("Fiscal position tax maps configured.")

Then run it:

mkdir -p /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/scripts
# (create the file via Write tool as shown above)
rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/scripts/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/scripts/
ssh odoo-nexa "docker exec -i odoo-nexa-app odoo shell -c /etc/odoo/odoo.conf -d nexamain_staging --no-http --stop-after-init < /opt/odoo/custom-addons/nexa_coa_setup/scripts/configure_fp_tax_maps.py 2>&1 | tail -15"

Expected: "OK fp_ca_ontario: 1 mapping(s)" through all 8.

  • Step 5: Verify mappings

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT fp.name AS fiscal_pos, st.name->>'en_US' AS from_tax, dt.name->>'en_US' AS to_tax FROM account_fiscal_position_tax m JOIN account_fiscal_position fp ON fp.id=m.position_id JOIN account_tax st ON st.id=m.tax_src_id JOIN account_tax dt ON dt.id=m.tax_dest_id WHERE fp.name LIKE 'CA %' OR fp.name LIKE 'Export %' OR fp.name LIKE 'Tax Exempt%' ORDER BY fp.name;\""

Expected: 8 rows showing each FP's substitution.

  • Step 6: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/04_account_fiscal_position.xml nexa_coa_setup/scripts/configure_fp_tax_maps.py
git commit -m "feat(nexa_coa_setup): 8 fiscal positions with auto-detect and tax substitutions"

Phase 7 — Product Categories

Task 7.1: Define service product categories with default accounts

Files:

  • Modify: nexa_coa_setup/data/07_product_category.xml

  • Step 1: Write the product category XML

Replace content of nexa_coa_setup/data/07_product_category.xml with:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="0">

        <!-- Parent: Services -->
        <record id="pc_services" model="product.category">
            <field name="name">Services</field>
        </record>

        <!-- Children, each with default income account -->
        <record id="pc_saas" model="product.category">
            <field name="name">SaaS Subscription</field>
            <field name="parent_id" ref="pc_services"/>
            <field name="property_account_income_categ_id" ref="acct_411100"/>
        </record>

        <record id="pc_hosting" model="product.category">
            <field name="name">Hosting</field>
            <field name="parent_id" ref="pc_services"/>
            <field name="property_account_income_categ_id" ref="acct_411200"/>
        </record>

        <record id="pc_support" model="product.category">
            <field name="name">Support Contract</field>
            <field name="parent_id" ref="pc_services"/>
            <field name="property_account_income_categ_id" ref="acct_411300"/>
        </record>

        <record id="pc_setup_fee" model="product.category">
            <field name="name">Setup Fee</field>
            <field name="parent_id" ref="pc_services"/>
            <field name="property_account_income_categ_id" ref="acct_411500"/>
        </record>

        <record id="pc_custom_software" model="product.category">
            <field name="name">Custom Software Development</field>
            <field name="parent_id" ref="pc_services"/>
            <field name="property_account_income_categ_id" ref="acct_412100"/>
        </record>

        <record id="pc_webapp" model="product.category">
            <field name="name">Custom Web App Development</field>
            <field name="parent_id" ref="pc_services"/>
            <field name="property_account_income_categ_id" ref="acct_412200"/>
        </record>

        <record id="pc_website" model="product.category">
            <field name="name">Custom Website Development</field>
            <field name="parent_id" ref="pc_services"/>
            <field name="property_account_income_categ_id" ref="acct_412300"/>
        </record>

        <record id="pc_erp" model="product.category">
            <field name="name">ERP Implementation</field>
            <field name="parent_id" ref="pc_services"/>
            <field name="property_account_income_categ_id" ref="acct_412400"/>
        </record>

        <record id="pc_consulting" model="product.category">
            <field name="name">Consulting &amp; Advisory</field>
            <field name="parent_id" ref="pc_services"/>
            <field name="property_account_income_categ_id" ref="acct_413100"/>
        </record>

        <record id="pc_training" model="product.category">
            <field name="name">Training</field>
            <field name="parent_id" ref="pc_services"/>
            <field name="property_account_income_categ_id" ref="acct_413200"/>
        </record>

        <!-- Parent: Resale -->
        <record id="pc_resale" model="product.category">
            <field name="name">Resale</field>
        </record>

        <record id="pc_resale_software" model="product.category">
            <field name="name">Software Resale</field>
            <field name="parent_id" ref="pc_resale"/>
            <field name="property_account_income_categ_id" ref="acct_414100"/>
            <field name="property_account_expense_categ_id" ref="acct_513100"/>
        </record>

        <record id="pc_resale_hardware" model="product.category">
            <field name="name">Hardware Resale</field>
            <field name="parent_id" ref="pc_resale"/>
            <field name="property_account_income_categ_id" ref="acct_414200"/>
            <field name="property_account_expense_categ_id" ref="acct_513200"/>
        </record>

    </data>
</odoo>
  • Step 2: Deploy, update, verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT pc.complete_name FROM product_category pc JOIN ir_model_data d ON d.model='product.category' AND d.res_id=pc.id WHERE d.module='nexa_coa_setup' ORDER BY pc.complete_name;\""

Expected: ~12 categories under Services/Resale.

  • Step 3: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/07_product_category.xml
git commit -m "feat(nexa_coa_setup): service product categories with default income accounts"

Phase 8 — Partner Setup (Westin, Divine, RP-Associated tag)

Task 8.1: Create RP-Associated partner tag

Files:

  • Modify: nexa_coa_setup/data/08_res_partner_category.xml

  • Step 1: Write the tag XML

Replace content with:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="0">

        <record id="rp_associated_tag" model="res.partner.category">
            <field name="name">RP-Associated</field>
            <field name="color">3</field>
            <field name="parent_id" eval="False"/>
        </record>

    </data>
</odoo>
  • Step 2: Deploy, update, verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT id, name->>'en_US' AS name, color FROM res_partner_category WHERE name->>'en_US' = 'RP-Associated';\""

Expected: 1 row.

Task 8.2: Create Westin Healthcare Inc and Divine Mobility Inc partner records

Files:

  • Modify: nexa_coa_setup/data/09_res_partner.xml

  • Step 1: Write the partner XML

Replace content with:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="0">

        <record id="partner_westin_healthcare" model="res.partner">
            <field name="name">Westin Healthcare Inc</field>
            <field name="is_company" eval="True"/>
            <field name="company_type">company</field>
            <field name="customer_rank">1</field>
            <field name="supplier_rank">1</field>
            <field name="country_id" ref="base.ca"/>
            <field name="state_id" ref="base.state_ca_on"/>
            <field name="category_id" eval="[(6, 0, [ref('rp_associated_tag')])]"/>
            <field name="property_account_position_id" ref="fp_ca_ontario"/>
            <field name="comment">Associated corporation under common control with Nexa Systems Inc (Gurpreet, owner). Intercompany transactions must be priced at fair market value (ITA s.247). Shared SBD limit per ITA s.125(5.1).</field>
        </record>

        <record id="partner_divine_mobility" model="res.partner">
            <field name="name">Divine Mobility Inc</field>
            <field name="is_company" eval="True"/>
            <field name="company_type">company</field>
            <field name="customer_rank">1</field>
            <field name="supplier_rank">1</field>
            <field name="country_id" ref="base.ca"/>
            <field name="state_id" ref="base.state_ca_on"/>
            <field name="category_id" eval="[(6, 0, [ref('rp_associated_tag')])]"/>
            <field name="property_account_position_id" ref="fp_ca_ontario"/>
            <field name="comment">Associated corporation under common control with Nexa Systems Inc (Gurpreet, owner). See Westin Healthcare Inc for compliance notes.</field>
        </record>

    </data>
</odoo>
  • Step 2: Deploy, update, verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT p.name, p.customer_rank, p.supplier_rank, p.is_company, fp.name AS fiscal_pos, array_agg(c.name->>'en_US') AS tags FROM res_partner p LEFT JOIN account_fiscal_position fp ON fp.id=p.property_account_position_id LEFT JOIN res_partner_res_partner_category_rel r ON r.partner_id=p.id LEFT JOIN res_partner_category c ON c.id=r.category_id WHERE p.name IN ('Westin Healthcare Inc', 'Divine Mobility Inc') GROUP BY p.id, p.name, p.customer_rank, p.supplier_rank, p.is_company, fp.name ORDER BY p.name;\""

Expected: 2 rows, both with customer_rank=1, supplier_rank=1, is_company=t, fiscal_pos="CA — Ontario (Default)", tags including 'RP-Associated'.

  • Step 3: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/08_res_partner_category.xml nexa_coa_setup/data/09_res_partner.xml
git commit -m "feat(nexa_coa_setup): Westin and Divine as associated-corp partners"

Phase 9 — Bank Reconciliation Rules

Task 9.1: Define auto-categorization rules for common vendors

Files:

  • Modify: nexa_coa_setup/data/10_account_reconcile_model.xml

  • Step 1: Look up account IDs we'll reference

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT id, code_store->>'1' AS code, name->>'en_US' AS name FROM account_account WHERE code_store->>'1' IN ('511100','511110','631100','631200','641100','691100','691200') ORDER BY code_store->>'1';\""

Note the IDs.

  • Step 2: Write the reconciliation rules XML

Replace content of nexa_coa_setup/data/10_account_reconcile_model.xml with:

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <data noupdate="0">

        <!-- AWS / Amazon Web Services -->
        <record id="rule_aws" model="account.reconcile.model">
            <field name="name">AWS / Amazon Web Services → Cloud Infrastructure</field>
            <field name="rule_type">writeoff_suggestion</field>
            <field name="match_label">contains</field>
            <field name="match_label_param">AMAZON WEB SERVICES</field>
            <field name="line_ids" eval="[(0, 0, {
                'account_id': ref('acct_511100'),
                'amount_type': 'percentage',
                'amount_string': '100',
                'label': 'AWS — Cloud Infrastructure',
            })]"/>
        </record>

        <!-- Hetzner / OVH / DigitalOcean / Linode -->
        <record id="rule_hetzner" model="account.reconcile.model">
            <field name="name">Hetzner → Cloud Infrastructure</field>
            <field name="rule_type">writeoff_suggestion</field>
            <field name="match_label">contains</field>
            <field name="match_label_param">HETZNER</field>
            <field name="line_ids" eval="[(0, 0, {
                'account_id': ref('acct_511100'),
                'amount_type': 'percentage',
                'amount_string': '100',
                'label': 'Hetzner — Cloud Infrastructure',
            })]"/>
        </record>

        <record id="rule_digitalocean" model="account.reconcile.model">
            <field name="name">DigitalOcean → Cloud Infrastructure</field>
            <field name="rule_type">writeoff_suggestion</field>
            <field name="match_label">contains</field>
            <field name="match_label_param">DIGITALOCEAN</field>
            <field name="line_ids" eval="[(0, 0, {
                'account_id': ref('acct_511100'),
                'amount_type': 'percentage',
                'amount_string': '100',
                'label': 'DigitalOcean — Cloud Infrastructure',
            })]"/>
        </record>

        <!-- Cloudflare → CDN -->
        <record id="rule_cloudflare" model="account.reconcile.model">
            <field name="name">Cloudflare → CDN &amp; Edge</field>
            <field name="rule_type">writeoff_suggestion</field>
            <field name="match_label">contains</field>
            <field name="match_label_param">CLOUDFLARE</field>
            <field name="line_ids" eval="[(0, 0, {
                'account_id': ref('acct_511110'),
                'amount_type': 'percentage',
                'amount_string': '100',
                'label': 'Cloudflare — CDN &amp; Edge',
            })]"/>
        </record>

        <!-- GitHub → Dev Tools -->
        <record id="rule_github" model="account.reconcile.model">
            <field name="name">GitHub → Software (Dev Tools)</field>
            <field name="rule_type">writeoff_suggestion</field>
            <field name="match_label">contains</field>
            <field name="match_label_param">GITHUB</field>
            <field name="line_ids" eval="[(0, 0, {
                'account_id': ref('acct_631200'),
                'amount_type': 'percentage',
                'amount_string': '100',
                'label': 'GitHub — Software (Dev Tools)',
            })]"/>
        </record>

        <!-- Microsoft / M365 → Productivity -->
        <record id="rule_microsoft" model="account.reconcile.model">
            <field name="name">Microsoft / M365 → Software (Productivity)</field>
            <field name="rule_type">writeoff_suggestion</field>
            <field name="match_label">contains</field>
            <field name="match_label_param">MICROSOFT</field>
            <field name="line_ids" eval="[(0, 0, {
                'account_id': ref('acct_631100'),
                'amount_type': 'percentage',
                'amount_string': '100',
                'label': 'Microsoft — Software (Productivity)',
            })]"/>
        </record>

        <!-- Stripe fee → Merchant Processing -->
        <record id="rule_stripe_fee" model="account.reconcile.model">
            <field name="name">Stripe fee → Merchant Processing</field>
            <field name="rule_type">writeoff_suggestion</field>
            <field name="match_label">contains</field>
            <field name="match_label_param">STRIPE FEE</field>
            <field name="line_ids" eval="[(0, 0, {
                'account_id': ref('acct_691200'),
                'amount_type': 'percentage',
                'amount_string': '100',
                'label': 'Stripe — Merchant Processing',
            })]"/>
        </record>

        <!-- Google Ads → Advertising Digital -->
        <record id="rule_google_ads" model="account.reconcile.model">
            <field name="name">Google Ads → Advertising (Digital)</field>
            <field name="rule_type">writeoff_suggestion</field>
            <field name="match_label">contains</field>
            <field name="match_label_param">GOOGLE ADS</field>
            <field name="line_ids" eval="[(0, 0, {
                'account_id': ref('acct_641100'),
                'amount_type': 'percentage',
                'amount_string': '100',
                'label': 'Google Ads — Advertising (Digital)',
            })]"/>
        </record>

    </data>
</odoo>
  • Step 3: Deploy, update, verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain_staging -u nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -3"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT name FROM account_reconcile_model WHERE name LIKE '%→%' ORDER BY name;\""

Expected: 8 reconciliation rules visible.

  • Step 4: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/data/10_account_reconcile_model.xml
git commit -m "feat(nexa_coa_setup): 8 bank reconciliation rules for common vendors"

Phase 10 — End-to-End Verification on Staging

Task 10.1: Create test invoice for Ontario customer (HST 13%)

Files: none — test data only

  • Step 1: Create a test ON customer and invoice via odoo-shell

Create /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/scripts/test_invoice_on.py:

env = self.env

# 1. Create or fetch test customer
partner = env['res.partner'].search([('name', '=', 'TEST CUSTOMER ON')], limit=1)
if not partner:
    partner = env['res.partner'].create({
        'name': 'TEST CUSTOMER ON',
        'country_id': env.ref('base.ca').id,
        'state_id': env.ref('base.state_ca_on').id,
        'customer_rank': 1,
    })

# 2. Find or create a SaaS product in SaaS Subscription category
saas_cat = env.ref('nexa_coa_setup.pc_saas')
product = env['product.product'].search([('name', '=', 'TEST SaaS Subscription')], limit=1)
if not product:
    product = env['product.product'].create({
        'name': 'TEST SaaS Subscription',
        'type': 'service',
        'list_price': 100.00,
        'categ_id': saas_cat.id,
    })

# 3. Create invoice
inv = env['account.move'].create({
    'move_type': 'out_invoice',
    'partner_id': partner.id,
    'invoice_line_ids': [(0, 0, {
        'product_id': product.id,
        'quantity': 1,
        'price_unit': 100.00,
    })],
})

print(f"Invoice {inv.name} created")
print(f"  Fiscal position: {inv.fiscal_position_id.name}")
print(f"  Tax on line: {inv.invoice_line_ids[0].tax_ids.mapped('name')}")
print(f"  Subtotal: {inv.amount_untaxed}")
print(f"  Tax: {inv.amount_tax}")
print(f"  Total: {inv.amount_total}")
print(f"  Income account: {inv.invoice_line_ids[0].account_id.code} {inv.invoice_line_ids[0].account_id.name}")

# Don't commit — this is just a test
env.cr.rollback()
print("Test rolled back.")
  • Step 2: Run the test on staging

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec -i odoo-nexa-app odoo shell -c /etc/odoo/odoo.conf -d nexamain_staging --no-http --stop-after-init < /opt/odoo/custom-addons/nexa_coa_setup/scripts/test_invoice_on.py 2>&1 | tail -15"

Expected output:

Invoice INV/2026/... created
  Fiscal position: CA — Ontario (Default)
  Tax on line: ['13% HST']
  Subtotal: 100.0
  Tax: 13.0
  Total: 113.0
  Income account: 411100 SaaS Subscription Revenue
Test rolled back.

If tax shows "5% GST" instead of "13% HST" → fiscal position substitution is broken. Stop and debug.

  • Step 3: Commit the test script

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/scripts/test_invoice_on.py
git commit -m "test(nexa_coa_setup): ON customer SaaS invoice — HST 13% mapping"

Task 10.2: Create test invoice for US customer (Zero-rated)

Files: create nexa_coa_setup/scripts/test_invoice_us.py

  • Step 1: Write the script (similar to test_invoice_on.py but with US partner)

Create /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/scripts/test_invoice_us.py:

env = self.env

partner = env['res.partner'].search([('name', '=', 'TEST CUSTOMER US')], limit=1)
if not partner:
    partner = env['res.partner'].create({
        'name': 'TEST CUSTOMER US',
        'country_id': env.ref('base.us').id,
        'customer_rank': 1,
    })

saas_cat = env.ref('nexa_coa_setup.pc_saas')
product = env['product.product'].search([('name', '=', 'TEST SaaS Subscription')], limit=1)
if not product:
    product = env['product.product'].create({
        'name': 'TEST SaaS Subscription',
        'type': 'service',
        'list_price': 100.00,
        'categ_id': saas_cat.id,
    })

inv = env['account.move'].create({
    'move_type': 'out_invoice',
    'partner_id': partner.id,
    'invoice_line_ids': [(0, 0, {
        'product_id': product.id,
        'quantity': 1,
        'price_unit': 100.00,
    })],
})

print(f"Invoice {inv.name} created")
print(f"  Fiscal position: {inv.fiscal_position_id.name}")
print(f"  Tax on line: {inv.invoice_line_ids[0].tax_ids.mapped('name')}")
print(f"  Total: {inv.amount_total}")

env.cr.rollback()
print("Test rolled back.")
  • Step 2: Run and verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec -i odoo-nexa-app odoo shell -c /etc/odoo/odoo.conf -d nexamain_staging --no-http --stop-after-init < /opt/odoo/custom-addons/nexa_coa_setup/scripts/test_invoice_us.py 2>&1 | tail -10"

Expected:

Invoice INV/2026/... created
  Fiscal position: Export — United States (Zero-rated)
  Tax on line: ['0% GST']
  Total: 100.0
Test rolled back.
  • Step 3: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/scripts/test_invoice_us.py
git commit -m "test(nexa_coa_setup): US customer SaaS invoice — Zero-rated export"

Task 10.3: Create test invoice for intercompany (Nexa → Westin)

Files: create nexa_coa_setup/scripts/test_invoice_intercompany.py

  • Step 1: Write the script

Create /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/scripts/test_invoice_intercompany.py:

env = self.env

westin = env.ref('nexa_coa_setup.partner_westin_healthcare')

consulting_cat = env.ref('nexa_coa_setup.pc_consulting')
product = env['product.product'].search([('name', '=', 'TEST Consulting Hour')], limit=1)
if not product:
    product = env['product.product'].create({
        'name': 'TEST Consulting Hour',
        'type': 'service',
        'list_price': 150.00,
        'categ_id': consulting_cat.id,
    })

inv = env['account.move'].create({
    'move_type': 'out_invoice',
    'partner_id': westin.id,
    'invoice_line_ids': [(0, 0, {
        'product_id': product.id,
        'quantity': 10,
        'price_unit': 150.00,
    })],
})

print(f"Invoice {inv.name} to {westin.name}")
print(f"  Fiscal position: {inv.fiscal_position_id.name}")
print(f"  Tax on line: {inv.invoice_line_ids[0].tax_ids.mapped('name')}")
print(f"  Subtotal: {inv.amount_untaxed}")
print(f"  Tax: {inv.amount_tax}")
print(f"  Total: {inv.amount_total}")
print(f"  Income account: {inv.invoice_line_ids[0].account_id.code} {inv.invoice_line_ids[0].account_id.name}")
print(f"  Customer tags: {[c.name for c in westin.category_id]}")

env.cr.rollback()
print("Test rolled back.")
  • Step 2: Run and verify

Run:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
ssh odoo-nexa "docker exec -i odoo-nexa-app odoo shell -c /etc/odoo/odoo.conf -d nexamain_staging --no-http --stop-after-init < /opt/odoo/custom-addons/nexa_coa_setup/scripts/test_invoice_intercompany.py 2>&1 | tail -15"

Expected:

Invoice INV/2026/... to Westin Healthcare Inc
  Fiscal position: CA — Ontario (Default)
  Tax on line: ['13% HST']
  Subtotal: 1500.0
  Tax: 195.0
  Total: 1695.0
  Income account: 413100 Consulting & Advisory Revenue
  Customer tags: ['RP-Associated']
Test rolled back.
  • Step 3: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/scripts/test_invoice_intercompany.py
git commit -m "test(nexa_coa_setup): intercompany Nexa→Westin invoice"

Phase 11 — Apply to Production

Task 11.1: Final pg_dump of prod BEFORE production install

Files: none

  • Step 1: Take a fresh dump

Run:

ssh odoo-nexa "docker exec odoo-nexa-db pg_dump -U odoo -d nexamain -F c -Z 9 -f /tmp/nexamain_prefinal_$(date +%Y%m%d_%H%M%S).dump && ls -lh /tmp/nexamain_prefinal_*.dump | tail -1"
scp odoo-nexa:/tmp/nexamain_prefinal_*.dump ~/Backups/odoo-nexa/
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) — pg_dump pre-final nexa_coa_setup install on prod — $(ls -1 ~/Backups/odoo-nexa/nexamain_prefinal_*.dump | tail -1)" >> ~/Backups/odoo-nexa/RESTORE_LOG.md

Task 11.2: Install nexa_coa_setup on production

Files: none

🚦 GATE — User confirmation required before this task. This is the destructive step that touches the live production database. Confirm:

  • pg_dump from Task 11.1 succeeded and is on local Mac (~/Backups/odoo-nexa/)
  • All staging tests passed (Tasks 10.1, 10.2, 10.3)
  • No outstanding transactions or active users on prod right now (or business is comfortable with brief disruption)

User must explicitly say "proceed with prod install" before continuing.

  • Step 1: Install on nexamain (production)

Run:

ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain -i nexa_coa_setup --no-http --stop-after-init 2>&1 | tail -30"

Expected: Modules loaded. no traceback. Watch for archived N unused accounts and renamed N legacy accounts log lines.

  • Step 2: Verify all checkpoints on production

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain -c \"
SELECT 'HST# format' AS check_name, (SELECT vat FROM res_partner WHERE id = (SELECT partner_id FROM res_company WHERE id = 1)) AS value
UNION ALL SELECT 'Fiscal year lock', (SELECT fiscalyear_lock_date::text FROM res_company WHERE id = 1)
UNION ALL SELECT 'Active accounts', (SELECT count(*)::text FROM account_account WHERE active = true)
UNION ALL SELECT 'Archived accounts', (SELECT count(*)::text FROM account_account WHERE active = false)
UNION ALL SELECT 'Active taxes', (SELECT count(*)::text FROM account_tax WHERE active = true)
UNION ALL SELECT 'Fiscal positions', (SELECT count(*)::text FROM account_fiscal_position WHERE active = true)
UNION ALL SELECT 'Analytic plans', (SELECT count(*)::text FROM account_analytic_plan)
UNION ALL SELECT 'Partner: Westin', (SELECT count(*)::text FROM res_partner WHERE name = 'Westin Healthcare Inc')
UNION ALL SELECT 'Partner: Divine', (SELECT count(*)::text FROM res_partner WHERE name = 'Divine Mobility Inc');
\""

Acceptance check:

  • HST# format: 741224877 RT0001

  • Fiscal year lock: 2025-12-31

  • Active accounts: 130-200 (down from 426)

  • Archived accounts: 280-370

  • Active taxes: ≤ 25

  • Fiscal positions: ≥ 8

  • Analytic plans: ≥ 3

  • Partner: Westin: 1

  • Partner: Divine: 1

  • Step 3: Run all three test invoices on production

ssh odoo-nexa "docker exec -i odoo-nexa-app odoo shell -c /etc/odoo/odoo.conf -d nexamain --no-http --stop-after-init < /opt/odoo/custom-addons/nexa_coa_setup/scripts/test_invoice_on.py 2>&1 | tail -10"
ssh odoo-nexa "docker exec -i odoo-nexa-app odoo shell -c /etc/odoo/odoo.conf -d nexamain --no-http --stop-after-init < /opt/odoo/custom-addons/nexa_coa_setup/scripts/test_invoice_us.py 2>&1 | tail -10"
ssh odoo-nexa "docker exec -i odoo-nexa-app odoo shell -c /etc/odoo/odoo.conf -d nexamain --no-http --stop-after-init < /opt/odoo/custom-addons/nexa_coa_setup/scripts/test_invoice_intercompany.py 2>&1 | tail -10"

Expected: all three produce the same output they did on staging. All are rolled back — no actual test data persists.

Task 11.3: Drop the staging DB

Files: none

  • Step 1: Drop nexamain_staging

Run:

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d postgres -c 'DROP DATABASE IF EXISTS nexamain_staging;'"

Expected: DROP DATABASE.

Task 11.4: Final commit and tag

Files: none

  • Step 1: Tag the deployment

Note

: git push --tags writes to the remote — ask user before running.

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git tag -a nexa_coa_setup-v1.0.0 -m "Initial install of nexa_coa_setup on prod nexamain"
# After user confirms remote push is OK:
git push --tags

Phase 12 — Documentation & Runbook

Task 12.1: Write operating runbook in module README

Files:

  • Modify: nexa_coa_setup/README.md

  • Step 1: Expand README with operating procedures

Replace nexa_coa_setup/README.md content with:

# Nexa Systems — Chart of Accounts Setup

Custom Odoo 19 module that configures the chart of accounts, taxes,
fiscal positions, analytic plans, and partner records for Nexa Systems Inc.

## Design reference

See `docs/superpowers/specs/2026-05-12-nexa-coa-design.md`.

## Initial install

ALWAYS take a pg_dump first:

ssh odoo-nexa "docker exec odoo-nexa-db pg_dump -U odoo -d nexamain -F c -Z 9 -f /tmp/nexamain_$(date +%Y%m%d).dump" scp odoo-nexa:/tmp/nexamain_*.dump ~/Backups/odoo-nexa/


Then install:

ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain -i nexa_coa_setup --no-http --stop-after-init"


## Update (when adding new accounts / taxes / fiscal positions)

1. Edit XML in `data/` or hooks in `hooks.py`
2. Sync to server:

rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/

3. Run -u:

ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain -u nexa_coa_setup --no-http --stop-after-init"


## Adding a new account

1. Append a `<record id="acct_XXXXXX" model="account.account">` to `data/01_account_account.xml` (use the next free code in the appropriate range)
2. If it's a service/COGS account, also add a matching `product.category` in `data/07_product_category.xml`
3. Sync and update (see above)

## Adding a new project (analytic account)

Don't put projects in XML — create via UI as projects are dynamic.

In Odoo: Accounting → Configuration → Analytic Accounting → Analytic Accounts → New
- Name: descriptive (e.g., "Westin ERP Phase 2")
- Code: `PRJ-YYYY-{CUST}-{SHORTNAME}`
- Plan: Project

## Yearly tasks

- **Jan**: review CCA classes for new asset purchases; ensure assets created against correct 151xxx account
- **Feb**: prepare T2 with accountant; allocate associated-group SBD via Schedule 23
- **Mar**: HST annual return due (March 31 for Dec 31 year-end)
- **Apr**: review fiscal year lock — set to previous Dec 31 once T2 is filed
- **SepDec**: SR&ED analytic report pull; provide to accountant for T661 prep
- **Dec**: year-end review of intercompany pricing for transfer-pricing compliance

## Restore (if catastrophic)

ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d postgres -c 'DROP DATABASE nexamain;'" ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d postgres -c 'CREATE DATABASE nexamain OWNER odoo;'" ssh odoo-nexa "docker exec odoo-nexa-db pg_restore -U odoo -d nexamain -j 4 /tmp/nexamain_.dump" ssh odoo-nexa "docker restart odoo-nexa-app"

  • Step 2: Commit

Run:

cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/README.md
git commit -m "docs(nexa_coa_setup): operating runbook"

Out of Scope (Future Sub-Projects)

These are intentionally NOT in this plan. Each warrants its own spec + plan cycle:

  1. Bank feeds via Plaid — Odoo Enterprise has Plaid integration; needs credentials, banking partner OAuth, ongoing rule-tuning. Independent from CoA setup.
  2. Historical data reconciliation — once accountant's Excel records arrive, mapping old transactions into the new account structure. Requires careful approach to respect the 2025-12-31 lock; possibly involves un-locking, posting, re-locking.
  3. Custom Canadian CCA module — declining-balance + half-year + AccII automation. Only worth it once asset count >50.
  4. Payroll integration — when first T4 employee is hired; integrate Wagepoint/ADP or Odoo Payroll, drive 215xxx source deductions automatically.
  5. Multi-company Odoo migration — Westin and Divine currently on separate Odoo instances; future consolidation enables auto-mirrored intercompany invoices.
  6. Approval workflows — purchase approval thresholds, expense approval per-department limits.
  7. Multi-currency — USD bank account and currency-rate-live when first US client onboards.
  8. Subscription module configurationsale_subscription is installed; configuring recurring-billing templates per SaaS product, Stripe integration, auto-renewal email cadence is a per-customer flow handled when first SaaS contract is sold.
  9. Bank journal consolidation — Section 10 Phase 4 of the spec calls for auditing the 7 bank journals; this is a manual decision per-account (which to keep, which to archive). Best done with the accountant during the historical reconciliation sub-project.

Self-Review Note

This plan was self-reviewed for:

  • Placeholder scan: no "TBD", "TODO later", or "similar to Task N" — every step contains the actual content needed.
  • Spec coverage: every section of docs/superpowers/specs/2026-05-12-nexa-coa-design.md maps to a phase/task:
    • Section 3 Skeleton → Phase 2 Tasks 2.1-2.6
    • Section 4 Revenue → Phase 2 Task 2.4
    • Section 5 COGS → Phase 2 Task 2.5
    • Section 6 OpEx → Phase 2 Task 2.6
    • Section 7 Capital Assets → Phase 2 Task 2.1 (cost accounts; asset models created on first asset purchase)
    • Section 8 Tax Accounts → Phase 2 Task 2.2 (213xxx) + Phase 5 (cleanup)
    • Section 9 Shareholder/Associated → Phase 2 Task 2.2 (221xxx, 222xxx) + Phase 8 (Westin/Divine partners)
    • Section 10 Analytic Plans → Phase 3
    • Section 11 Tax Setup/Fiscal Positions → Phase 5 + Phase 6
    • Section 12 Cleanup Plan → Phase 4 (archive + rename hooks) + Phase 5 (taxes) + Phase 11 (lock fiscal year via hook)
    • Section 13 Automation Hooks → Phase 7 (product categories) + Phase 9 (bank rec rules)
    • Section 17 Acceptance Criteria → Phase 10 (verification tests) + Phase 11 Task 11.2 (production validation query)
  • Type consistency: all XMLIDs use the acct_/fp_/pc_/aa_/partner_/rule_ prefixes consistently; all account references in Phase 9 (reconcile rules) and Phase 7 (product cats) use account XMLIDs defined in Phase 2.
  • Known potential issues flagged inline: account 511100 collision (Task 4.2 Step 2 has resolution procedure).