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>
113 KiB
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, DBnexamain - 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-addonsin container) - Deploy:
rsyncfrom Mac → odoo-nexa, thendocker exec ... odoo -u nexa_coa_setup
Safety rules (read before every destructive step):
- Always pg_dump before any phase that archives, renames, or locks data
- Never delete an account that has postings — archive (
active=False) only - Lock period BEFORE archive sweep so prior years can't be retroactively damaged
- Test on a staging clone first for any phase that touches >50 records
- 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-uupdates them (nonoupdate="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 metpost_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.pywith 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 & 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 & 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 & 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 & Small Equipment <$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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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&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&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 & 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 & 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 & Wages — Development (SR&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 & Wages — Sales & 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 & Wages — Admin & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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&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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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 & 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&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 & 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 & 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&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-LEGACYvia 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.pyto delete the("511100", ...)line fromlegacy_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") PYEOFThen leave
hooks.pyas-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 & 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 & 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 & 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 --tagswrites 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
- **Sep–Dec**: 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:
- Bank feeds via Plaid — Odoo Enterprise has Plaid integration; needs credentials, banking partner OAuth, ongoing rule-tuning. Independent from CoA setup.
- 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.
- Custom Canadian CCA module — declining-balance + half-year + AccII automation. Only worth it once asset count >50.
- Payroll integration — when first T4 employee is hired; integrate Wagepoint/ADP or Odoo Payroll, drive 215xxx source deductions automatically.
- Multi-company Odoo migration — Westin and Divine currently on separate Odoo instances; future consolidation enables auto-mirrored intercompany invoices.
- Approval workflows — purchase approval thresholds, expense approval per-department limits.
- Multi-currency — USD bank account and currency-rate-live when first US client onboards.
- Subscription module configuration —
sale_subscriptionis 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. - 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.mdmaps 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).