metatroncubeswdev d05d80c4df feat(tncsc_deployment): TNCSC client configuration (Phase 7)
Data-only deployment layer seeding TNCSC's branding (navy/orange/electric-
blue from the plan), 5 membership tiers in CAD (Individual $50, Family $80,
Student $20, Senior $30, Life $500 - the plan's own example figures;
update via Settings once TNCSC confirms real pricing), the
'TNCSC-{year}-{seq}' member-ID format, a Canadian (Ontario/HST) chart of
accounts via l10n_ca plus non-profit-specific accounts (Membership Dues/
Event/Sponsorship/School Fees/Donations Revenue, Deferred Event Revenue
liability, a Stripe clearing account) and a Donations journal, bilingual
EN/Tamil overrides of two membership email templates (Tamil text is a
best-effort draft only, explicitly flagged as needing native-speaker
review before go-live), five placeholder website pages (Home/About/Tamil
School/Sponsors/Contact), and TNCSC-named role groups (Board Admin,
Treasurer, Events Officer, School Coordinator, Teacher, Classifieds
Moderator) that imply the existing generic product-layer groups rather
than defining new permission logic.

Chasing the chart-of-accounts setup down to a genuinely working state
took real digging: setting company.country_id on a brand-new company
auto-schedules this Odoo build's own chart-template installer via a
precommit hook (res.company.install_l10n_modules), which races an
explicit try_loading('ca_2023', ...) call made in the same install
transaction and silently replaces its result afterwards (reverting
currency to USD, chart_template to 'generic_coa', and deleting the custom
accounts) - confirmed via raw SQL checks that the correct state exists
right up until the post_init_hook transaction commits, and is gone by the
time the install process exits. Since the precommit auto-trigger only
ever fires once per company (guarded by chart_template being unset), a
second call from a separate transaction is immune to the race. The fix:
the actual setup logic lives in an idempotent res.company._tncsc_setup_
accounting() method (models/res_company.py - a narrow, documented
exception to "no models" in the deployment layer, since cramming this
into a sandboxed ir.cron code string wasn't practical), called as a
best-effort from post_init_hook and guaranteed by a daily safety-net cron
que runs in its own transaction. Also hit two smaller, separate bugs on
the way: ir.cron code execution forbids direct attribute assignment
(STORE_ATTR) in its sandbox, and Html config_parameter fields must not be
wrapped in CDATA in data XML.

Verified end-to-end on a genuinely fresh database (not the long-lived dev
DB, which already has posted entries and correctly refuses a currency
change): installed tncsc_deployment alone, pulling in all 8 product
modules plus l10n_ca as dependencies, confirmed the post-install state via
raw SQL, manually triggered the safety-net cron and confirmed it reached
the fully-correct state (CAD, ca_2023, 355 accounts including all 7
custom ones, the Donations journal, tier products linked to the dues
account), confirmed the cron is idempotent on a second run, and ran the
full test suite (9/9 passing) with the same deterministic setup called
from the test transaction directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 00:53:17 -04:00

106 lines
3.8 KiB
Python

from odoo import models
CUSTOM_ACCOUNT_CODE_START = 800001
REVENUE_ACCOUNT_SPECS = [
('Membership Dues Revenue', 'income'),
('Event Revenue', 'income'),
('Sponsorship Revenue', 'income'),
('School Fees Revenue', 'income'),
('Donations Revenue', 'income_other'),
]
class ResCompany(models.Model):
"""Deliberate, narrow exception to "no models" in the deployment layer:
this is bootstrap-only glue (idempotent, TNCSC-specific, never called by
any product module), not a reusable feature. It exists because this
Odoo build's own chart-template auto-install (triggered the first time a
fresh company's country_id is set, before this module's data even loads)
races with an explicit try_loading() call made in the same install
transaction, and can silently replace it afterwards. A second call from
a separate transaction (the daily cron in data/ir_cron.xml) is immune to
that race, since the auto-trigger only fires once, for a company that
has never had a chart_template set at all.
"""
_inherit = 'res.company'
def _tncsc_setup_accounting(self):
for company in self:
company._tncsc_fix_chart_template()
company._tncsc_create_custom_accounts()
def _tncsc_fix_chart_template(self):
self.ensure_one()
env = self.env
cad = env.ref('base.CAD')
if self.currency_id == cad and self.chart_template == 'ca_2023':
return
self.with_context(chart_template_load=True).write({
'currency_id': cad.id,
'country_id': env.ref('base.ca').id,
'state_id': env.ref('base.state_ca_on').id,
})
env['account.chart.template'].try_loading('ca_2023', self, install_demo=False)
if self.currency_id != cad:
self.currency_id = cad
def _tncsc_create_custom_accounts(self):
self.ensure_one()
env = self.env
Account = env['account.account']
if Account.search_count([('company_ids', 'in', self.id), ('name', '=', 'Membership Dues Revenue')]):
return
def _next_free_code(start):
code = start
while Account.search_count([('company_ids', 'in', self.id), ('code', '=', str(code))]):
code += 1
return str(code)
accounts = {}
next_code = CUSTOM_ACCOUNT_CODE_START
for name, account_type in REVENUE_ACCOUNT_SPECS:
code = _next_free_code(next_code)
next_code = int(code) + 1
accounts[name] = Account.create({
'name': name,
'code': code,
'account_type': account_type,
'company_ids': [(6, 0, [self.id])],
})
deferred_code = _next_free_code(next_code)
next_code = int(deferred_code) + 1
Account.create({
'name': 'Deferred Event Revenue',
'code': deferred_code,
'account_type': 'liability_current',
'company_ids': [(6, 0, [self.id])],
})
stripe_code = _next_free_code(next_code)
Account.create({
'name': 'Stripe Clearing Account',
'code': stripe_code,
'account_type': 'asset_current',
'company_ids': [(6, 0, [self.id])],
})
if not env['account.journal'].search_count([('code', '=', 'DON'), ('company_id', '=', self.id)]):
env['account.journal'].create({
'name': 'Donations',
'code': 'DON',
'type': 'sale',
'company_id': self.id,
'default_account_id': accounts['Donations Revenue'].id,
})
tiers = env['community.membership.tier'].search([])
for tier in tiers:
if tier.product_id:
tier.product_id.property_account_income_id = accounts['Membership Dues Revenue']