14 Commits

Author SHA1 Message Date
gsinghpal
d7bbeb49b7 fix(sticker): bigger QR + larger body text + tighter Notes row
- QR generation bumped from 300x300 to 600x600 (down-scales at print
  time instead of up-scales — eliminates the pixelation that broke
  thermal-printer scans).
- QR display wrapper grew from 380->460px; the underlying image and
  quiet-zone crop scaled proportionally (510->620px image, offset
  -65->-80px).
- Header band 40%->44% so the bigger QR has room without crowding
  the logo / WO# stack.
- Body band 60%->56%. First 6 rows take 15% of the band each (~9.4mm
  printed — slightly taller than the prior equal-split 8.7mm despite
  the smaller band) and the Notes row drops to 10% (~6mm). Notes is
  usually one-line content on these stickers; main rows carry the
  PO / Customer / Process / Part / Due / Qty info that has to read
  well from across the shop.
- Body font 38pt->44pt, muted 28pt->32pt. Notes row uses 32pt so 1-2
  lines still fit in its trimmed height.

Verified: 123,668-byte PDF renders cleanly on entech (WO-30019).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:33:50 -04:00
gsinghpal
2737bc481c docs(nexa_coa_setup): comprehensive operating runbook
Expands README into a full ops guide covering:
- Chart of accounts at a glance (4-digit ranges + examples)
- Standard products catalog with SKUs and income routing
- Fiscal positions with auto-detect rules
- Three analytic plans + their tag conventions
- Install / update / deploy / restore commands
- Yearly close calendar (HST Mar 31, T2 Jun 30, SR&ED prep timeline)
- Common tasks (add account, add product, add analytic, lock FY,
  reclassify invoice, pull SR&ED data)
- Compliance flags (associated-corp SBD sharing, s.15(2) loans,
  transfer pricing, HST cadence triggers, specified-employee SR&ED cap)
- Implementation scripts table (audit reference)
- Open items checklist for future manual follow-ups

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:52:18 -04:00
gsinghpal
0e595e6129 feat(nexa_coa_setup): batch-reclass 200 historical 411000 lines
All 123 historical out_invoices ($249k revenue, 2022-2023 mostly) had
been posted to the generic l10n_ca '411000 Inside Sales' account, since
the module they predated proper product setup and had no SKU attached.

Keyword-rule script (scripts/reclass_historical_411000.py) routes each
line by description text to the correct Nexa account:

  Pattern                                  -> Target account     Lines   Revenue
  Computer & Server Maintenance, Server   4030 Support &         165    $236,259
    Backup & Monitoring, Membership Fee    Maintenance Contracts
  [CUSTCOMP], Custom Computer, HP Desk,   4320 Hardware Resale    24    ~$8,200
    Server 2019, Server Rack, 16 Port
    POE, CPU:, Cleaning Supplies
  ONSITE-, OFFSITE-, Server Setup,        4230 Technical Support  11    ~$3,200
    Wiring for                             — Per-incident/Hourly

Match rate: 200/200 = 100%. Verified the legacy 411000 account now has
zero open-invoice lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:43:22 -04:00
gsinghpal
a0f783ab14 feat(nexa_coa_setup): seed 14 standard service products
Standard catalog covering Nexa's main service lines, each linked to the
appropriate product category so income posts to the right GL account
automatically:

  Recurring (per month/year)
    SAAS-BASIC    SaaS Subscription — Basic               $0    -> 4010
    HOST-S        Hosting — Small                         $49   -> 4020
    HOST-M        Hosting — Medium                        $149  -> 4020
    HOST-L        Hosting — Large                         $299  -> 4020
    SUPPORT-RET   Support Contract — 4 hrs retainer       $640  -> 4030
    SETUP-FEE     Setup / Onboarding Fee                  $500  -> 4050

  Project (hourly)
    DEV-SOFTWARE  Custom Software Development             $160  -> 4110
    DEV-WEBAPP    Custom Web App Development              $160  -> 4120
    DEV-WEBSITE   Custom Website Development              $160  -> 4130
    ERP-IMPL      ERP Implementation & Customization      $175  -> 4140

  Services (hourly)
    CONSULT       Consulting & Advisory                   $200  -> 4210
    TRAINING      Training & Workshop                     $120  -> 4220
    TECH-SUPPORT  Technical Support — Per-incident        $160  -> 4230

  Reseller (template)
    RESALE-SW     Third-party Software License (template) $0    -> 4310

File uses noupdate=1 so user price/description edits persist across
future -u runs.

Verified: creating an invoice for Westin with 10 hrs of DEV-SOFTWARE
auto-routes to 4110 Custom Software Development Revenue with 13% HST
applied via fiscal position.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:41:13 -04:00
gsinghpal
82a13b2ce5 feat(nexa_coa_setup): pc_tech_support category + fix Entech invoice 1127
Adds pc_tech_support product category (parent: Services, income default:
4230 Technical Support — Per-incident / Hourly Revenue). Existing
categories had no hourly-tech-support slot; SETUP-type hourly billing
products go here.

Also repoints the 17 product lines of invoice 1127 (Electroless Nickel
Technologies, ,985.48, posted 2026-04-29) from the legacy account
412000 to the correct Nexa accounts via direct UPDATE on
account_move_line:
  13 hardware lines (Lenovo, RTX, NAS drives, cabinets, UPS, ...)
    -> 4320 Hardware Resale Revenue
  4 SETUP hours lines (Cloud / Security / NAS / Network setup)
    -> 4230 Technical Support — Per-incident / Hourly Revenue
Invoice totals, tax, payment, customer PDF all unchanged.

Reassigns 14 product templates (P620, CUSPC, SETUP, etc.) to use the
new categories so future invoices auto-route correctly:
  Hardware SKUs -> pc_resale_hardware
  SETUP         -> pc_tech_support

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:21:43 -04:00
gsinghpal
0230670bdc feat(nexa_coa_setup): renumber l10n_ca bank/AR/AP/tax legacy accounts to 4-digit
Final batch of code conversions — 12 l10n_ca accounts that we kept active
because they have historical postings. Renaming preserves all FK
references (account_id stays the same), just changes the displayed code.

  112005 Scotia Current 9309     -> 1010  (primary operating bank)
  112004 BMO                     -> 1030
  112007 RBC                     -> 1040
  112008 Scotia Credit Card 5890 -> 1070
  112006 RBC VISA                -> 1071
  112002 Outstanding Receipts    -> 1080  (in-transit receipts)
  112003 Outstanding Payments    -> 1081  (in-transit payments)
  112001 Bank Suspense Account   -> 1090
  115100 Customers Account       -> 1100  (AR control)
  118310 HST receivable - 13%    -> 1215  (legacy HST receivable, near new 1210 ITC)
  211100 Vendors Account         -> 2010  (AP control)
  213310 HST to pay - 13%        -> 2115  (legacy HST collected, near new 2110)

Verification: 140/140 active accounts now use 4-digit codes. All four
end-to-end test invoices still post correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:10:47 -04:00
gsinghpal
86e89ca419 feat(nexa_coa_setup): convert chart of accounts to 4-digit codes
Renumbered all 128 Nexa accounts from 6-digit (l10n_ca style) to clean
4-digit codes for readability:

  1000-1999  Assets
    1120  Due From Shareholder
    1210  HST/GST ITC Receivable
    1510-1750  Capital assets + accumulated depreciation
  2000-2999  Liabilities
    2110  HST/GST Collected
    2510  Due To Shareholder
  3000-3999  Equity
    3010  Common Shares
    3510  Retained Earnings — Current
  4000-4999  Revenue
    4010-4050  Recurring (SaaS, Hosting, Support, ...)
    4110-4160  Project work
    4210-4230  Hourly services
    4310-4320  Reseller
  5000-5999  COGS
    5010-5120  Infrastructure & APIs
    5210-5250  Project direct costs
    5310-5320  Resold goods
  6000-6999  Operating expenses
    6010-6092  Personnel (T4)
    6110-6120  Contract labour
    6210-6960  Office/Tech/Marketing/Professional/Insurance/Travel/Training/Banking
  7000+  Other (bad debt, donations, FX, depreciation)

Applied to prod via scripts/convert_to_4digit.py (now committed). XML
codes updated in 01_account_account.xml; XMLIDs preserved so existing
ir.model.data rows on prod stay valid.

Hook constants updated:
- _TAX_REPARTITION_REMAP targets: 118100 -> 1210, 213100 -> 2110, etc.
- _LEGACY_RENAMES new_name strings: 're-class to NNNN' guidance updated
  to 4-digit targets.

Verified -u on prod completes cleanly + all 4 test invoices still post:
  ON     -> 4010 SaaS, total 113.00
  US     -> 4010 SaaS, total 100.00 (zero-rated)
  QC     -> 4010 SaaS, total 114.98
  Westin -> 4210 Consulting, total 169.50

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:09:01 -04:00
gsinghpal
749c0335fa fix(nexa_coa_setup): clean GL codes — 119100/119900->115200/115900, 511105->511100
Three odd code positions that were chosen to dodge l10n_ca collisions are
now cleaned up:
- Due From Shareholder       119100 -> 115200 (115xxx is where receivables belong)
- Due From Associated Corps  119900 -> 115900
- Cloud Infrastructure       511105 -> 511100 (legacy 'Inside Purchases'
                                       renamed to 511100.OLD)

Applied to prod via scripts/fix_gl_codes.py (now committed).

Module XML updated: <field name='code'> values match new codes; XMLIDs
(acct_119100, acct_119900, acct_511105) preserved so existing
ir.model.data rows on prod still map to the right records.

pre_init_hook augmented with _L10N_CA_FORCE_CLEAR_CODES set so a fresh
install on a new DB also force-clears 511100 (which would otherwise be
blocked by the postings-exist guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:02:54 -04:00
gsinghpal
092423d7de feat(nexa_coa_setup): hard-delete unused accounts
Adds _delete_unused_accounts hook that hard-deletes (not archives) every
account that's safe to remove — not owned by nexa_coa_setup AND not
referenced by:
- account.move.line postings
- account.tax.repartition.line
- account.journal default/suspense/profit/loss accounts
- account.fiscal.position.account substitution maps
- product.category and product.template JSONB property_account_* fields
- res.partner JSONB property_account_payable_id/receivable_id
- res.company exchange/transfer/POS receivable accounts

Tries bulk unlink first; falls back to per-record if a batch fails so
the rest still get cleaned.

Result on staging: 554 -> 172 total accounts (deleted 382). The 31 still
archived are blocked by references (historical postings, tax repartition
links, bank journal defaults, etc.) — left as archived so they're hidden
from dropdowns but preserve audit history.

Verified all 4 test invoices still post correctly (ON 113, US 100, QC
114.98, Westin intercompany 169.50).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:49:00 -04:00
gsinghpal
9c52fac9ba fix(nexa_coa_setup): default tax = 13% HST, tax repartition migration, FP pass-through
Three fixes that unblock end-to-end invoice tests on staging:

1. Switched company default sale/purchase tax from '5% GST' to '13% HST'
   (Ontario is the home province). New products auto-get 13% HST; fiscal
   positions substitute OUT to other rates per customer location.

2. Added _migrate_tax_repartition_accounts hook. The post_init archive sweep
   correctly archived legacy l10n_ca tax-tracking accounts (118100.OLD,
   231000, 232000, 233000, 118400, 118500, etc.) but active taxes still
   referenced them via repartition lines, causing invoice posting to fail
   with 'account is archived'. Hook repoints repartition to Nexa's
   consolidated 118100 (ITC) / 213100 (HST collected) / 213500 (QST
   collected) accounts.

3. Odoo 19 fiscal position behavior change: empty tax_ids now means
   'remove all taxes' (was 'pass-through' in v17/18). For ON home position
   we now add a self-mapping placeholder (13% HST -> 13% HST) so the FP
   has a non-empty tax_ids and map_tax falls through to pass-through
   semantics on the 13% HST source.

Verified with 4 invoice tests on staging:
  ON     -> 13% HST   total 113.00
  US     -> 0% GST    total 100.00 (zero-rated export)
  QC     -> 14.975%   total 114.98
  Westin -> 13% HST   total 169.50 (intercompany, RP-Associated tag)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:49 -04:00
gsinghpal
d2f8934a53 feat(nexa_coa_setup): product categories, partner records, bank reconcile rules
Phase 7 — 14 product categories under Services/Resale parents, each wired
to the appropriate default income (and expense for Resale) accounts.

Phase 8 — RP-Associated partner tag + Westin Healthcare Inc + Divine
Mobility Inc partner records, both as Customer+Vendor, both tagged
RP-Associated, both with CA-Ontario fiscal position pre-applied.

Phase 9 — 8 bank reconciliation rules for common vendors (AWS, Hetzner,
DigitalOcean, Cloudflare, GitHub, Microsoft, Stripe fee, Google Ads)
that auto-suggest the correct category account when reconciling bank
statement lines. Uses Odoo 19's 'trigger' field (replaces old
'rule_type').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:15:21 -04:00
gsinghpal
113427f7e2 feat(nexa_coa_setup): 8 fiscal positions + tax substitution maps
XML defines 8 positions with auto-detection by country/state:
- CA Ontario (default), CA Atlantic, CA Quebec, CA BC, CA Prairies/Territories
- Export US, Export International, Tax Exempt

post_init hook _configure_fiscal_position_tax_maps sets up bidirectional
tax routing (sale + purchase) from the default '5% GST' to the appropriate
provincial tax via Odoo 19's account.fiscal.position.tax_ids /
account.tax.original_tax_ids relation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:12:19 -04:00
gsinghpal
3559eb1fd5 feat(nexa_coa_setup): archive unused taxes
Adds _archive_unused_taxes hook that archives all active taxes whose
name is not in the curated keep-set (GST/HST/QST/PST per province + zero
rated + exempt) AND that have zero usage on existing move lines.

Reduces active taxes from 49 to 30 on staging. The 'HST for sales/
purchases - 13%' pair is kept active because of historical postings
(215 sales lines + 1 purchase line) — new invoicing routes to the
cleaner '13% HST' via fiscal positions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:09:37 -04:00
gsinghpal
9f28dce160 feat(nexa_coa_setup): archive-unused + rename-legacy hooks
_archive_unused_l10n_ca_accounts: archives every active account that has
zero postings and doesn't belong to nexa_coa_setup. Sweeps ~280 unused
l10n_ca defaults from 426 to 141 active.

_rename_legacy_accounts: marks 14 legacy bookkeeping codes with a
'(LEGACY)' prefix indicating the new account they map to, and archives
them. Uses active_test=False so already-archived accounts also get the
prefix for future readability.

Both idempotent — re-running on -u or via odoo-shell has no effect on
already-processed records.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:08:50 -04:00
18 changed files with 1718 additions and 182 deletions

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.10.3.0',
'version': '19.0.10.4.0',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -70,9 +70,12 @@
and _so.x_fc_internal_note.striptags()[:100])
or '-'"/>
<!-- Inline the QR as base64 data URI so wkhtmltopdf doesn't need
to fetch /report/barcode/ over the network during rendering. -->
to fetch /report/barcode/ over the network during rendering.
Generated at 600x600 (was 300x300) so it down-scales rather
than up-scales at print time — much sharper on Brother /
Zebra thermal printers where small QRs were dropping rows. -->
<t t-set="_qr_src" t-value="env['ir.actions.report'].barcode_data_uri(
'QR', _scan_url, width=300, height=300)"/>
'QR', _scan_url, width=600, height=600)"/>
<style>
@page { margin: 0; size: 152mm 102mm; }
@@ -97,11 +100,14 @@
page-break-after: always;
page-break-inside: avoid;
}
/* ---- HEADER band — grew to 40% to fit 2x WO# + logo + bigger QR. */
/* ---- HEADER band — grew to 44% (was 40%) to fit a bigger QR
for thermal-printer scan reliability. The body band is now
56% but each row stays roughly the same height because
we tightened the Notes row at the bottom. */
.fp-sticker-head-wrap {
position: absolute;
left: 0; right: 0; top: 0;
height: 40%;
height: 44%;
border-bottom: 2px solid #000;
box-sizing: border-box;
padding: 0;
@@ -168,26 +174,32 @@
render the image larger than the wrapper and offset it so
the wrapper clips that border out. ---------------------- */
.fp-sticker-qr-wrap {
width: 380px;
height: 380px;
width: 460px;
height: 460px;
display: inline-block;
position: relative;
overflow: hidden;
}
.fp-sticker-qr {
width: 510px;
height: 510px;
width: 620px;
height: 620px;
position: absolute;
top: -65px;
left: -65px;
top: -80px;
left: -80px;
margin: 0;
display: block;
}
/* ---- BODY band (7 rows, each 14.28% of the band) ---- */
/* ---- BODY band — first 6 rows are equal-height; the
final Notes row takes less. Header moved down to 44%
so this band is 56%; the row-height split below brings
the 6 main rows to ~9.4mm each (slightly more than the
old equal-split 8.7mm despite the smaller band). Notes
drops to ~6mm — enough for 1-2 lines of the bigger
text below. ---- */
.fp-sticker-body-wrap {
position: absolute;
left: 0; right: 0;
top: 40%; bottom: 0;
top: 44%; bottom: 0;
}
table.fp-sticker-body {
width: 100%;
@@ -195,14 +207,15 @@
table-layout: fixed;
border-collapse: collapse;
}
table.fp-sticker-body tr { height: 14.28%; }
table.fp-sticker-body tr.fp-sticker-row-main { height: 15%; }
table.fp-sticker-body tr.fp-sticker-row-notes { height: 10%; }
table.fp-sticker-body tr + tr td { border-top: 1px solid #000; }
col.fp-col-label { width: 32%; }
col.fp-col-value { width: 68%; }
table.fp-sticker-body td {
vertical-align: middle;
padding: 0 14px;
font-size: 38pt;
font-size: 44pt;
line-height: 1.1;
}
td.fp-sticker-label {
@@ -217,7 +230,10 @@
white-space: nowrap;
}
.fp-sticker-strong { font-weight: 700; }
.fp-sticker-muted { color: #555; font-size: 28pt; }
.fp-sticker-muted { color: #555; font-size: 32pt; }
/* Notes row uses a slightly smaller font so 1-2 lines fit
in the trimmed-down height. -------------------------- */
tr.fp-sticker-row-notes td { font-size: 32pt; }
</style>
<div class="fp-sticker">
@@ -269,7 +285,7 @@
<col class="fp-col-label"/>
<col class="fp-col-value"/>
</colgroup>
<tr>
<tr class="fp-sticker-row-main">
<td class="fp-sticker-label">PO (RO):</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong"
@@ -281,13 +297,13 @@
</t>
</td>
</tr>
<tr>
<tr class="fp-sticker-row-main">
<td class="fp-sticker-label">Customer:</td>
<td class="fp-sticker-value">
<span t-esc="_partner_name"/>
</td>
</tr>
<tr>
<tr class="fp-sticker-row-main">
<td class="fp-sticker-label">Process:</td>
<td class="fp-sticker-value">
<t t-if="_process">
@@ -299,7 +315,7 @@
<t t-else="">-</t>
</td>
</tr>
<tr>
<tr class="fp-sticker-row-main">
<td class="fp-sticker-label">Part Number:</td>
<td class="fp-sticker-value">
<t t-if="_part">
@@ -323,7 +339,7 @@
<t t-else="">-</t>
</td>
</tr>
<tr>
<tr class="fp-sticker-row-main">
<td class="fp-sticker-label">Due Date:</td>
<td class="fp-sticker-value">
<t t-if="_due">
@@ -332,7 +348,7 @@
<t t-else="">-</t>
</td>
</tr>
<tr>
<tr class="fp-sticker-row-main">
<td class="fp-sticker-label">Qty:</td>
<td class="fp-sticker-value">
<span class="fp-sticker-strong">
@@ -340,7 +356,7 @@
</span>
</td>
</tr>
<tr>
<tr class="fp-sticker-row-notes">
<td class="fp-sticker-label">Notes:</td>
<td class="fp-sticker-value">
<t t-esc="_internal_note"/>

View File

@@ -1,26 +1,261 @@
# 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.
Odoo 19 module that installs and maintains Nexa Systems Inc's chart of
accounts, taxes, fiscal positions, analytic plans, partner records, and
seeded standard products.
## Install
- **Design reference**: `docs/superpowers/specs/2026-05-12-nexa-coa-design.md`
- **Implementation plan**: `docs/superpowers/plans/2026-05-12-nexa-coa-setup.md`
- **Target**: odoo-nexa (192.168.1.111), database `nexamain`
```
docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain \
-i nexa_coa_setup --no-http --stop-after-init
---
## Chart of accounts at a glance
| Range | Purpose | Examples |
|---|---|---|
| `1010-1090` | Cash & bank | 1010 Scotia 9309, 1030 BMO, 1040 RBC, 1070 Scotia CC, 1090 Suspense |
| `1100-1130` | Accounts receivable | 1100 Customers, 1120 Due From Shareholder, 1130 Due From Associated Corps |
| `1210-1230` | Tax assets | 1210 HST/GST ITC Receivable, 1220 Instalments, 1230 QST Refund |
| `1510-1750` | Capital assets + accumulated depreciation | 1510 Computers (CCA 50), 1550 Software (CCA 14.1) |
| `2010-2130` | Trade AP + sales tax | 2010 Vendors, 2110 HST Collected, 2120 QST Collected, 2130 Net Payable |
| `2210-2330` | Payroll + corporate tax payable | 2210 Fed Tax W/H, 2220 CPP, 2230 EI |
| `2510-2590` | Shareholder + intercompany | 2510 Due To Shareholder, 2520 Long-term Loan, 2590 Due To Associated |
| `3010-3590` | Equity + retained earnings | 3010 Common Shares, 3510 RE Current Year, 3590 Dividends Declared |
| `4010-4050` | Recurring revenue | 4010 SaaS, 4020 Hosting, 4030 Support, 4040 Domain Pass-through, 4050 Setup |
| `4110-4160` | Project revenue | 4110 Custom Software, 4120 Web App, 4130 Website, 4140 ERP, 4150 Mobile |
| `4210-4230` | Services revenue (hourly) | 4210 Consulting, 4220 Training, 4230 Tech Support |
| `4310-4320` | Reseller revenue | 4310 Software resale, 4320 Hardware resale |
| `4910-4930` | Revenue adjustments | 4910 Discounts, 4920 Returns, 4930 Bad Debt Recovery |
| `5010-5910` | Direct costs (COGS) | 5010 Cloud Infrastructure, 5210 Sub Labour CA (SR&ED-eligible) |
| `6010-6092` | Personnel — T4 employees | 6010 Dev salaries (SR&ED proxy base), 6040 Owner salary |
| `6110-6120` | Contract labour (non-project) | 6110 Canadian, 6120 Foreign |
| `6210-6270` | Office & facilities | 6210 Rent, 6220 Home Office, 6240 Internet & Phone |
| `6310-6350` | Software subs (internal) | 6310 Productivity, 6320 Dev Tools, 6340 Security |
| `6410-6450` | Marketing & sales | 6410 Digital Ads, 6450 Own Website |
| `6510-6540` | Professional fees | 6510 Legal, 6520 Accounting, 6530 Tax Prep |
| `6610-6650` | Insurance | 6620 Professional Liability / E&O, 6630 Cyber |
| `6710-6740` | Travel & entertainment | 6710 Travel, 6720 M&E (50% deductible) |
| `6810-6840` | Training & development | |
| `6910-6960` | Banking & finance | 6910 Bank fees, 6920 Stripe/merchant, 6960 Late penalties (non-deductible) |
| `7010-7050` | Other | 7010 Bad Debt, 7020 Donations, 7030 Fines (non-deductible), 7050 Depreciation |
---
## Standard products (seeded)
All 14 starter products auto-route to the right income account via their
category. Use as-is, duplicate to create variants, or override prices per
customer/quote.
| SKU | Description | Default | Income → |
|---|---|---|---|
| `SAAS-BASIC` | SaaS Subscription — Basic | per-customer | 4010 |
| `HOST-S/M/L` | Hosting Small/Medium/Large | $49/$149/$299 mo | 4020 |
| `SUPPORT-RET` | Support Retainer (4 hrs/mo) | $640 mo | 4030 |
| `SETUP-FEE` | Setup / Onboarding Fee | $500 | 4050 |
| `DEV-SOFTWARE` | Custom Software Dev | $160 / hr | 4110 |
| `DEV-WEBAPP` | Custom Web App Dev | $160 / hr | 4120 |
| `DEV-WEBSITE` | Custom Website Dev | $160 / hr | 4130 |
| `ERP-IMPL` | ERP Implementation | $175 / hr | 4140 |
| `CONSULT` | Consulting & Advisory | $200 / hr | 4210 |
| `TRAINING` | Training & Workshop | $120 / hr | 4220 |
| `TECH-SUPPORT` | Technical Support — Hourly | $160 / hr | 4230 |
| `RESALE-SW` | Software License (template) | per-quote | 4310 |
Product file uses `noupdate=1` so your price/description edits persist
across module updates.
---
## Fiscal positions (automatic tax handling)
Tax routing is determined by customer billing address — you don't pick
the tax manually on invoices.
| Position | Auto-applies to | Sales tax |
|---|---|---|
| CA — Ontario (Default) | ON customers | 13% HST |
| CA — Atlantic | NB, NS, PE, NL | 15% HST |
| CA — Quebec | QC | 5% GST + 9.975% QST |
| CA — BC | BC | 5% GST (PST handled per-product if applicable) |
| CA — Prairies / Territories | AB, MB, SK, YT, NT, NU | 5% GST |
| Export — US | All US customers | 0% (Zero-rated) |
| Export — International | Manual | 0% (Zero-rated) |
| Tax Exempt | Manual (cert-holders) | 0% |
---
## Analytic plans
Three orthogonal tagging plans on every transaction:
| Plan | Tags | Used for |
|---|---|---|
| **Customer Project** | One per engagement (e.g. `PRJ-2026-WESTIN-ERP`) | Project P&L, billable-hour realization, WIP |
| **Department** | DEPT-DEV, DEPT-SALES, DEPT-ADMIN, DEPT-HOSTING | Cost allocation, departmental P&L |
| **SR&ED Tag** | SRED-T4-DEV-SALARY, SRED-SPECIFIED-EMPLOYEE, SRED-CONTRACTOR-CA-ARM-LENGTH, SRED-CONTRACTOR-CA-NON-ARM-LENGTH, SRED-MATERIALS-CONSUMED, SRED-OVERHEAD-PROXY-BASIS, NOT-ELIGIBLE | T661 SR&ED filing |
---
## Install / update
**Always pg_dump first.**
```bash
ssh odoo-nexa "STAMP=\$(date +%Y%m%d_%H%M%S) && \
docker exec odoo-nexa-db pg_dump -U odoo -d nexamain -F c -Z 9 \
> /tmp/nexamain_\${STAMP}.dump"
scp odoo-nexa:/tmp/nexamain_*.dump ~/Backups/odoo-nexa/
```
## Update
**Deploy module changes** (Mac → server; rsync isn't available, use tar):
```
docker exec odoo-nexa-app odoo -c /etc/odoo/odoo.conf -d nexamain \
-u nexa_coa_setup --no-http --stop-after-init
```bash
cd /Users/gurpreet/Github/Odoo-Modules && \
tar czf - nexa_coa_setup | \
ssh odoo-nexa "cd /opt/odoo/custom-addons && \
sudo rm -rf nexa_coa_setup && sudo tar xzf - && \
sudo chown -R 1000:1000 nexa_coa_setup"
```
## Design reference
**Install (first time on a database)**:
See `docs/superpowers/specs/2026-05-12-nexa-coa-design.md`.
```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"
```
## Safety
**Update (after editing XML or hooks)**:
Always take a pg_dump BEFORE running `-i` or `-u`. See `docs/superpowers/plans/2026-05-12-nexa-coa-setup.md` Phase 0.
```bash
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"
```
**Restore from backup** (only if catastrophic):
```bash
DUMP=~/Backups/odoo-nexa/nexamain_<timestamp>.dump
scp "$DUMP" odoo-nexa:/tmp/
ssh odoo-nexa "docker exec odoo-nexa-db psql -U odoo -d postgres -c \
'DROP DATABASE nexamain;' && \
docker exec odoo-nexa-db psql -U odoo -d postgres -c \
'CREATE DATABASE nexamain OWNER odoo;' && \
cat $DUMP | docker exec -i odoo-nexa-db pg_restore -U odoo -d nexamain && \
docker restart odoo-nexa-app"
```
---
## Yearly close calendar
Nexa is an annual HST filer and an annual T2 filer (fiscal year-end Dec 31).
| When | What | Notes |
|---|---|---|
| **Jan** | Confirm asset purchases for the year, assign correct CCA class accounts (1510-1560) | Class 50 hardware @ 55% DB (AccII = 82.5% Y1 through 2027). Class 14.1 software @ 100% Y1. |
| **Jan-Feb** | Pull SR&ED analytic report. Filter on SR&ED Tag analytic plan. Export. | Hand off to accountant for T661 prep. |
| **Mar 31** | **HST/GST annual return due**. Line 105 = sum of 2110, Line 108 = sum of 1210 ITC. | Pay any net tax + first quarterly instalment if prior year > $3k. |
| **Mar 31** | **T2 balance due** (CCPC). | T2 return itself is due June 30. |
| **Mar-Apr** | Set fiscalyear_lock_date to prior Dec 31 via Accounting > Configuration > Settings > Lock Dates. | Prevents accidental back-dating into closed period. |
| **Jun 30** | **T2 corporate income tax return due**. Schedule 23 allocates SBD + SR&ED expenditure limits across Nexa / Westin / Divine. | Associated-group calculation. |
| **Quarterly** | Pay HST instalments (if prior net tax ≥ $3k). | Track via 1220 Instalments Paid. |
| **Sep-Dec** | Yearly review of intercompany pricing (Nexa ↔ Westin / Divine). | Transfer-pricing compliance — must be fair market value. |
---
## Common tasks
### Add a new GL account
1. Append a `<record id="acct_NNNN" model="account.account">` to `data/01_account_account.xml` using the next free 4-digit code in the relevant range.
2. Deploy + `-u` (see Install/Update above).
3. Verify: `psql -d nexamain -c "SELECT code, name FROM ... WHERE code = 'NNNN';"`
### Add a new product
For ad-hoc / per-customer items, just create via UI:
- Sales > Products > New → set name, price, **Product Category** (this drives the income account).
- The fiscal position auto-applies tax when invoicing.
For new standard catalog items, append to `data/11_products.xml` (uses `noupdate=1` so a re-install won't overwrite UI edits).
### Add a new analytic account (project, department, SR&ED tag)
Projects are dynamic — create via UI:
- Accounting > Configuration > Analytic Accounting > Analytic Accounts > New
- Code: `PRJ-{YYYY}-{CUST}-{SHORTNAME}` (e.g. `PRJ-2026-WESTIN-ERP-PHASE2`)
- Plan: **Customer Project**
For departments or new SR&ED tags, edit `data/06_account_analytic_account.xml` + deploy + `-u`.
### Set the fiscal year lock
Accounting > Configuration > Settings > Lock Dates > set `Lock Date` to Dec 31 of the prior fiscal year. Requires:
- All bank statement lines for the period reconciled / cleared / deleted
- Final accountant adjustments posted
The `post_init_hook` attempts this automatically at install but tolerates failure (logs a warning instead) — set it manually once the books are clean.
### Reclassify a wrongly-categorized historical invoice line
Two ways:
- **In place (no PDF change)**: SQL `UPDATE account_move_line SET account_id = <new_id> WHERE id = <line_id>;`. Used in `scripts/fix_invoice_1127.py` and `scripts/reclass_historical_411000.py` as references.
- **Proper way (audit-trail clean)**: Reset invoice to draft, edit, re-post. Requires admin permissions + un-reconciling any payment first.
### Pull SR&ED data at year-end
```sql
-- Eligible salaries summary
SELECT aa.code, aa.name, ROUND(SUM(aml.debit - aml.credit)::numeric, 2) AS amount
FROM account_move_line aml
JOIN account_analytic_line aal ON aal.move_line_id = aml.id
JOIN account_analytic_account aa ON aa.id = aal.account_id
JOIN account_analytic_plan ap ON ap.id = aa.plan_id
WHERE ap.name = 'SR&ED Tag'
AND aml.date BETWEEN '2026-01-01' AND '2026-12-31'
AND aa.code <> 'NOT-ELIGIBLE'
GROUP BY aa.code, aa.name
ORDER BY aa.code;
```
Plus pull eligible contractor invoices: customer = Nexa, vendor in Canada (arm's length), tagged `SRED-CONTRACTOR-CA-ARM-LENGTH`.
---
## Compliance flags (do not forget)
- **Associated corporations**: Westin Healthcare Inc + Divine Mobility Inc share Nexa's $500k SBD limit and $3M SR&ED expenditure limit. **Annual Schedule 23 filing** allocates these across the group.
- **Subsection 15(2)**: Shareholder loans outstanding > 1 year past fiscal year-end become **taxable to Gurpreet personally**. Track 2510 (short-term) vs 2520 (long-term commercial loan).
- **Transfer pricing (s.247)**: Nexa invoicing Westin / Divine must be at fair market value. Document the methodology; penalty is 10% of any adjustment.
- **GST/HST cadence**: currently annual. Once Nexa-only revenue clears **$1.5M**, CRA auto-moves you to quarterly filing.
- **QC QST registration**: required if Nexa has any QC customers and revenue > $30k. Separate from federal CRA registration.
- **Quick Method GST/HST**: **likely unavailable** — the $400k threshold is checked against the associated-group total (Nexa + Westin + Divine combined).
- **Specified employee SR&ED cap**: Gurpreet's SR&ED-eligible salary is capped at 75% of basic salary (no bonuses), and only counts if paid via T4 payroll.
---
## Implementation scripts (one-shot, idempotent)
All under `scripts/` — preserved for audit reference; not run by the module
itself. Already executed against prod.
| Script | What it did |
|---|---|
| `fix_gl_codes.py` | Renumbered 119100/119900 → 115200/115900 and 511105 → 511100 |
| `convert_to_4digit.py` | Renumbered 128 Nexa accounts from 6-digit l10n_ca style to clean 4-digit |
| `convert_l10nca_to_4digit.py` | Renumbered 12 retained l10n_ca legacy accounts (Bank/AR/AP/HST) to 4-digit |
| `fix_invoice_1127.py` | Reclassified Entech invoice 1127's 17 lines + reassigned 14 product templates to proper categories |
| `reclass_historical_411000.py` | Batch-reclassified 200 historical invoice lines from legacy 411000 by keyword rules |
| `test_invoices.py` | End-to-end invoice tests (ON HST, US zero-rated, QC GST+QST, intercompany) |
---
## Open items (manual follow-ups)
- [ ] Set fiscal year lock at 2025-12-31 once unreconciled bank statement lines are cleared
- [ ] Verify HST# format display on customer invoice PDFs (stored as `741224877RT0001`; some Canadian accountants prefer `741224877 RT0001` with space — change via UI if needed)
- [ ] When first T4 employee is hired: configure payroll (Wagepoint / ADP / Odoo Payroll), set up source deduction journal automation, decide on payroll cadence
- [ ] When first USD invoice arrives: add USD bank account (1020 reserved), enable currency_rate_live cron, configure multi-currency on company
- [ ] When asset count grows: install custom CCA module OR keep maintaining CCA schedule via accountant's spreadsheet

View File

@@ -30,6 +30,7 @@
"data/08_res_partner_category.xml",
"data/09_res_partner.xml",
"data/10_account_reconcile_model.xml",
"data/11_products.xml",
],
"pre_init_hook": "pre_init_hook",
"post_init_hook": "post_init_hook",

View File

@@ -8,101 +8,105 @@
<!-- NOTE: 115100 is l10n_ca "Customers Account" (240 postings — AR control) — kept as l10n_ca.
115110 is l10n_ca "Customers Account (PoS)" — kept.
Nexa intercompany receivables live in the 119xxx range to avoid all collisions. -->
Nexa intercompany receivables sit at 115200 / 115900 (freed when their
unused l10n_ca defaults — Mortgage Loans — were deleted during install).
XMLIDs (acct_119100, acct_119900) preserved from initial install where
these accounts were at codes 119100/119900 — codes updated in-place via
scripts/fix_gl_codes.py without rewriting ir.model.data. -->
<record id="acct_119100" model="account.account">
<field name="code">119100</field>
<field name="code">1120</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_119900" model="account.account">
<field name="code">119900</field>
<field name="code">1130</field>
<field name="name">Due From Associated Corporations</field>
<field name="account_type">asset_current</field>
<field name="reconcile" eval="True"/>
</record>
<record id="acct_118100" model="account.account">
<field name="code">118100</field>
<field name="code">1210</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="code">1220</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="code">1230</field>
<field name="name">QST Input Tax Refund Receivable</field>
<field name="account_type">asset_current</field>
</record>
<record id="acct_151100" model="account.account">
<field name="code">151100</field>
<field name="code">1510</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="code">1520</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="code">1530</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="code">1540</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="code">1550</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="code">1560</field>
<field name="name">Tools &amp; Small Equipment &lt;$500 (CCA Class 12)</field>
<field name="account_type">asset_fixed</field>
</record>
<record id="acct_154100" model="account.account">
<field name="code">154100</field>
<field name="code">1710</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="code">1720</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="code">1730</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="code">1740</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="code">1750</field>
<field name="name">Acc. Depreciation — Acquired Software</field>
<field name="account_type">asset_fixed</field>
</record>
@@ -112,75 +116,75 @@
<!-- ============================================================ -->
<record id="acct_213100" model="account.account">
<field name="code">213100</field>
<field name="code">2110</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="code">2120</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="code">2130</field>
<field name="name">Net HST/GST Payable</field>
<field name="account_type">liability_current</field>
</record>
<record id="acct_215100" model="account.account">
<field name="code">215100</field>
<field name="code">2210</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="code">2220</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="code">2230</field>
<field name="name">Source Deductions Payable — EI</field>
<field name="account_type">liability_current</field>
</record>
<record id="acct_216100" model="account.account">
<field name="code">216100</field>
<field name="code">2310</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="code">2320</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="code">2330</field>
<field name="name">Corporate Tax Instalments Paid</field>
<field name="account_type">asset_current</field>
</record>
<record id="acct_221100" model="account.account">
<field name="code">221100</field>
<field name="code">2510</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="code">2520</field>
<field name="name">Shareholder Loan — Gurpreet (long-term)</field>
<field name="account_type">liability_non_current</field>
<field name="reconcile" eval="True"/>
</record>
<record id="acct_222900" model="account.account">
<field name="code">222900</field>
<field name="code">2590</field>
<field name="name">Due To Associated Corporations</field>
<field name="account_type">liability_current</field>
<field name="reconcile" eval="True"/>
@@ -191,37 +195,37 @@
<!-- ============================================================ -->
<record id="acct_311100" model="account.account">
<field name="code">311100</field>
<field name="code">3010</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="code">3020</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="code">3030</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="code">3510</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="code">3520</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="code">3590</field>
<field name="name">Dividends Declared</field>
<field name="account_type">equity</field>
</record>
@@ -231,115 +235,115 @@
<!-- ============================================================ -->
<record id="acct_411100" model="account.account">
<field name="code">411100</field>
<field name="code">4010</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="code">4020</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="code">4030</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="code">4040</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="code">4050</field>
<field name="name">Setup / Onboarding Fees Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_412100" model="account.account">
<field name="code">412100</field>
<field name="code">4110</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="code">4120</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="code">4130</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="code">4140</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="code">4150</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="code">4160</field>
<field name="name">Business App / Integration Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_413100" model="account.account">
<field name="code">413100</field>
<field name="code">4210</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="code">4220</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="code">4230</field>
<field name="name">Technical Support — Per-incident / Hourly Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_414100" model="account.account">
<field name="code">414100</field>
<field name="code">4310</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="code">4320</field>
<field name="name">Hardware Resale Revenue</field>
<field name="account_type">income</field>
</record>
<record id="acct_419100" model="account.account">
<field name="code">419100</field>
<field name="code">4910</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="code">4920</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="code">4930</field>
<field name="name">Bad Debt Recovery</field>
<field name="account_type">income_other</field>
</record>
@@ -348,106 +352,108 @@
<!-- 5xxxxx — DIRECT COSTS (COGS) -->
<!-- ============================================================ -->
<!-- NOTE: 511100 was "Inside Purchases" in l10n_ca (1 posting from legacy bookkeeping) — kept as l10n_ca.
Cloud Infrastructure sits at 511105 to avoid collision. -->
<!-- NOTE: legacy l10n_ca "Inside Purchases" at 511100 (had 1 historical
posting) was renamed to 511100.OLD via scripts/fix_gl_codes.py —
Cloud Infrastructure now claims the clean 511100 code. XMLID
acct_511105 preserved from initial install. -->
<record id="acct_511105" model="account.account">
<field name="code">511105</field>
<field name="code">5010</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="code">5020</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="code">5030</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="code">5040</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="code">5050</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="code">5060</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="code">5070</field>
<field name="name">DNS &amp; Email Hosting (wholesale for resale)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_511200" model="account.account">
<field name="code">511200</field>
<field name="code">5110</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="code">5120</field>
<field name="name">Per-customer Licensing &amp; Royalties</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_512100" model="account.account">
<field name="code">512100</field>
<field name="code">5210</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="code">5220</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="code">5230</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="code">5240</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="code">5250</field>
<field name="name">Project Hardware (passed through)</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_513100" model="account.account">
<field name="code">513100</field>
<field name="code">5310</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="code">5320</field>
<field name="name">Cost of Hardware Resold</field>
<field name="account_type">expense_direct_cost</field>
</record>
<record id="acct_519100" model="account.account">
<field name="code">519100</field>
<field name="code">5910</field>
<field name="name">COGS Adjustments / Write-offs</field>
<field name="account_type">expense_direct_cost</field>
</record>
@@ -457,349 +463,349 @@
<!-- ============================================================ -->
<record id="acct_611100" model="account.account">
<field name="code">611100</field>
<field name="code">6010</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="code">6020</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="code">6030</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="code">6040</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="code">6050</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="code">6060</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="code">6070</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="code">6080</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="code">6090</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="code">6091</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="code">6092</field>
<field name="name">Vacation Pay Accrual</field>
<field name="account_type">expense</field>
</record>
<record id="acct_612100" model="account.account">
<field name="code">612100</field>
<field name="code">6110</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="code">6120</field>
<field name="name">Contract Labour — Foreign</field>
<field name="account_type">expense</field>
</record>
<record id="acct_621100" model="account.account">
<field name="code">621100</field>
<field name="code">6210</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="code">6220</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="code">6230</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="code">6240</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="code">6250</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="code">6260</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="code">6270</field>
<field name="name">Office Snacks &amp; Refreshments</field>
<field name="account_type">expense</field>
</record>
<record id="acct_631100" model="account.account">
<field name="code">631100</field>
<field name="code">6310</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="code">6320</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="code">6330</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="code">6340</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="code">6350</field>
<field name="name">Software — Sales &amp; Marketing</field>
<field name="account_type">expense</field>
</record>
<record id="acct_641100" model="account.account">
<field name="code">641100</field>
<field name="code">6410</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="code">6420</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="code">6430</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="code">6440</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="code">6450</field>
<field name="name">Website — Own (nexasystems.ca)</field>
<field name="account_type">expense</field>
</record>
<record id="acct_651100" model="account.account">
<field name="code">651100</field>
<field name="code">6510</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="code">6520</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="code">6530</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="code">6540</field>
<field name="name">Business Consulting</field>
<field name="account_type">expense</field>
</record>
<record id="acct_661100" model="account.account">
<field name="code">661100</field>
<field name="code">6610</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="code">6620</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="code">6630</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="code">6640</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="code">6650</field>
<field name="name">Insurance — Directors &amp; Officers</field>
<field name="account_type">expense</field>
</record>
<record id="acct_671100" model="account.account">
<field name="code">671100</field>
<field name="code">6710</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="code">6720</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="code">6730</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="code">6740</field>
<field name="name">Mileage Reimbursement — Personal Vehicle</field>
<field name="account_type">expense</field>
</record>
<record id="acct_681100" model="account.account">
<field name="code">681100</field>
<field name="code">6810</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="code">6820</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="code">6830</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="code">6840</field>
<field name="name">Professional Memberships &amp; Dues</field>
<field name="account_type">expense</field>
</record>
<record id="acct_691100" model="account.account">
<field name="code">691100</field>
<field name="code">6910</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="code">6920</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="code">6930</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="code">6940</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="code">6950</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="code">6960</field>
<field name="name">Late Payment Penalties — Non-deductible</field>
<field name="account_type">expense</field>
</record>
<record id="acct_699100" model="account.account">
<field name="code">699100</field>
<field name="code">7010</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="code">7020</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="code">7030</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="code">7040</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="code">7050</field>
<field name="name">Depreciation / CCA Expense</field>
<field name="account_type">expense</field>
</record>

View File

@@ -1,5 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<!-- Tax substitutions on each fiscal position are configured by the
post_init_hook (_configure_fiscal_position_tax_maps) because the
source/destination tax IDs are resolved at runtime from the
curated active tax set. -->
<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 apply for non-CA / non-US customers. Auto-apply by country-group requires a 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 a valid exemption certificate on file. Record certificate details in the partner notes.</field>
</record>
</data>
</odoo>

View File

@@ -1,5 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<record id="pc_services" model="product.category">
<field name="name">Services</field>
</record>
<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_tech_support" model="product.category">
<field name="name">Technical Support — Hourly</field>
<field name="parent_id" ref="pc_services"/>
<field name="property_account_income_categ_id" ref="acct_413300"/>
</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>
<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>

View File

@@ -1,5 +1,12 @@
<?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>

View File

@@ -1,5 +1,32 @@
<?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>

View File

@@ -1,5 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="0">
<record id="rule_aws" model="account.reconcile.model">
<field name="name">AWS / Amazon Web Services → Cloud Infrastructure</field>
<field name="trigger">manual</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_511105'),
'amount_type': 'percentage',
'amount_string': '100',
'label': 'AWS — Cloud Infrastructure',
})]"/>
</record>
<record id="rule_hetzner" model="account.reconcile.model">
<field name="name">Hetzner → Cloud Infrastructure</field>
<field name="trigger">manual</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_511105'),
'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="trigger">manual</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_511105'),
'amount_type': 'percentage',
'amount_string': '100',
'label': 'DigitalOcean — Cloud Infrastructure',
})]"/>
</record>
<record id="rule_cloudflare" model="account.reconcile.model">
<field name="name">Cloudflare → CDN &amp; Edge</field>
<field name="trigger">manual</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>
<record id="rule_github" model="account.reconcile.model">
<field name="name">GitHub → Software (Dev Tools)</field>
<field name="trigger">manual</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>
<record id="rule_microsoft" model="account.reconcile.model">
<field name="name">Microsoft / M365 → Software (Productivity)</field>
<field name="trigger">manual</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>
<record id="rule_stripe_fee" model="account.reconcile.model">
<field name="name">Stripe fee → Merchant Processing</field>
<field name="trigger">manual</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>
<record id="rule_google_ads" model="account.reconcile.model">
<field name="name">Google Ads → Advertising (Digital)</field>
<field name="trigger">manual</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>

View File

@@ -0,0 +1,158 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- noupdate=1: products are seeded on first install but NEVER overwritten
on later -u, so user price/description edits stick. -->
<!-- ============================================================ -->
<!-- Recurring Revenue (4010-4050) -->
<!-- ============================================================ -->
<record id="prod_saas_basic" model="product.template">
<field name="name">SaaS Subscription — Basic</field>
<field name="default_code">SAAS-BASIC</field>
<field name="type">service</field>
<field name="list_price">0.00</field>
<field name="categ_id" ref="pc_saas"/>
<field name="description_sale">Basic SaaS subscription tier. Set monthly price per customer.</field>
</record>
<record id="prod_hosting_small" model="product.template">
<field name="name">Hosting — Small (1 vCPU / 2GB RAM / 50GB SSD)</field>
<field name="default_code">HOST-S</field>
<field name="type">service</field>
<field name="list_price">49.00</field>
<field name="categ_id" ref="pc_hosting"/>
<field name="description_sale">Small managed hosting tier. Suitable for small websites, dev/staging environments.</field>
</record>
<record id="prod_hosting_medium" model="product.template">
<field name="name">Hosting — Medium (2 vCPU / 8GB RAM / 200GB SSD)</field>
<field name="default_code">HOST-M</field>
<field name="type">service</field>
<field name="list_price">149.00</field>
<field name="categ_id" ref="pc_hosting"/>
<field name="description_sale">Medium managed hosting tier. Production websites, small apps.</field>
</record>
<record id="prod_hosting_large" model="product.template">
<field name="name">Hosting — Large (4 vCPU / 16GB RAM / 500GB SSD)</field>
<field name="default_code">HOST-L</field>
<field name="type">service</field>
<field name="list_price">299.00</field>
<field name="categ_id" ref="pc_hosting"/>
<field name="description_sale">Large managed hosting tier. Production SaaS, heavier workloads.</field>
</record>
<record id="prod_support_retainer" model="product.template">
<field name="name">Support Contract — Monthly Retainer (4 hrs)</field>
<field name="default_code">SUPPORT-RET</field>
<field name="type">service</field>
<field name="list_price">640.00</field>
<field name="categ_id" ref="pc_support"/>
<field name="description_sale">4 hours/month support retainer. Unused hours expire monthly. Overage billed at standard rate.</field>
</record>
<record id="prod_setup_fee" model="product.template">
<field name="name">Setup / Onboarding Fee</field>
<field name="default_code">SETUP-FEE</field>
<field name="type">service</field>
<field name="list_price">500.00</field>
<field name="categ_id" ref="pc_setup_fee"/>
<field name="description_sale">One-time setup &amp; onboarding fee for new SaaS / hosting / support customers.</field>
</record>
<!-- ============================================================ -->
<!-- Project Revenue (4110-4160) — hourly -->
<!-- ============================================================ -->
<record id="prod_dev_software" model="product.template">
<field name="name">Custom Software Development — Hourly</field>
<field name="default_code">DEV-SOFTWARE</field>
<field name="type">service</field>
<field name="list_price">160.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="categ_id" ref="pc_custom_software"/>
<field name="description_sale">Custom software development billed hourly. Quote on scope; final billing on actual hours.</field>
</record>
<record id="prod_dev_webapp" model="product.template">
<field name="name">Custom Web Application Development — Hourly</field>
<field name="default_code">DEV-WEBAPP</field>
<field name="type">service</field>
<field name="list_price">160.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="categ_id" ref="pc_webapp"/>
<field name="description_sale">Custom web application development (frontend + backend) billed hourly.</field>
</record>
<record id="prod_dev_website" model="product.template">
<field name="name">Custom Website Development — Hourly</field>
<field name="default_code">DEV-WEBSITE</field>
<field name="type">service</field>
<field name="list_price">160.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="categ_id" ref="pc_website"/>
<field name="description_sale">Custom website development (corporate sites, marketing sites) billed hourly.</field>
</record>
<record id="prod_erp_impl" model="product.template">
<field name="name">ERP Implementation &amp; Customization — Hourly</field>
<field name="default_code">ERP-IMPL</field>
<field name="type">service</field>
<field name="list_price">175.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="categ_id" ref="pc_erp"/>
<field name="description_sale">Odoo / ERP implementation, customization, module development billed hourly.</field>
</record>
<!-- ============================================================ -->
<!-- Services Revenue (4210-4230) — hourly -->
<!-- ============================================================ -->
<record id="prod_consulting" model="product.template">
<field name="name">Consulting &amp; Advisory — Hourly</field>
<field name="default_code">CONSULT</field>
<field name="type">service</field>
<field name="list_price">200.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="categ_id" ref="pc_consulting"/>
<field name="description_sale">Architecture, strategy, technology advisory billed hourly.</field>
</record>
<record id="prod_training" model="product.template">
<field name="name">Training &amp; Workshop — Hourly</field>
<field name="default_code">TRAINING</field>
<field name="type">service</field>
<field name="list_price">120.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="categ_id" ref="pc_training"/>
<field name="description_sale">Custom training, workshops, knowledge transfer sessions billed hourly.</field>
</record>
<record id="prod_tech_support" model="product.template">
<field name="name">Technical Support — Per-incident / Hourly</field>
<field name="default_code">TECH-SUPPORT</field>
<field name="type">service</field>
<field name="list_price">160.00</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="categ_id" ref="pc_tech_support"/>
<field name="description_sale">Ad-hoc technical support, troubleshooting, infrastructure setup billed hourly.</field>
</record>
<!-- ============================================================ -->
<!-- Reseller-side placeholders (4310 / 4320) -->
<!-- ============================================================ -->
<record id="prod_resale_software_template" model="product.template">
<field name="name">Third-party Software License (template)</field>
<field name="default_code">RESALE-SW</field>
<field name="type">service</field>
<field name="list_price">0.00</field>
<field name="categ_id" ref="pc_resale_software"/>
<field name="description_sale">Template for reselling third-party software licenses (M365, Adobe, etc.). Duplicate &amp; set price per item.</field>
</record>
</data>
</odoo>

View File

@@ -4,14 +4,12 @@ import logging
_logger = logging.getLogger(__name__)
# l10n_ca account codes that collide with the Nexa CoA design and that
# l10n_ca pre-loads with 'income_other'/'expense'/etc. types we don't want.
# Each of these is checked at pre_init: if it has zero postings we suffix
# its code with '.OLD' and archive it so our XML can claim the code.
# Codes with postings are LEFT ALONE — we renumbered the Nexa code instead
# (115100 stays as l10n_ca 'Customers Account' AR; Nexa shareholder receivable
# moved to 119100. 511100 stays as l10n_ca 'Inside Purchases'; Nexa Cloud
# Infrastructure moved to 511105).
# l10n_ca account codes that collide with the Nexa CoA design. Each is
# checked at pre_init: if it has zero postings we suffix its code with
# '.OLD' and archive it so our XML can claim the code.
# Codes with postings are LEFT ALONE — we renumbered the Nexa account.
# Currently 115100 stays as l10n_ca 'Customers Account' (240 postings, AR
# control) — Nexa shareholder receivable sits at 115200 instead.
_L10N_CA_COLLISION_CODES = [
"118100", "118200", "118300",
"213100", "214100",
@@ -19,12 +17,18 @@ _L10N_CA_COLLISION_CODES = [
"311100", "311200", "311300",
"411100", "411200", "411300",
"413100", "413200", "413300",
"511110", "511120", "511130", "511140", "511200", "511210",
"511100", "511110", "511120", "511130", "511140", "511200", "511210",
"512100", "512110", "512200",
"611100", "611200", "611300",
"612100", "612200",
]
# Codes that MUST be cleared even if they have postings (force-suffix to .OLD).
# Use sparingly — historical reports lose the original name. Only for codes
# where the Nexa account at that code is the canonical one going forward and
# any prior posting is a misclassification the user will re-class later.
_L10N_CA_FORCE_CLEAR_CODES = {"511100"}
def pre_init_hook(env):
"""Run BEFORE XML data is loaded. Clear l10n_ca account codes that would
@@ -47,7 +51,8 @@ def _clear_l10n_ca_collisions(env):
not_found += 1
continue
usage = env["account.move.line"].search_count([("account_id", "=", acc.id)])
if usage > 0:
force = code in _L10N_CA_FORCE_CLEAR_CODES
if usage > 0 and not force:
_logger.info(
"nexa_coa_setup: keeping l10n_ca account %s (%s) — %d postings exist",
code, acc.name, usage,
@@ -64,6 +69,11 @@ def _clear_l10n_ca_collisions(env):
"active": False,
})
cleared += 1
if force and usage > 0:
_logger.info(
"nexa_coa_setup: force-cleared %s despite %d postings (in FORCE_CLEAR set)",
code, usage,
)
_logger.info(
"nexa_coa_setup: collision sweep — cleared %d, kept-with-postings %d, not-found %d",
cleared, kept_with_postings, not_found,
@@ -79,10 +89,353 @@ def post_init_hook(env):
_normalize_company_hst_number(env)
_archive_unused_l10n_ca_accounts(env)
_rename_legacy_accounts(env)
_archive_unused_taxes(env)
_migrate_tax_repartition_accounts(env)
_configure_fiscal_position_tax_maps(env)
_delete_unused_accounts(env)
_lock_fiscal_year_2025(env)
_logger.info("nexa_coa_setup: post_init_hook complete")
def _delete_unused_accounts(env):
"""Hard-delete every account that's safe to remove.
'Safe' = not owned by nexa_coa_setup AND not referenced by:
- account.move.line (posted entries)
- account.tax.repartition.line
- account.journal default/suspense/profit/loss accounts
- account.fiscal.position.account maps
- product.category property_account_income_categ_id / property_account_expense_categ_id
- product.template property_account_income_id / property_account_expense_id
- res.partner property_account_payable_id / property_account_receivable_id
- res.company income_currency_exchange_account_id / expense_currency_exchange_account_id /
transfer_account_id
Properties in Odoo 19 are stored as JSONB (e.g. {"1": 1234}) per company.
Tries unlink in batches; if a batch fails, falls back to per-record so the
rest still get cleaned. Logs successes and skipped (still-referenced) ids.
"""
# Collect every account-id we must keep
keep = set()
# 1. Nexa-owned
env.cr.execute(
"SELECT res_id FROM ir_model_data WHERE model='account.account' AND module='nexa_coa_setup'"
)
keep.update(r[0] for r in env.cr.fetchall())
# 2. Anything with posted move lines
env.cr.execute(
"SELECT DISTINCT account_id FROM account_move_line WHERE account_id IS NOT NULL"
)
keep.update(r[0] for r in env.cr.fetchall())
# 3. Tax repartition lines
env.cr.execute(
"SELECT DISTINCT account_id FROM account_tax_repartition_line WHERE account_id IS NOT NULL"
)
keep.update(r[0] for r in env.cr.fetchall())
# 4. Journal default/suspense/profit/loss accounts
env.cr.execute("""
SELECT default_account_id FROM account_journal WHERE default_account_id IS NOT NULL
UNION SELECT suspense_account_id FROM account_journal WHERE suspense_account_id IS NOT NULL
UNION SELECT profit_account_id FROM account_journal WHERE profit_account_id IS NOT NULL
UNION SELECT loss_account_id FROM account_journal WHERE loss_account_id IS NOT NULL
""")
keep.update(r[0] for r in env.cr.fetchall())
# 5. Fiscal position account substitution maps
env.cr.execute(
"SELECT account_src_id FROM account_fiscal_position_account WHERE account_src_id IS NOT NULL "
"UNION SELECT account_dest_id FROM account_fiscal_position_account WHERE account_dest_id IS NOT NULL"
)
keep.update(r[0] for r in env.cr.fetchall())
# 6. JSONB property fields on product_category and product_template
for table, col in [
("product_category", "property_account_income_categ_id"),
("product_category", "property_account_expense_categ_id"),
("product_category", "property_stock_valuation_account_id"),
("product_category", "property_price_difference_account_id"),
("product_template", "property_account_income_id"),
("product_template", "property_account_expense_id"),
]:
env.cr.execute(
f"SELECT DISTINCT (jsonb_each_text({col})).value::int "
f"FROM {table} WHERE {col} IS NOT NULL"
)
keep.update(r[0] for r in env.cr.fetchall() if r[0])
# 7. JSONB property fields on res_partner
for col in ("property_account_payable_id", "property_account_receivable_id"):
env.cr.execute(
f"SELECT DISTINCT (jsonb_each_text({col})).value::int "
f"FROM res_partner WHERE {col} IS NOT NULL"
)
keep.update(r[0] for r in env.cr.fetchall() if r[0])
# 8. Company exchange/transfer accounts (regular int columns, not JSONB)
env.cr.execute("""
SELECT income_currency_exchange_account_id FROM res_company
WHERE income_currency_exchange_account_id IS NOT NULL
UNION SELECT expense_currency_exchange_account_id FROM res_company
WHERE expense_currency_exchange_account_id IS NOT NULL
UNION SELECT transfer_account_id FROM res_company
WHERE transfer_account_id IS NOT NULL
UNION SELECT account_default_pos_receivable_account_id FROM res_company
WHERE account_default_pos_receivable_account_id IS NOT NULL
""")
keep.update(r[0] for r in env.cr.fetchall())
# Pick deletion candidates
candidates = env["account.account"].with_context(active_test=False).search([
("id", "not in", list(keep) or [0]),
])
if not candidates:
_logger.info("nexa_coa_setup: no accounts to delete")
return
_logger.info(
"nexa_coa_setup: attempting to delete %d unused accounts (keeping %d protected)",
len(candidates), len(keep),
)
# Try bulk first, fall back to per-record on error
deleted = 0
skipped = 0
try:
with env.cr.savepoint():
candidates.unlink()
deleted = len(candidates)
except Exception:
# Per-record fallback
for acc in candidates:
try:
with env.cr.savepoint():
acc.unlink()
deleted += 1
except Exception as e:
_logger.debug(
"nexa_coa_setup: cannot delete %s (%s): %s",
acc.code, acc.name, e,
)
skipped += 1
_logger.info("nexa_coa_setup: deleted %d accounts, skipped %d (still referenced)", deleted, skipped)
# Map of legacy l10n_ca account codes (some now suffixed '.OLD', some still
# raw codes that were archived) → Nexa consolidated tax accounts.
# Used by _migrate_tax_repartition_accounts to repoint tax repartition lines
# at the new accounts, so when an invoice creates tax journal items they hit
# 118100 (ITC) and 213100 (HST collected) instead of the archived legacy ones.
_TAX_REPARTITION_REMAP = {
# ITC / receivable side → Nexa 1210 HST/GST ITC Receivable (or 1230 QST refund)
"118100.OLD": "1210",
"118200.OLD": "1230",
"118300.OLD": "1210",
"118400": "1210",
"118500": "1210",
# Payable / collected side → Nexa 2110 HST/GST Collected (or 2120 QST collected)
"231000": "2110",
"232000": "2120",
"233000": "2110",
"234000": "2110",
"235000": "2110",
}
def _migrate_tax_repartition_accounts(env):
"""Repoint account_tax_repartition_line.account_id from legacy l10n_ca
accounts to Nexa's consolidated tax accounts (118100 ITC, 213100 HST
collected, 213500 QST collected). Without this, posting an invoice that
uses an active tax (e.g., 13% HST) fails because the repartition still
points at archived accounts.
Idempotent: only touches repartition lines whose current account is in
the legacy set.
"""
legacy_codes = list(_TAX_REPARTITION_REMAP.keys())
legacy_accounts = env["account.account"].with_context(active_test=False).search([
("code", "in", legacy_codes),
])
if not legacy_accounts:
_logger.info("nexa_coa_setup: no legacy tax accounts found; repartition migration skipped")
return
# Build target ID lookup (target accounts must exist and be active)
target_ids = {}
for legacy_code, target_code in _TAX_REPARTITION_REMAP.items():
target = env["account.account"].search([("code", "=", target_code), ("active", "=", True)], limit=1)
if target:
target_ids[legacy_code] = target.id
migrated = 0
for legacy in legacy_accounts:
target_id = target_ids.get(legacy.code)
if not target_id:
continue
rep_lines = env["account.tax.repartition.line"].search([("account_id", "=", legacy.id)])
if rep_lines:
rep_lines.write({"account_id": target_id})
migrated += len(rep_lines)
_logger.info("nexa_coa_setup: migrated %d tax repartition lines to Nexa accounts", migrated)
def _configure_fiscal_position_tax_maps(env):
"""Configure default Ontario taxes on the company + tax substitution maps
on each Nexa fiscal position.
Design: Nexa is Ontario-based, so:
- Default sale tax = '13% HST' (sale)
- Default purchase tax = '13% HST' (purchase)
- Fiscal positions substitute OUT to other rates per customer location:
ON → no substitution (already 13%)
Atlantic → 15% HST
Quebec → 14.975% GST+QST
BC/Prairies/Territories → 5% GST only
US/Export/Exempt → 0% GST
Odoo 19 fiscal position model:
- account.fiscal.position has tax_ids (M2M) — destination taxes
- account.tax has original_tax_ids (M2M) — source taxes it replaces
Idempotent: writes are 'replace' style so re-running cleans up prior runs.
"""
def find_tax(name, use):
return env["account.tax"].search(
[("name", "=", name), ("type_tax_use", "=", use), ("active", "=", True)],
limit=1,
)
HST_13_sale = find_tax("13% HST", "sale")
HST_13_purchase = find_tax("13% HST", "purchase")
HST_15_sale = find_tax("15% HST", "sale")
HST_15_purchase = find_tax("15% HST", "purchase")
QST_sale = find_tax("14.975% GST+QST", "sale")
QST_purchase = find_tax("14.975% GST+QST", "purchase")
GST_5_sale = find_tax("5% GST", "sale")
GST_5_purchase = find_tax("5% GST", "purchase")
ZERO_sale = find_tax("0% GST", "sale")
ZERO_purchase = find_tax("0% GST", "purchase")
# Set company-default sale/purchase taxes to Ontario HST 13% (the clean
# named ones, not the l10n_ca 'HST for sales - 13%' legacy).
company = env.ref("base.main_company", raise_if_not_found=False)
if company:
if HST_13_sale:
company.account_sale_tax_id = HST_13_sale.id
if HST_13_purchase:
company.account_purchase_tax_id = HST_13_purchase.id
_logger.info(
"nexa_coa_setup: set company default sale tax = %s, purchase tax = %s",
HST_13_sale.name if HST_13_sale else "(none)",
HST_13_purchase.name if HST_13_purchase else "(none)",
)
# Each entry: (fp_xmlid, [(source, destination), ...])
# Note: Odoo 19's account.fiscal.position.map_tax treats EMPTY tax_ids as
# "remove ALL taxes" (tax-unit semantics). To get the legacy "pass-through"
# behavior we have to put at least one destination tax in tax_ids — even
# if no substitution is needed. We use a self-mapping (13% HST → 13% HST)
# which is filtered out below as no-op but still populates tax_ids.
fp_map = [
# Ontario is the home: pass-through via self-mapping placeholder
("nexa_coa_setup.fp_ca_ontario", [
(HST_13_sale, HST_13_sale),
(HST_13_purchase, HST_13_purchase),
]),
("nexa_coa_setup.fp_ca_atlantic", [
(HST_13_sale, HST_15_sale),
(HST_13_purchase, HST_15_purchase),
]),
("nexa_coa_setup.fp_ca_quebec", [
(HST_13_sale, QST_sale),
(HST_13_purchase, QST_purchase),
]),
("nexa_coa_setup.fp_ca_bc", [
(HST_13_sale, GST_5_sale),
(HST_13_purchase, GST_5_purchase),
]),
("nexa_coa_setup.fp_ca_prairies_territories", [
(HST_13_sale, GST_5_sale),
(HST_13_purchase, GST_5_purchase),
]),
("nexa_coa_setup.fp_export_us", [
(HST_13_sale, ZERO_sale),
]),
("nexa_coa_setup.fp_export_intl", [
(HST_13_sale, ZERO_sale),
]),
("nexa_coa_setup.fp_tax_exempt", [
(HST_13_sale, ZERO_sale),
]),
]
configured = 0
for fp_xmlid, pairs in fp_map:
fp = env.ref(fp_xmlid, raise_if_not_found=False)
if not fp:
_logger.warning("nexa_coa_setup: fiscal position not found: %s", fp_xmlid)
continue
fp.tax_ids = [(5, 0, 0)]
for src, dst in pairs:
if not src or not dst:
continue
# Always add dst to fp.tax_ids so the FP isn't "empty"
# (Odoo 19 treats empty tax_ids as remove-all-taxes).
fp.tax_ids = [(4, dst.id, 0)]
# If src == dst, no original_tax_ids link needed — the map_tax
# fallback returns the source unchanged when no mapping is found.
if src.id != dst.id and src.id not in dst.original_tax_ids.ids:
dst.original_tax_ids = [(4, src.id, 0)]
configured += 1
_logger.info("nexa_coa_setup: configured tax maps on %d fiscal positions", configured)
# Tax names to keep ACTIVE (covers GST/HST/QST/PST across provinces + zero-rated
# export + exempt). Everything else gets archived if it has zero usage on
# existing journal entries.
_KEEP_TAX_NAMES = {
"5% GST", "13% HST", "14% HST", "15% HST",
"11% GST+PST SK", "12% GST+PST BC", "12% GST+PST MB", "14.975% GST+QST",
"9.975% QST", "7% PST BC", "8% PST MB", "6% PST SK", "5% PST SK",
"0% GST", "0% Exempt", "0% Int",
}
def _archive_unused_taxes(env):
"""Archive active taxes whose name is NOT in _KEEP_TAX_NAMES AND that
have no usage on existing move lines. Preserves audit trail for historical
moves; just hides duplicates and unused defaults from the active set.
"""
keep_names = list(_KEEP_TAX_NAMES)
env.cr.execute(
"""
SELECT t.id
FROM account_tax t
WHERE t.active = true
AND COALESCE(t.name->>'en_US', '') != ALL(%s)
AND NOT EXISTS (
SELECT 1 FROM account_move_line_account_tax_rel r
WHERE r.account_tax_id = t.id
)
""",
(keep_names,),
)
ids = [r[0] for r in env.cr.fetchall()]
if not ids:
_logger.info("nexa_coa_setup: no unused taxes to archive")
return
env["account.tax"].browse(ids).write({"active": False})
_logger.info(
"nexa_coa_setup: archived %d unused taxes (kept set: %d names)",
len(ids), len(keep_names),
)
def _normalize_company_hst_number(env):
"""Convert '741224877' to '741224877 RT0001' if not already in full form."""
company = env.ref("base.main_company", raise_if_not_found=False)
@@ -95,13 +448,81 @@ def _normalize_company_hst_number(env):
def _archive_unused_l10n_ca_accounts(env):
"""Stub — filled in Phase 4. Archives ~370 unused accounts."""
pass
"""Archive l10n_ca accounts that have zero postings and don't belong to
nexa_coa_setup. Preserves history (active=False, never delete).
Idempotent: re-running has no effect on already-archived accounts.
"""
env.cr.execute(
"""
SELECT a.id
FROM account_account a
WHERE a.active = true
AND NOT EXISTS (
SELECT 1 FROM account_move_line aml WHERE aml.account_id = a.id
)
AND NOT EXISTS (
SELECT 1 FROM ir_model_data d
WHERE d.model = 'account.account'
AND d.res_id = a.id
AND d.module = 'nexa_coa_setup'
)
"""
)
ids = [r[0] for r in env.cr.fetchall()]
if not ids:
_logger.info("nexa_coa_setup: no unused accounts to archive")
return
env["account.account"].browse(ids).write({"active": False})
_logger.info("nexa_coa_setup: archived %d unused accounts", len(ids))
# Legacy accounts (from Gurpreet's prior bookkeeping) to rename + archive.
# These all have postings, so we mark them "(LEGACY)" so they stop appearing
# in regular dropdowns but their history is preserved for future
# accountant-driven reconciliation.
_LEGACY_RENAMES = [
# (code, new_name, archive_after)
("1400", "(LEGACY) Transferred to Gurpreet — re-class to 2510", True),
("1505", "(LEGACY) Sent to India — re-class to 6120", True),
("1580", "(LEGACY) Transferred to Westin — Westin is now a partner", True),
("1590", "(LEGACY) Transferred to Divine — Divine is now a partner", True),
("1600", "(LEGACY) Transferred to Manpreet — non-related; archive", True),
("1500", "(LEGACY) Food & Entertainment — re-class to 6720", True),
("1501", "(LEGACY) Office Expenses — re-class to 6250", True),
("411000", "(LEGACY) Inside Sales — re-class to 4xxx specific lines", True),
("412000", "(LEGACY) Harmonized Provinces Sales — handled by tax codes", True),
("413000", "(LEGACY) Non-Harmonized Provinces Sales — handled by tax", True),
("414000", "(LEGACY) International Sales — handled by Zero-rated Export", True),
("12000", "(LEGACY) Abdul & Future Mobility — use partner subledger", True),
("12001", "(LEGACY) MSI Account — use partner subledger", True),
("110010", "(LEGACY) Bank Fee — re-class to 6910", True),
]
def _rename_legacy_accounts(env):
"""Stub — filled in Phase 4. Renames the 14xx/15xx legacy accounts."""
pass
"""Rename + archive the legacy accounts from prior bookkeeping.
Idempotent: accounts already prefixed with '(LEGACY)' are skipped.
"""
renamed = 0
archived = 0
for code, new_name, archive in _LEGACY_RENAMES:
# active_test=False so we also rename accounts that were already
# archived by _archive_unused_l10n_ca_accounts (e.g., 413000 sales bucket).
accs = env["account.account"].with_context(active_test=False).search([("code", "=", code)])
for acc in accs:
if (acc.name or "").startswith("(LEGACY)"):
continue
acc.name = new_name
renamed += 1
if archive and acc.active:
acc.active = False
archived += 1
_logger.info(
"nexa_coa_setup: renamed %d legacy accounts, archived %d",
renamed, archived,
)
def _lock_fiscal_year_2025(env):

View File

@@ -0,0 +1,34 @@
"""Rename the l10n_ca bank/AR/AP/tax legacy accounts to 4-digit codes."""
LEGACY_MAP = [
# (old_code, new_code, expected_name_substring)
("112005", "1010", "Scotia Current"), # Primary operating (most-used)
("112004", "1030", "BMO"), # BMO bank
("112007", "1040", "RBC"), # RBC bank (NOT visa)
("112008", "1070", "Scotia Credit Card"), # Scotia CC
("112006", "1071", "RBC VISA"), # RBC visa
("112002", "1080", "Outstanding Receipts"), # In-transit receipts
("112003", "1081", "Outstanding Payments"), # In-transit payments
("112001", "1090", "Bank Suspense"), # Catch-all suspense
("115100", "1100", "Customers Account"), # AR control
("118310", "1215", "HST receivable - 13%"), # Legacy 13% HST receivable (near new 1210)
("211100", "2010", "Vendors Account"), # AP control
("213310", "2115", "HST to pay - 13%"), # Legacy 13% HST collected (near new 2110)
]
ok = 0; skipped = 0; missing = 0
for old, new, hint in LEGACY_MAP:
acc = env['account.account'].search([('code', '=', old)], limit=1)
if not acc:
print(f"MISS {old} -> {new}: not found")
missing += 1; continue
if hint.lower() not in (acc.name or '').lower():
print(f"SKIP {old} -> {new}: name '{acc.name}' doesn't match '{hint}'")
skipped += 1; continue
conflict = env['account.account'].with_context(active_test=False).search([('code', '=', new)], limit=1)
if conflict:
print(f"SKIP {old} -> {new}: target occupied by {conflict.name}")
skipped += 1; continue
acc.code = new
ok += 1
print(f"OK {old} -> {new}: {acc.name}")
print(f">>> Renumbered {ok}, skipped {skipped}, missing {missing} of {len(LEGACY_MAP)}")
env.cr.commit()

View File

@@ -0,0 +1,171 @@
"""Map current Nexa CoA codes (6-digit) to a clean 4-digit scheme.
Run on prod via odoo-shell. Updates account_account.code in place."""
# (current_code, new_code, expected_name_substring_for_safety)
CODE_MAP = [
# 1xxx ASSETS
("115200", "1120", "Due From Shareholder"),
("115900", "1130", "Due From Associated Corporations"),
("118100", "1210", "Input Tax Credit"),
("118200", "1220", "Instalments Paid"),
("118300", "1230", "QST Input Tax Refund"),
("151100", "1510", "Computer Hardware"),
("151200", "1520", "Office Furniture"),
("151300", "1530", "Vehicles"),
("151400", "1540", "Leasehold Improvements"),
("151500", "1550", "Acquired Software"),
("151600", "1560", "Tools"),
("154100", "1710", "Acc. Depreciation — Computer"),
("154200", "1720", "Acc. Depreciation — Office"),
("154300", "1730", "Acc. Depreciation — Vehicles"),
("154400", "1740", "Acc. Depreciation — Leasehold"),
("154500", "1750", "Acc. Depreciation — Acquired"),
# 2xxx LIABILITIES
("213100", "2110", "HST/GST Collected"),
("213500", "2120", "QST Collected"),
("214100", "2130", "Net HST/GST Payable"),
("215100", "2210", "Source Deductions Payable — Federal"),
("215200", "2220", "Source Deductions Payable — CPP"),
("215300", "2230", "Source Deductions Payable — EI"),
("216100", "2310", "Federal Payable"),
("216200", "2320", "Provincial Payable"),
("216300", "2330", "Tax Instalments Paid"),
("221100", "2510", "Due To Shareholder"),
("221200", "2520", "Shareholder Loan"),
("222900", "2590", "Due To Associated Corporations"),
# 3xxx EQUITY
("311100", "3010", "Common Shares"),
("311200", "3020", "Preferred Shares"),
("311300", "3030", "Contributed Surplus"),
("321100", "3510", "Retained Earnings — Current"),
("321200", "3520", "Retained Earnings — Prior"),
("321900", "3590", "Dividends Declared"),
# 4xxx REVENUE
("411100", "4010", "SaaS Subscription"),
("411200", "4020", "Hosting"),
("411300", "4030", "Support"),
("411400", "4040", "Domain/SSL"),
("411500", "4050", "Setup"),
("412100", "4110", "Custom Software Development"),
("412200", "4120", "Custom Web Application"),
("412300", "4130", "Custom Website"),
("412400", "4140", "ERP Implementation"),
("412500", "4150", "Mobile App"),
("412600", "4160", "Business App"),
("413100", "4210", "Consulting"),
("413200", "4220", "Training"),
("413300", "4230", "Technical Support — Per-incident"),
("414100", "4310", "Third-party Software Resale"),
("414200", "4320", "Hardware Resale"),
("419100", "4910", "Sales Discounts"),
("419200", "4920", "Sales Returns"),
("419300", "4930", "Bad Debt Recovery"),
# 5xxx COGS
("511100", "5010", "Cloud Infrastructure"),
("511110", "5020", "CDN"),
("511120", "5030", "Backup"),
("511130", "5040", "Database"),
("511140", "5050", "Monitoring"),
("511150", "5060", "SSL"),
("511160", "5070", "DNS"),
("511200", "5110", "Third-party API"),
("511210", "5120", "Per-customer Licensing"),
("512100", "5210", "Subcontracted Labour — Canadian"),
("512110", "5220", "Subcontracted Labour — Foreign"),
("512200", "5230", "Project-specific Software"),
("512300", "5240", "Project Travel"),
("512400", "5250", "Project Hardware"),
("513100", "5310", "Cost of Software Resold"),
("513200", "5320", "Cost of Hardware Resold"),
("519100", "5910", "COGS Adjustments"),
# 6xxx OPERATING EXPENSES
("611100", "6010", "Salaries & Wages — Development"),
("611200", "6020", "Salaries & Wages — Sales"),
("611300", "6030", "Salaries & Wages — Admin"),
("611400", "6040", "Salary — Shareholder"),
("611500", "6050", "Employer CPP"),
("611600", "6060", "Employer EI"),
("611700", "6070", "Employer Health Tax"),
("611800", "6080", "WCB"),
("611900", "6090", "Employee Benefits"),
("611950", "6091", "Bonuses"),
("611960", "6092", "Vacation Pay"),
("612100", "6110", "Contract Labour — Canadian"),
("612200", "6120", "Contract Labour — Foreign"),
("621100", "6210", "Rent"),
("621200", "6220", "Home Office"),
("621300", "6230", "Utilities"),
("621400", "6240", "Internet"),
("621500", "6250", "Office Supplies"),
("621600", "6260", "Cleaning"),
("621700", "6270", "Office Snacks"),
("631100", "6310", "Software — Productivity"),
("631200", "6320", "Software — Development Tools"),
("631300", "6330", "Software — Internal Infrastructure"),
("631400", "6340", "Software — Security"),
("631500", "6350", "Software — Sales"),
("641100", "6410", "Advertising — Digital"),
("641200", "6420", "Advertising — Content"),
("641300", "6430", "Trade Shows"),
("641400", "6440", "Promotional"),
("641500", "6450", "Website — Own"),
("651100", "6510", "Legal Fees"),
("651200", "6520", "Accounting"),
("651300", "6530", "Tax Preparation"),
("651400", "6540", "Business Consulting"),
("661100", "6610", "Insurance — Commercial General"),
("661200", "6620", "Insurance — Professional Liability"),
("661300", "6630", "Insurance — Cyber"),
("661400", "6640", "Insurance — Property"),
("661500", "6650", "Insurance — Directors"),
("671100", "6710", "Travel"),
("671200", "6720", "Meals"),
("671300", "6730", "Vehicle — Operating"),
("671400", "6740", "Mileage"),
("681100", "6810", "Conferences"),
("681200", "6820", "Courses"),
("681300", "6830", "Books"),
("681400", "6840", "Professional Memberships"),
("691100", "6910", "Bank Service Charges"),
("691200", "6920", "Merchant Processing"),
("691300", "6930", "Wire Transfer"),
("691400", "6940", "Interest Expense — Bank"),
("691500", "6950", "Interest Expense — Credit Cards"),
("691600", "6960", "Late Payment Penalties"),
# 7xxx OTHER (was 699xxx)
("699100", "7010", "Bad Debt Expense"),
("699200", "7020", "Donations"),
("699300", "7030", "Penalties & Fines"),
("699400", "7040", "Realized FX Losses"),
("699500", "7050", "Depreciation"),
]
ok = 0
skipped = 0
missing = 0
for old_code, new_code, expected_name_part in CODE_MAP:
acc = env['account.account'].search([('code', '=', old_code)], limit=1)
if not acc:
print(f"MISS {old_code}{new_code}: not found")
missing += 1
continue
if expected_name_part.lower() not in (acc.name or '').lower():
print(f"SKIP {old_code}{new_code}: name '{acc.name}' doesn't contain '{expected_name_part}'")
skipped += 1
continue
# Check target code is free
conflict = env['account.account'].with_context(active_test=False).search([('code', '=', new_code)], limit=1)
if conflict:
print(f"SKIP {old_code}{new_code}: target occupied by {conflict.name}")
skipped += 1
continue
acc.code = new_code
ok += 1
print(f">>> Renumbered {ok}, skipped {skipped}, missing {missing} of {len(CODE_MAP)}")
env.cr.commit()

View File

@@ -0,0 +1,40 @@
"""Renumber a few oddly-coded accounts to cleaner positions.
Idempotent — checks before each move."""
moves = [
# (current_code, target_code, expected_name_substr)
('119100', '115200', 'Due From Shareholder'),
('119900', '115900', 'Due From Associated Corporations'),
]
print(">>> Easy renames (no postings)")
for old, new, name_hint in moves:
acc = env['account.account'].search([('code', '=', old)], limit=1)
if not acc:
print(f"SKIP {old}: not found")
continue
if name_hint.lower() not in (acc.name or '').lower():
print(f"SKIP {old}: name doesn't match expected '{name_hint}' (got '{acc.name}')")
continue
conflict = env['account.account'].with_context(active_test=False).search([('code', '=', new)], limit=1)
if conflict:
print(f"SKIP {old}->{new}: target code occupied by {conflict.name}")
continue
acc.code = new
print(f"OK {old} -> {new}: {acc.name}")
# The 511100 swap — rename legacy first, then renumber ours
print(">>> 511105 -> 511100 swap")
legacy = env['account.account'].with_context(active_test=False).search([('code', '=', '511100'), ('name', 'ilike', 'inside purchases')], limit=1)
ours = env['account.account'].search([('code', '=', '511105'), ('name', 'ilike', 'cloud infrastructure')], limit=1)
if not legacy:
print("legacy 511100 not found (already moved?)")
elif not ours:
print("our 511105 not found (already renamed?)")
else:
# Rename legacy first to free the code
legacy.write({'code': '511100.OLD', 'name': f"(l10n_ca LEGACY) {legacy.name}", 'active': False})
ours.code = '511100'
print(f"OK legacy 511100 -> 511100.OLD ({legacy.name})")
print(f"OK 511105 -> 511100 ({ours.name})")
env.cr.commit()
print(">>> done <<<")

View File

@@ -0,0 +1,46 @@
"""Fix invoice 1127 + assign products to proper Nexa categories."""
# 1. Get target account IDs
acct_4320 = env['account.account'].search([('code', '=', '4320')], limit=1) # Hardware Resale
acct_4230 = env['account.account'].search([('code', '=', '4230')], limit=1) # Tech Support hourly
print(f"4320 Hardware Resale id={acct_4320.id}; 4230 Tech Support id={acct_4230.id}")
# 2. Repoint invoice 1127's lines
inv = env['account.move'].browse(62485)
print(f"Invoice {inv.name} (id={inv.id}) - {inv.partner_id.name}")
HW_CODES = {'P620','CUSPC','RTX5090','DRW','N6100','WD8TB','NV3500','UDM-PRO','ST4000VN008','12U-600','18U-600','2UD15','UBCP004'}
SVC_CODES = {'SETUP'}
# Note: posted invoice lines are protected; we update the journal items directly via SQL
hw_changed = sv_changed = 0
for line in inv.line_ids:
if not line.product_id:
continue
sku = line.product_id.default_code
if sku in HW_CODES and line.account_id != acct_4320:
env.cr.execute("UPDATE account_move_line SET account_id = %s WHERE id = %s",
(acct_4320.id, line.id))
hw_changed += 1
elif sku in SVC_CODES and line.account_id != acct_4230:
env.cr.execute("UPDATE account_move_line SET account_id = %s WHERE id = %s",
(acct_4230.id, line.id))
sv_changed += 1
print(f"Repointed {hw_changed} hardware lines -> 4320, {sv_changed} service lines -> 4230")
# 3. Reassign product categories
pc_hardware = env.ref('nexa_coa_setup.pc_resale_hardware')
pc_techsupport = env.ref('nexa_coa_setup.pc_tech_support')
print(f"Hardware category id={pc_hardware.id}; Tech Support category id={pc_techsupport.id}")
prods_changed = 0
for prod in env['product.product'].search([('default_code', 'in', list(HW_CODES | SVC_CODES))]):
sku = prod.default_code
target = pc_hardware if sku in HW_CODES else pc_techsupport
if prod.product_tmpl_id.categ_id != target:
prod.product_tmpl_id.categ_id = target.id
prods_changed += 1
print(f" {sku} {prod.name} -> category '{target.name}'")
print(f"Reassigned {prods_changed} product templates")
env.cr.commit()
print(">>> done <<<")

View File

@@ -0,0 +1,62 @@
"""Reclassify all 200 lines on legacy 411000 to proper Nexa accounts based on
keyword rules. Run on prod via odoo-shell."""
acct_4030 = env['account.account'].search([('code', '=', '4030')], limit=1).id # Support & Maintenance
acct_4230 = env['account.account'].search([('code', '=', '4230')], limit=1).id # Tech Support hourly
acct_4320 = env['account.account'].search([('code', '=', '4320')], limit=1).id # Hardware Resale
legacy_acct = env['account.account'].with_context(active_test=False).search([('code', '=', '411000')], limit=1).id
# (priority, regex pattern (case-insensitive), target_account_id, label)
import re
RULES = [
('Computer & Server Maintenance', acct_4030, 'Support & Maintenance'),
('Server Backup & Monitoring', acct_4030, 'Support & Maintenance'),
('Membership Fee', acct_4030, 'Support & Maintenance (membership)'),
('[CUSTCOMP]', acct_4320, 'Hardware Resale (custom PC)'),
('Custom Computer', acct_4320, 'Hardware Resale (custom PC)'),
('ustom Computer', acct_4320, 'Hardware Resale (typo'), # the 'ustom Computer' typo entry
('HP Desk Computer', acct_4320, 'Hardware Resale (HP desktop)'),
('Server 2019', acct_4320, 'Hardware Resale (Windows Server license)'),
('Server Rack', acct_4320, 'Hardware Resale (rack)'),
('16 Port POE', acct_4320, 'Hardware Resale (switch)'),
('CPU:', acct_4320, 'Hardware Resale (custom build)'),
('Cleaning Supplies', acct_4320, 'Hardware Resale (consumables)'),
('ONSITE-', acct_4230, 'Tech Support — onsite'),
('OFFSITE-', acct_4230, 'Tech Support — offsite'),
('Onsite Sever Setup', acct_4230, 'Tech Support — setup'),
('Server Setup', acct_4230, 'Tech Support — setup'),
('Wiring for', acct_4230, 'Tech Support — installation'),
]
# Find all lines on 411000
env.cr.execute("""
SELECT aml.id, aml.name, aml.credit
FROM account_move_line aml
JOIN account_move m ON m.id = aml.move_id
WHERE aml.account_id = %s AND m.move_type = 'out_invoice'
""", (legacy_acct,))
lines = env.cr.fetchall()
bucket = {acct_4030: 0, acct_4230: 0, acct_4320: 0}
unmatched = []
for line_id, line_name, credit in lines:
name = (line_name or '').strip()
matched = False
for pattern, target, label in RULES:
if pattern.lower() in name.lower():
env.cr.execute("UPDATE account_move_line SET account_id = %s WHERE id = %s",
(target, line_id))
bucket[target] += 1
matched = True
break
if not matched:
unmatched.append((line_id, name[:60], credit))
print(f"Reclassified {bucket[acct_4030]} lines -> 4030 Support & Maintenance")
print(f"Reclassified {bucket[acct_4230]} lines -> 4230 Tech Support — Hourly")
print(f"Reclassified {bucket[acct_4320]} lines -> 4320 Hardware Resale")
print(f"Unmatched: {len(unmatched)}")
for u in unmatched[:20]:
print(f" id={u[0]} amount={u[2]} name={u[1]!r}")
env.cr.commit()
print(">>> done <<<")

View File

@@ -0,0 +1,59 @@
"""Test invoices using Form to emulate UI onchange behavior."""
from odoo.tests.common import Form
import json
results = []
def test_invoice(label, partner_vals, product_name, product_cat_xmlid, price=100.0):
env.cr.execute("SAVEPOINT test")
try:
partner = env['res.partner'].search([('name', '=', partner_vals['name'])], limit=1)
if not partner:
partner = env['res.partner'].create(partner_vals)
cat = env.ref(product_cat_xmlid)
product = env['product.product'].search([('name', '=', product_name)], limit=1)
if not product:
product = env['product.product'].create({
'name': product_name, 'type': 'service',
'list_price': price, 'categ_id': cat.id,
})
with Form(env['account.move'].with_context(default_move_type='out_invoice')) as inv_form:
inv_form.partner_id = partner
with inv_form.invoice_line_ids.new() as line:
line.product_id = product
line.quantity = 1
line.price_unit = price
inv = inv_form.save()
line = inv.invoice_line_ids[0]
results.append({
'label': label,
'fiscal_position': inv.fiscal_position_id.name or '(none)',
'taxes': line.tax_ids.mapped('name'),
'income_account': f"{line.account_id.code} {line.account_id.name}",
'subtotal': inv.amount_untaxed,
'tax_total': inv.amount_tax,
'total': inv.amount_total,
'partner_tags': [c.name for c in partner.category_id],
})
finally:
env.cr.execute("ROLLBACK TO SAVEPOINT test")
test_invoice("Ontario (HST 13%)",
{'name': 'TEST CUST ON', 'country_id': env.ref('base.ca').id, 'state_id': env.ref('base.state_ca_on').id, 'customer_rank': 1},
'TEST SaaS', 'nexa_coa_setup.pc_saas', 100.0)
test_invoice("US (Zero-rated)",
{'name': 'TEST CUST US', 'country_id': env.ref('base.us').id, 'customer_rank': 1},
'TEST SaaS', 'nexa_coa_setup.pc_saas', 100.0)
test_invoice("Quebec (GST+QST)",
{'name': 'TEST CUST QC', 'country_id': env.ref('base.ca').id, 'state_id': env.ref('base.state_ca_qc').id, 'customer_rank': 1},
'TEST SaaS', 'nexa_coa_setup.pc_saas', 100.0)
test_invoice("Intercompany -> Westin",
{'name': 'Westin Healthcare Inc'},
'TEST Consulting', 'nexa_coa_setup.pc_consulting', 150.0)
for r in results:
print("---")
print(json.dumps(r, indent=2, default=str))