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

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

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

2798 lines
113 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Nexa Systems — Chart of Accounts Setup Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implement the CoA design in `docs/superpowers/specs/2026-05-12-nexa-coa-design.md` against the live odoo-nexa `nexamain` database — clean account structure, automated taxes, SR&ED-ready analytic plans, intercompany-aware partner setup.
**Architecture:** Build a new Odoo module `nexa_coa_setup` that declares new accounts/taxes/fiscal-positions/analytic-plans via XML data files (declarative, idempotent, version-controlled), and uses post-install Python hooks for imperative one-shot operations (archive unused accounts, rename legacy accounts, normalize HST#, lock fiscal year). Apply to nexamain via standard `odoo -i nexa_coa_setup`. Take a pg_dump before any destructive step.
**Tech Stack:**
- Odoo 19 Enterprise on odoo-nexa (192.168.1.111), Docker container `odoo-nexa-app`, DB `nexamain`
- Postgres 16 (pgvector image) on container `odoo-nexa-db`
- Python 3.12 (Odoo's runtime)
- l10n_ca localization (already loaded — we extend, not replace)
- Local dev: `/Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/`
- Server addons path: `/opt/odoo/custom-addons/` (bind-mounted as `/mnt/extra-addons` in container)
- Deploy: `rsync` from Mac → odoo-nexa, then `docker exec ... odoo -u nexa_coa_setup`
**Safety rules (read before every destructive step):**
1. **Always** pg_dump before any phase that archives, renames, or locks data
2. **Never** delete an account that has postings — archive (`active=False`) only
3. **Lock period BEFORE archive sweep** so prior years can't be retroactively damaged
4. **Test on a staging clone first** for any phase that touches >50 records
5. **Each phase commits independently** — no batch commits across phases
---
## File Structure
Create the following under `/Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/`:
```
nexa_coa_setup/
├── __init__.py # imports models, hooks
├── __manifest__.py # module metadata + data file list
├── hooks.py # post_init_hook for imperative operations
├── data/
│ ├── 01_account_account.xml # New chart of accounts entries (~70 accounts)
│ ├── 02_account_journal.xml # New journals (EXP if missing)
│ ├── 03_account_tax.xml # Curated tax set (additions if any)
│ ├── 04_account_fiscal_position.xml # 8 fiscal positions
│ ├── 05_account_analytic_plan.xml # Project / Department / SR&ED Tag plans
│ ├── 06_account_analytic_account.xml # Seed analytic accounts (departments, SR&ED tags)
│ ├── 07_product_category.xml # Service product categories
│ ├── 08_res_partner_category.xml # 'RP-Associated' partner tag
│ ├── 09_res_partner.xml # Westin & Divine partner records
│ └── 10_account_reconcile_model.xml # Bank reconciliation rules
├── models/
│ └── __init__.py # (no custom models needed; placeholder)
├── security/
│ └── ir.model.access.csv # (empty; no new models)
└── README.md # operating runbook
```
**Each XML data file**:
- Uses `<odoo><data noupdate="0">` so re-running `-u` updates them (no `noupdate="1"`)
- Uses stable XMLIDs prefixed `nexa_coa_setup.` so future updates can find records
- Sets `forcecreate="True"` where appropriate
**hooks.py responsibilities** (idempotent — safe to re-run):
- `pre_init_hook(env)`: pg_dump verification reminder; safety bail-out if pre-conditions not met
- `post_init_hook(env)`: normalize HST# format, archive unused l10n_ca accounts, rename legacy 14xx/15xx accounts, lock fiscal year at 2025-12-31
---
## Phase 0 — Safety, Backup, and Staging Clone
### Task 0.1: Take a full pg_dump of nexamain BEFORE anything
**Files:** none
- [ ] **Step 1: Verify the database is reachable and quiet**
Run from local Mac:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```python
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:
```python
# -*- 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:
```python
# 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:
```csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
```
- [ ] **Step 6: Write `hooks.py` with empty post_init_hook (filled in Phase 4)**
Create file `/Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/hooks.py` with content:
```python
# -*- 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:
```markdown
# 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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- ============================================================
1xxxxx ASSETS
============================================================ -->
<!-- 115xxx Due From Shareholder / Associated Corps -->
<record id="acct_115100" model="account.account">
<field name="code">115100</field>
<field name="name">Due From Shareholder — Gurpreet</field>
<field name="account_type">asset_current</field>
<field name="reconcile" eval="True"/>
</record>
<record id="acct_115900" model="account.account">
<field name="code">115900</field>
<field name="name">Due From Associated Corporations</field>
<field name="account_type">asset_current</field>
<field name="reconcile" eval="True"/>
</record>
<!-- 118xxx Tax Assets -->
<record id="acct_118100" model="account.account">
<field name="code">118100</field>
<field name="name">HST/GST Input Tax Credit (ITC) Receivable</field>
<field name="account_type">asset_current</field>
</record>
<record id="acct_118200" model="account.account">
<field name="code">118200</field>
<field name="name">HST/GST Instalments Paid</field>
<field name="account_type">asset_current</field>
</record>
<record id="acct_118300" model="account.account">
<field name="code">118300</field>
<field name="name">QST Input Tax Refund Receivable</field>
<field name="account_type">asset_current</field>
</record>
<!-- 151xxx Capital Assets — Cost -->
<record id="acct_151100" model="account.account">
<field name="code">151100</field>
<field name="name">Computer Hardware &amp; Equipment (CCA Class 50)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_151200" model="account.account">
<field name="code">151200</field>
<field name="name">Office Furniture &amp; Equipment (CCA Class 8)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_151300" model="account.account">
<field name="code">151300</field>
<field name="name">Vehicles (CCA Class 10/10.1)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_151400" model="account.account">
<field name="code">151400</field>
<field name="name">Leasehold Improvements (CCA Class 13)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_151500" model="account.account">
<field name="code">151500</field>
<field name="name">Acquired Software &amp; Intangibles (CCA Class 14.1)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_151600" model="account.account">
<field name="code">151600</field>
<field name="name">Tools &amp; Small Equipment &lt;$500 (CCA Class 12)</field>
<field name="account_type">asset_fixed</field>
</record>
<!-- 154xxx Accumulated Depreciation (contra) -->
<record id="acct_154100" model="account.account">
<field name="code">154100</field>
<field name="name">Acc. Depreciation — Computer Hardware</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_154200" model="account.account">
<field name="code">154200</field>
<field name="name">Acc. Depreciation — Office Furniture</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_154300" model="account.account">
<field name="code">154300</field>
<field name="name">Acc. Depreciation — Vehicles</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_154400" model="account.account">
<field name="code">154400</field>
<field name="name">Acc. Depreciation — Leasehold Improvements</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_154500" model="account.account">
<field name="code">154500</field>
<field name="name">Acc. Depreciation — Acquired Software</field>
<field name="account_type">asset_fixed</field>
</record>
</data>
</odoo>
```
- [ ] **Step 2: Deploy and update on staging**
Run:
```bash
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:
```bash
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:
```bash
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`:
```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:
```bash
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:
```bash
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:
```bash
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>`:
```xml
<!-- ============================================================
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:
```bash
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:
```bash
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>`:
```xml
<!-- ============================================================
4xxxxx REVENUE (one account per service line; jurisdiction via tax)
============================================================ -->
<!-- Recurring Revenue -->
<record id="acct_411100" model="account.account">
<field name="code">411100</field>
<field name="name">SaaS Subscription Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_411200" model="account.account">
<field name="code">411200</field>
<field name="name">Hosting &amp; Infrastructure Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_411300" model="account.account">
<field name="code">411300</field>
<field name="name">Support &amp; Maintenance Contracts Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_411400" model="account.account">
<field name="code">411400</field>
<field name="name">Domain/SSL/Renewal Pass-through Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_411500" model="account.account">
<field name="code">411500</field>
<field name="name">Setup / Onboarding Fees Revenue</field>
<field name="account_type">income</field>
</record>
<!-- Project Revenue -->
<record id="acct_412100" model="account.account">
<field name="code">412100</field>
<field name="name">Custom Software Development Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412200" model="account.account">
<field name="code">412200</field>
<field name="name">Custom Web Application Development Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412300" model="account.account">
<field name="code">412300</field>
<field name="name">Custom Website Development Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412400" model="account.account">
<field name="code">412400</field>
<field name="name">ERP Implementation &amp; Customization Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412500" model="account.account">
<field name="code">412500</field>
<field name="name">Mobile App Development Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412600" model="account.account">
<field name="code">412600</field>
<field name="name">Business App / Integration Revenue</field>
<field name="account_type">income</field>
</record>
<!-- Services -->
<record id="acct_413100" model="account.account">
<field name="code">413100</field>
<field name="name">Consulting &amp; Advisory Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_413200" model="account.account">
<field name="code">413200</field>
<field name="name">Training &amp; Workshops Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_413300" model="account.account">
<field name="code">413300</field>
<field name="name">Technical Support — Per-incident / Hourly Revenue</field>
<field name="account_type">income</field>
</record>
<!-- Reseller -->
<record id="acct_414100" model="account.account">
<field name="code">414100</field>
<field name="name">Third-party Software Resale Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_414200" model="account.account">
<field name="code">414200</field>
<field name="name">Hardware Resale Revenue</field>
<field name="account_type">income</field>
</record>
<!-- Adjustments (contra) -->
<record id="acct_419100" model="account.account">
<field name="code">419100</field>
<field name="name">Sales Discounts</field>
<field name="account_type">income</field>
</record>
<record id="acct_419200" model="account.account">
<field name="code">419200</field>
<field name="name">Sales Returns &amp; Refunds</field>
<field name="account_type">income</field>
</record>
<record id="acct_419300" model="account.account">
<field name="code">419300</field>
<field name="name">Bad Debt Recovery</field>
<field name="account_type">income_other</field>
</record>
```
- [ ] **Step 2: Deploy, update, verify**
Run:
```bash
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:
```bash
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:
```xml
<!-- ============================================================
5xxxxx DIRECT COSTS (COGS)
============================================================ -->
<!-- Infrastructure & Hosting -->
<record id="acct_511100" model="account.account">
<field name="code">511100</field>
<field name="name">Cloud Infrastructure (AWS, Hetzner, OVH, DigitalOcean, Linode)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511110" model="account.account">
<field name="code">511110</field>
<field name="name">CDN &amp; Edge Services (Cloudflare, Fastly)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511120" model="account.account">
<field name="code">511120</field>
<field name="name">Backup &amp; Storage Services</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511130" model="account.account">
<field name="code">511130</field>
<field name="name">Database &amp; Backend Services (Supabase, hosted Postgres, Redis)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511140" model="account.account">
<field name="code">511140</field>
<field name="name">Monitoring &amp; Observability (customer-facing only)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511150" model="account.account">
<field name="code">511150</field>
<field name="name">SSL Certificates &amp; Domains (wholesale for resale)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511160" model="account.account">
<field name="code">511160</field>
<field name="name">DNS &amp; Email Hosting (wholesale for resale)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<!-- Third-party APIs -->
<record id="acct_511200" model="account.account">
<field name="code">511200</field>
<field name="name">Third-party API Costs (Twilio, SendGrid, OpenAI)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511210" model="account.account">
<field name="code">511210</field>
<field name="name">Per-customer Licensing &amp; Royalties</field>
<field name="account_type">expense_direct_cost</field>
</record>
<!-- Project Direct Costs -->
<record id="acct_512100" model="account.account">
<field name="code">512100</field>
<field name="name">Subcontracted Labour — Canadian (T4A) — SR&amp;ED-eligible</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_512110" model="account.account">
<field name="code">512110</field>
<field name="name">Subcontracted Labour — Foreign — NOT SR&amp;ED-eligible</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_512200" model="account.account">
<field name="code">512200</field>
<field name="name">Project-specific Software &amp; Licenses</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_512300" model="account.account">
<field name="code">512300</field>
<field name="name">Project Travel &amp; Onsite (rebilled)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_512400" model="account.account">
<field name="code">512400</field>
<field name="name">Project Hardware (passed through)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<!-- Resold Goods -->
<record id="acct_513100" model="account.account">
<field name="code">513100</field>
<field name="name">Cost of Software Resold</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_513200" model="account.account">
<field name="code">513200</field>
<field name="name">Cost of Hardware Resold</field>
<field name="account_type">expense_direct_cost</field>
</record>
<!-- Adjustments -->
<record id="acct_519100" model="account.account">
<field name="code">519100</field>
<field name="name">COGS Adjustments / Write-offs</field>
<field name="account_type">expense_direct_cost</field>
</record>
```
- [ ] **Step 2: Deploy, update, verify**
Run:
```bash
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:
```bash
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:
```xml
<!-- ============================================================
6xxxxx OPERATING EXPENSES
============================================================ -->
<!-- 611xxx Personnel — Internal Staff (T4) -->
<record id="acct_611100" model="account.account">
<field name="code">611100</field>
<field name="name">Salaries &amp; Wages — Development (SR&amp;ED-eligible)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611200" model="account.account">
<field name="code">611200</field>
<field name="name">Salaries &amp; Wages — Sales &amp; Marketing</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611300" model="account.account">
<field name="code">611300</field>
<field name="name">Salaries &amp; Wages — Admin &amp; Operations</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611400" model="account.account">
<field name="code">611400</field>
<field name="name">Salary — Shareholder/Officer (Gurpreet)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611500" model="account.account">
<field name="code">611500</field>
<field name="name">Employer CPP / QPP Contributions</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611600" model="account.account">
<field name="code">611600</field>
<field name="name">Employer EI Premiums</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611700" model="account.account">
<field name="code">611700</field>
<field name="name">Employer Health Tax (EHT/QHST)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611800" model="account.account">
<field name="code">611800</field>
<field name="name">WCB / WSIB Premiums</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611900" model="account.account">
<field name="code">611900</field>
<field name="name">Employee Benefits (health, dental, group)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611950" model="account.account">
<field name="code">611950</field>
<field name="name">Bonuses &amp; Incentives</field>
<field name="account_type">expense</field>
</record>
<record id="acct_611960" model="account.account">
<field name="code">611960</field>
<field name="name">Vacation Pay Accrual</field>
<field name="account_type">expense</field>
</record>
<!-- 612xxx Personnel — Contract (non-project) -->
<record id="acct_612100" model="account.account">
<field name="code">612100</field>
<field name="name">Contract Labour — Canadian (admin/marketing/freelance)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_612200" model="account.account">
<field name="code">612200</field>
<field name="name">Contract Labour — Foreign</field>
<field name="account_type">expense</field>
</record>
<!-- 621xxx Office & Facilities -->
<record id="acct_621100" model="account.account">
<field name="code">621100</field>
<field name="name">Rent — Commercial Office</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621200" model="account.account">
<field name="code">621200</field>
<field name="name">Home Office — Business Portion</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621300" model="account.account">
<field name="code">621300</field>
<field name="name">Utilities — Commercial</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621400" model="account.account">
<field name="code">621400</field>
<field name="name">Internet &amp; Phone — Business</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621500" model="account.account">
<field name="code">621500</field>
<field name="name">Office Supplies &amp; Consumables</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621600" model="account.account">
<field name="code">621600</field>
<field name="name">Cleaning &amp; Maintenance</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621700" model="account.account">
<field name="code">621700</field>
<field name="name">Office Snacks &amp; Refreshments</field>
<field name="account_type">expense</field>
</record>
<!-- 631xxx Technology — Operating -->
<record id="acct_631100" model="account.account">
<field name="code">631100</field>
<field name="name">Software — Productivity (M365, Slack, Notion, Linear, GitHub)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_631200" model="account.account">
<field name="code">631200</field>
<field name="name">Software — Development Tools (Cursor, Figma, IDEs)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_631300" model="account.account">
<field name="code">631300</field>
<field name="name">Software — Internal Infrastructure</field>
<field name="account_type">expense</field>
</record>
<record id="acct_631400" model="account.account">
<field name="code">631400</field>
<field name="name">Software — Security &amp; IT</field>
<field name="account_type">expense</field>
</record>
<record id="acct_631500" model="account.account">
<field name="code">631500</field>
<field name="name">Software — Sales &amp; Marketing</field>
<field name="account_type">expense</field>
</record>
<!-- 641xxx Marketing & Sales -->
<record id="acct_641100" model="account.account">
<field name="code">641100</field>
<field name="name">Advertising — Digital Ads</field>
<field name="account_type">expense</field>
</record>
<record id="acct_641200" model="account.account">
<field name="code">641200</field>
<field name="name">Advertising — Content / SEO</field>
<field name="account_type">expense</field>
</record>
<record id="acct_641300" model="account.account">
<field name="code">641300</field>
<field name="name">Trade Shows &amp; Conferences</field>
<field name="account_type">expense</field>
</record>
<record id="acct_641400" model="account.account">
<field name="code">641400</field>
<field name="name">Promotional Items / Branded Swag</field>
<field name="account_type">expense</field>
</record>
<record id="acct_641500" model="account.account">
<field name="code">641500</field>
<field name="name">Website — Own (nexasystems.ca)</field>
<field name="account_type">expense</field>
</record>
<!-- 651xxx Professional Fees -->
<record id="acct_651100" model="account.account">
<field name="code">651100</field>
<field name="name">Legal Fees — General</field>
<field name="account_type">expense</field>
</record>
<record id="acct_651200" model="account.account">
<field name="code">651200</field>
<field name="name">Accounting &amp; Bookkeeping</field>
<field name="account_type">expense</field>
</record>
<record id="acct_651300" model="account.account">
<field name="code">651300</field>
<field name="name">Tax Preparation (T2, T1, GST/HST)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_651400" model="account.account">
<field name="code">651400</field>
<field name="name">Business Consulting</field>
<field name="account_type">expense</field>
</record>
<!-- 661xxx Insurance -->
<record id="acct_661100" model="account.account">
<field name="code">661100</field>
<field name="name">Insurance — Commercial General Liability</field>
<field name="account_type">expense</field>
</record>
<record id="acct_661200" model="account.account">
<field name="code">661200</field>
<field name="name">Insurance — Professional Liability / E&amp;O</field>
<field name="account_type">expense</field>
</record>
<record id="acct_661300" model="account.account">
<field name="code">661300</field>
<field name="name">Insurance — Cyber Liability</field>
<field name="account_type">expense</field>
</record>
<record id="acct_661400" model="account.account">
<field name="code">661400</field>
<field name="name">Insurance — Property</field>
<field name="account_type">expense</field>
</record>
<record id="acct_661500" model="account.account">
<field name="code">661500</field>
<field name="name">Insurance — Directors &amp; Officers</field>
<field name="account_type">expense</field>
</record>
<!-- 671xxx Travel & Entertainment -->
<record id="acct_671100" model="account.account">
<field name="code">671100</field>
<field name="name">Travel — Flights, Hotels, Ground Transport</field>
<field name="account_type">expense</field>
</record>
<record id="acct_671200" model="account.account">
<field name="code">671200</field>
<field name="name">Meals &amp; Entertainment — 50% Deductible</field>
<field name="account_type">expense</field>
</record>
<record id="acct_671300" model="account.account">
<field name="code">671300</field>
<field name="name">Vehicle — Operating (gas, insurance, repairs, parking)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_671400" model="account.account">
<field name="code">671400</field>
<field name="name">Mileage Reimbursement — Personal Vehicle</field>
<field name="account_type">expense</field>
</record>
<!-- 681xxx Training & Development -->
<record id="acct_681100" model="account.account">
<field name="code">681100</field>
<field name="name">Conferences &amp; Seminars (registration)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_681200" model="account.account">
<field name="code">681200</field>
<field name="name">Courses &amp; Certifications</field>
<field name="account_type">expense</field>
</record>
<record id="acct_681300" model="account.account">
<field name="code">681300</field>
<field name="name">Books &amp; Publications</field>
<field name="account_type">expense</field>
</record>
<record id="acct_681400" model="account.account">
<field name="code">681400</field>
<field name="name">Professional Memberships &amp; Dues</field>
<field name="account_type">expense</field>
</record>
<!-- 691xxx Banking & Finance -->
<record id="acct_691100" model="account.account">
<field name="code">691100</field>
<field name="name">Bank Service Charges</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691200" model="account.account">
<field name="code">691200</field>
<field name="name">Merchant Processing Fees (Stripe, PayPal, Square)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691300" model="account.account">
<field name="code">691300</field>
<field name="name">Wire Transfer &amp; FX Fees</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691400" model="account.account">
<field name="code">691400</field>
<field name="name">Interest Expense — Bank Loans / LOC</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691500" model="account.account">
<field name="code">691500</field>
<field name="name">Interest Expense — Credit Cards</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691600" model="account.account">
<field name="code">691600</field>
<field name="name">Late Payment Penalties — Non-deductible</field>
<field name="account_type">expense</field>
</record>
<!-- 699xxx Other -->
<record id="acct_699100" model="account.account">
<field name="code">699100</field>
<field name="name">Bad Debt Expense</field>
<field name="account_type">expense</field>
</record>
<record id="acct_699200" model="account.account">
<field name="code">699200</field>
<field name="name">Donations &amp; Sponsorships (deductible)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_699300" model="account.account">
<field name="code">699300</field>
<field name="name">Penalties &amp; Fines — Non-deductible</field>
<field name="account_type">expense</field>
</record>
<record id="acct_699400" model="account.account">
<field name="code">699400</field>
<field name="name">Realized FX Losses</field>
<field name="account_type">expense</field>
</record>
<record id="acct_699500" model="account.account">
<field name="code">699500</field>
<field name="name">Depreciation / CCA Expense</field>
<field name="account_type">expense</field>
</record>
```
- [ ] **Step 2: Deploy, update, verify**
Run:
```bash
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:
```bash
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
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<record id="plan_project" model="account.analytic.plan">
<field name="name">Project</field>
<field name="default_applicability">mandatory</field>
</record>
<record id="plan_department" model="account.analytic.plan">
<field name="name">Department</field>
<field name="default_applicability">mandatory</field>
</record>
<record id="plan_sred_tag" model="account.analytic.plan">
<field name="name">SR&amp;ED Tag</field>
<field name="default_applicability">optional</field>
</record>
</data>
</odoo>
```
- [ ] **Step 2: Deploy, update, verify**
Run:
```bash
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:
```bash
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
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Departments -->
<record id="aa_dept_dev" model="account.analytic.account">
<field name="name">Development</field>
<field name="code">DEPT-DEV</field>
<field name="plan_id" ref="plan_department"/>
</record>
<record id="aa_dept_sales" model="account.analytic.account">
<field name="name">Sales &amp; Marketing</field>
<field name="code">DEPT-SALES</field>
<field name="plan_id" ref="plan_department"/>
</record>
<record id="aa_dept_admin" model="account.analytic.account">
<field name="name">Admin &amp; Operations</field>
<field name="code">DEPT-ADMIN</field>
<field name="plan_id" ref="plan_department"/>
</record>
<record id="aa_dept_hosting" model="account.analytic.account">
<field name="name">Hosting Operations</field>
<field name="code">DEPT-HOSTING</field>
<field name="plan_id" ref="plan_department"/>
</record>
<!-- SR&ED tag values (one analytic account per tag) -->
<record id="aa_sred_t4_dev" model="account.analytic.account">
<field name="name">T4 Dev Salary — full proxy</field>
<field name="code">SRED-T4-DEV-SALARY</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_specified" model="account.analytic.account">
<field name="name">Specified Employee Salary — 75% cap</field>
<field name="code">SRED-SPECIFIED-EMPLOYEE</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_contr_ca_arm" model="account.analytic.account">
<field name="name">Contractor CA Arm's Length — 80% eligible</field>
<field name="code">SRED-CONTRACTOR-CA-ARM-LENGTH</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_contr_ca_naf" model="account.analytic.account">
<field name="name">Contractor CA Non-Arm's Length</field>
<field name="code">SRED-CONTRACTOR-CA-NON-ARM-LENGTH</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_materials" model="account.analytic.account">
<field name="name">Materials Consumed in R&amp;D</field>
<field name="code">SRED-MATERIALS-CONSUMED</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_overhead_basis" model="account.analytic.account">
<field name="name">Overhead Proxy Basis (direct labour basis)</field>
<field name="code">SRED-OVERHEAD-PROXY-BASIS</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
<record id="aa_sred_not_eligible" model="account.analytic.account">
<field name="name">Not Eligible (default)</field>
<field name="code">NOT-ELIGIBLE</field>
<field name="plan_id" ref="plan_sred_tag"/>
</record>
</data>
</odoo>
```
- [ ] **Step 2: Deploy, update, verify**
Run:
```bash
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:
```bash
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:
```python
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```python
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:
```bash
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d nexamain_staging -c \"SELECT a.id, a.name->>'en_US' AS name, a.code_store->>'1' AS code, d.module FROM account_account a LEFT JOIN ir_model_data d ON d.model='account.account' AND d.res_id=a.id WHERE a.code_store->>'1' = '511100';\""
```
If only ONE row exists and it's our new XML record (module=`nexa_coa_setup`), then the legacy `511100 Inside Purchases` was already absorbed/replaced — REMOVE that line from `legacy_map` before proceeding.
If TWO rows exist (legacy + new), Odoo prevented loading the new one because of code uniqueness. In that case:
- Manually re-code the legacy 511100 to `511100-LEGACY` via odoo-shell first, then re-run -u to create our new account at 511100, then re-add the rename step.
- [ ] **Step 2: Resolve the 511100 collision (per the note above)**
Run the verify query. Based on result:
- **If only nexa_coa_setup's record exists**: edit `hooks.py` to delete the `("511100", ...)` line from `legacy_map`. Commit the edit. Proceed.
- **If both exist**: open an odoo-shell session and re-code the legacy:
```bash
ssh odoo-nexa "docker exec -i odoo-nexa-app odoo shell -c /etc/odoo/odoo.conf -d nexamain_staging --no-http --stop-after-init" <<'PYEOF'
legacy = env['account.account'].search([('code','=','511100'),('name','ilike','inside purchases')])
if legacy:
legacy.code = '511100-LEGACY'
env.cr.commit()
print(f"Renamed legacy account {legacy.id} to code 511100-LEGACY")
PYEOF
```
Then leave `hooks.py` as-is.
- [ ] **Step 3: Deploy, update on staging**
Run:
```bash
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:
```bash
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:
```bash
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`:
```python
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)`:
```python
_archive_unused_taxes(env)
```
- [ ] **Step 2: Deploy, update on staging**
Run:
```bash
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:
```bash
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:
```bash
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:
```bash
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
<?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:
```bash
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`:
```python
"""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:
```bash
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:
```bash
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:
```bash
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
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Parent: Services -->
<record id="pc_services" model="product.category">
<field name="name">Services</field>
</record>
<!-- Children, each with default income account -->
<record id="pc_saas" model="product.category">
<field name="name">SaaS Subscription</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_411100"/>
</record>
<record id="pc_hosting" model="product.category">
<field name="name">Hosting</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_411200"/>
</record>
<record id="pc_support" model="product.category">
<field name="name">Support Contract</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_411300"/>
</record>
<record id="pc_setup_fee" model="product.category">
<field name="name">Setup Fee</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_411500"/>
</record>
<record id="pc_custom_software" model="product.category">
<field name="name">Custom Software Development</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_412100"/>
</record>
<record id="pc_webapp" model="product.category">
<field name="name">Custom Web App Development</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_412200"/>
</record>
<record id="pc_website" model="product.category">
<field name="name">Custom Website Development</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_412300"/>
</record>
<record id="pc_erp" model="product.category">
<field name="name">ERP Implementation</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_412400"/>
</record>
<record id="pc_consulting" model="product.category">
<field name="name">Consulting &amp; Advisory</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_413100"/>
</record>
<record id="pc_training" model="product.category">
<field name="name">Training</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_413200"/>
</record>
<!-- Parent: Resale -->
<record id="pc_resale" model="product.category">
<field name="name">Resale</field>
</record>
<record id="pc_resale_software" model="product.category">
<field name="name">Software Resale</field>
<field name="parent_id" ref="pc_resale"/>
<field name="property_account_income_categ_id" ref="acct_414100"/>
<field name="property_account_expense_categ_id" ref="acct_513100"/>
</record>
<record id="pc_resale_hardware" model="product.category">
<field name="name">Hardware Resale</field>
<field name="parent_id" ref="pc_resale"/>
<field name="property_account_income_categ_id" ref="acct_414200"/>
<field name="property_account_expense_categ_id" ref="acct_513200"/>
</record>
</data>
</odoo>
```
- [ ] **Step 2: Deploy, update, verify**
Run:
```bash
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:
```bash
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
<?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:
```bash
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
<?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:
```bash
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:
```bash
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:
```bash
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
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- AWS / Amazon Web Services -->
<record id="rule_aws" model="account.reconcile.model">
<field name="name">AWS / Amazon Web Services → Cloud Infrastructure</field>
<field name="rule_type">writeoff_suggestion</field>
<field name="match_label">contains</field>
<field name="match_label_param">AMAZON WEB SERVICES</field>
<field name="line_ids" eval="[(0, 0, {
'account_id': ref('acct_511100'),
'amount_type': 'percentage',
'amount_string': '100',
'label': 'AWS — Cloud Infrastructure',
})]"/>
</record>
<!-- Hetzner / OVH / DigitalOcean / Linode -->
<record id="rule_hetzner" model="account.reconcile.model">
<field name="name">Hetzner → Cloud Infrastructure</field>
<field name="rule_type">writeoff_suggestion</field>
<field name="match_label">contains</field>
<field name="match_label_param">HETZNER</field>
<field name="line_ids" eval="[(0, 0, {
'account_id': ref('acct_511100'),
'amount_type': 'percentage',
'amount_string': '100',
'label': 'Hetzner — Cloud Infrastructure',
})]"/>
</record>
<record id="rule_digitalocean" model="account.reconcile.model">
<field name="name">DigitalOcean → Cloud Infrastructure</field>
<field name="rule_type">writeoff_suggestion</field>
<field name="match_label">contains</field>
<field name="match_label_param">DIGITALOCEAN</field>
<field name="line_ids" eval="[(0, 0, {
'account_id': ref('acct_511100'),
'amount_type': 'percentage',
'amount_string': '100',
'label': 'DigitalOcean — Cloud Infrastructure',
})]"/>
</record>
<!-- Cloudflare → CDN -->
<record id="rule_cloudflare" model="account.reconcile.model">
<field name="name">Cloudflare → CDN &amp; Edge</field>
<field name="rule_type">writeoff_suggestion</field>
<field name="match_label">contains</field>
<field name="match_label_param">CLOUDFLARE</field>
<field name="line_ids" eval="[(0, 0, {
'account_id': ref('acct_511110'),
'amount_type': 'percentage',
'amount_string': '100',
'label': 'Cloudflare — CDN &amp; Edge',
})]"/>
</record>
<!-- GitHub → Dev Tools -->
<record id="rule_github" model="account.reconcile.model">
<field name="name">GitHub → Software (Dev Tools)</field>
<field name="rule_type">writeoff_suggestion</field>
<field name="match_label">contains</field>
<field name="match_label_param">GITHUB</field>
<field name="line_ids" eval="[(0, 0, {
'account_id': ref('acct_631200'),
'amount_type': 'percentage',
'amount_string': '100',
'label': 'GitHub — Software (Dev Tools)',
})]"/>
</record>
<!-- Microsoft / M365 → Productivity -->
<record id="rule_microsoft" model="account.reconcile.model">
<field name="name">Microsoft / M365 → Software (Productivity)</field>
<field name="rule_type">writeoff_suggestion</field>
<field name="match_label">contains</field>
<field name="match_label_param">MICROSOFT</field>
<field name="line_ids" eval="[(0, 0, {
'account_id': ref('acct_631100'),
'amount_type': 'percentage',
'amount_string': '100',
'label': 'Microsoft — Software (Productivity)',
})]"/>
</record>
<!-- Stripe fee → Merchant Processing -->
<record id="rule_stripe_fee" model="account.reconcile.model">
<field name="name">Stripe fee → Merchant Processing</field>
<field name="rule_type">writeoff_suggestion</field>
<field name="match_label">contains</field>
<field name="match_label_param">STRIPE FEE</field>
<field name="line_ids" eval="[(0, 0, {
'account_id': ref('acct_691200'),
'amount_type': 'percentage',
'amount_string': '100',
'label': 'Stripe — Merchant Processing',
})]"/>
</record>
<!-- Google Ads → Advertising Digital -->
<record id="rule_google_ads" model="account.reconcile.model">
<field name="name">Google Ads → Advertising (Digital)</field>
<field name="rule_type">writeoff_suggestion</field>
<field name="match_label">contains</field>
<field name="match_label_param">GOOGLE ADS</field>
<field name="line_ids" eval="[(0, 0, {
'account_id': ref('acct_641100'),
'amount_type': 'percentage',
'amount_string': '100',
'label': 'Google Ads — Advertising (Digital)',
})]"/>
</record>
</data>
</odoo>
```
- [ ] **Step 3: Deploy, update, verify**
Run:
```bash
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:
```bash
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`:
```python
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:
```bash
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:
```bash
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`:
```python
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:
```bash
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:
```bash
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`:
```python
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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:
```bash
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**
```bash
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:
```bash
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d postgres -c 'DROP DATABASE IF EXISTS nexamain_staging;'"
```
Expected: `DROP DATABASE`.
### Task 11.4: Final commit and tag
**Files:** none
- [ ] **Step 1: Tag the deployment**
> **Note**: `git push --tags` writes to the remote — ask user before running.
Run:
```bash
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:
```markdown
# Nexa Systems — Chart of Accounts Setup
Custom Odoo 19 module that configures the chart of accounts, taxes,
fiscal positions, analytic plans, and partner records for Nexa Systems Inc.
## Design reference
See `docs/superpowers/specs/2026-05-12-nexa-coa-design.md`.
## Initial install
ALWAYS take a pg_dump first:
```
ssh odoo-nexa "docker exec odoo-nexa-db pg_dump -U odoo -d nexamain -F c -Z 9 -f /tmp/nexamain_$(date +%Y%m%d).dump"
scp odoo-nexa:/tmp/nexamain_*.dump ~/Backups/odoo-nexa/
```
Then install:
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain -i nexa_coa_setup --no-http --stop-after-init"
```
## Update (when adding new accounts / taxes / fiscal positions)
1. Edit XML in `data/` or hooks in `hooks.py`
2. Sync to server:
```
rsync -avz /Users/gurpreet/Github/Odoo-Modules/nexa_coa_setup/ odoo-nexa:/opt/odoo/custom-addons/nexa_coa_setup/
```
3. Run -u:
```
ssh odoo-nexa "docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain -u nexa_coa_setup --no-http --stop-after-init"
```
## Adding a new account
1. Append a `<record id="acct_XXXXXX" model="account.account">` to `data/01_account_account.xml` (use the next free code in the appropriate range)
2. If it's a service/COGS account, also add a matching `product.category` in `data/07_product_category.xml`
3. Sync and update (see above)
## Adding a new project (analytic account)
Don't put projects in XML — create via UI as projects are dynamic.
In Odoo: Accounting → Configuration → Analytic Accounting → Analytic Accounts → New
- Name: descriptive (e.g., "Westin ERP Phase 2")
- Code: `PRJ-YYYY-{CUST}-{SHORTNAME}`
- Plan: Project
## Yearly tasks
- **Jan**: review CCA classes for new asset purchases; ensure assets created against correct 151xxx account
- **Feb**: prepare T2 with accountant; allocate associated-group SBD via Schedule 23
- **Mar**: HST annual return due (March 31 for Dec 31 year-end)
- **Apr**: review fiscal year lock — set to previous Dec 31 once T2 is filed
- **SepDec**: SR&ED analytic report pull; provide to accountant for T661 prep
- **Dec**: year-end review of intercompany pricing for transfer-pricing compliance
## Restore (if catastrophic)
```
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d postgres -c 'DROP DATABASE nexamain;'"
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d postgres -c 'CREATE DATABASE nexamain OWNER odoo;'"
ssh odoo-nexa "docker exec odoo-nexa-db pg_restore -U odoo -d nexamain -j 4 /tmp/nexamain_<date>.dump"
ssh odoo-nexa "docker restart odoo-nexa-app"
```
```
- [ ] **Step 2: Commit**
Run:
```bash
cd /Users/gurpreet/Github/Odoo-Modules
git add nexa_coa_setup/README.md
git commit -m "docs(nexa_coa_setup): operating runbook"
```
---
## Out of Scope (Future Sub-Projects)
These are intentionally NOT in this plan. Each warrants its own spec + plan cycle:
1. **Bank feeds via Plaid** — Odoo Enterprise has Plaid integration; needs credentials, banking partner OAuth, ongoing rule-tuning. Independent from CoA setup.
2. **Historical data reconciliation** — once accountant's Excel records arrive, mapping old transactions into the new account structure. Requires careful approach to respect the 2025-12-31 lock; possibly involves un-locking, posting, re-locking.
3. **Custom Canadian CCA module** — declining-balance + half-year + AccII automation. Only worth it once asset count >50.
4. **Payroll integration** — when first T4 employee is hired; integrate Wagepoint/ADP or Odoo Payroll, drive 215xxx source deductions automatically.
5. **Multi-company Odoo migration** — Westin and Divine currently on separate Odoo instances; future consolidation enables auto-mirrored intercompany invoices.
6. **Approval workflows** — purchase approval thresholds, expense approval per-department limits.
7. **Multi-currency** — USD bank account and currency-rate-live when first US client onboards.
8. **Subscription module configuration** — `sale_subscription` is installed; configuring recurring-billing templates per SaaS product, Stripe integration, auto-renewal email cadence is a per-customer flow handled when first SaaS contract is sold.
9. **Bank journal consolidation** — Section 10 Phase 4 of the spec calls for auditing the 7 bank journals; this is a manual decision per-account (which to keep, which to archive). Best done with the accountant during the historical reconciliation sub-project.
---
## Self-Review Note
This plan was self-reviewed for:
- **Placeholder scan**: no "TBD", "TODO later", or "similar to Task N" — every step contains the actual content needed.
- **Spec coverage**: every section of `docs/superpowers/specs/2026-05-12-nexa-coa-design.md` maps to a phase/task:
- Section 3 Skeleton → Phase 2 Tasks 2.1-2.6
- Section 4 Revenue → Phase 2 Task 2.4
- Section 5 COGS → Phase 2 Task 2.5
- Section 6 OpEx → Phase 2 Task 2.6
- Section 7 Capital Assets → Phase 2 Task 2.1 (cost accounts; asset models created on first asset purchase)
- Section 8 Tax Accounts → Phase 2 Task 2.2 (213xxx) + Phase 5 (cleanup)
- Section 9 Shareholder/Associated → Phase 2 Task 2.2 (221xxx, 222xxx) + Phase 8 (Westin/Divine partners)
- Section 10 Analytic Plans → Phase 3
- Section 11 Tax Setup/Fiscal Positions → Phase 5 + Phase 6
- Section 12 Cleanup Plan → Phase 4 (archive + rename hooks) + Phase 5 (taxes) + Phase 11 (lock fiscal year via hook)
- Section 13 Automation Hooks → Phase 7 (product categories) + Phase 9 (bank rec rules)
- Section 17 Acceptance Criteria → Phase 10 (verification tests) + Phase 11 Task 11.2 (production validation query)
- **Type consistency**: all XMLIDs use the `acct_`/`fp_`/`pc_`/`aa_`/`partner_`/`rule_` prefixes consistently; all account references in Phase 9 (reconcile rules) and Phase 7 (product cats) use account XMLIDs defined in Phase 2.
- **Known potential issues flagged inline**: account 511100 collision (Task 4.2 Step 2 has resolution procedure).