Compare commits
415 Commits
phase6_3-a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71f4c41d5c | ||
|
|
2f6a8b33a9 | ||
|
|
4b832e7445 | ||
|
|
f67cefc213 | ||
|
|
658611457e | ||
|
|
4df35448c2 | ||
|
|
1d6797f0d2 | ||
|
|
4622521729 | ||
|
|
40a29081bf | ||
|
|
11ab261ad9 | ||
|
|
00f7e90a3d | ||
|
|
859a327738 | ||
|
|
a52f2bbebd | ||
|
|
9a8e1d7ab5 | ||
|
|
451fc5eafd | ||
|
|
7fcf38ca82 | ||
|
|
64a202ff6e | ||
|
|
13fabb0e79 | ||
|
|
319de06ca6 | ||
|
|
903ceb10d0 | ||
|
|
0499a1ad2e | ||
|
|
4f48bab6e9 | ||
|
|
b616375679 | ||
|
|
5c4a26b65f | ||
|
|
b59ad6b21e | ||
|
|
8a1a09b150 | ||
|
|
a092c385ea | ||
|
|
ca44461b6f | ||
|
|
249adf8145 | ||
|
|
cc568b0ec8 | ||
|
|
17d21bffb5 | ||
|
|
aafc2db8a8 | ||
|
|
6c3830fd4c | ||
|
|
12d383a8c2 | ||
|
|
139e917e09 | ||
|
|
de3e0df5fc | ||
|
|
747c814249 | ||
|
|
c527c7cade | ||
|
|
f7ec1e28f9 | ||
|
|
96b3f124f8 | ||
|
|
2c32e7bcd0 | ||
|
|
aa9b95bd5d | ||
|
|
493f01827e | ||
|
|
2ab59bccde | ||
|
|
914c96a09a | ||
|
|
b015958edc | ||
|
|
ca94a4c42a | ||
|
|
a5ec79013a | ||
|
|
b61e159e6f | ||
|
|
13a892c7ab | ||
|
|
2ee01fd1f2 | ||
|
|
d6d6bbe161 | ||
|
|
31098c4d14 | ||
|
|
1a1ab2da4f | ||
|
|
3f78f652e7 | ||
|
|
e230e42d81 | ||
|
|
06346cfa6b | ||
|
|
a858693d9c | ||
|
|
68b10e1199 | ||
|
|
e260f030d1 | ||
|
|
8d6fcb75a7 | ||
|
|
fef99809e5 | ||
|
|
ea4f216c1a | ||
|
|
db48029e61 | ||
|
|
be721f82ae | ||
|
|
806ec5a5a6 | ||
|
|
0acd2251e6 | ||
|
|
defa7250e1 | ||
|
|
719853c251 | ||
|
|
6a9c7c74ea | ||
|
|
87639a12b5 | ||
|
|
360370db15 | ||
|
|
85bbd8a20e | ||
|
|
136a64ea21 | ||
|
|
a479052b72 | ||
|
|
11108dfea3 | ||
|
|
85cdecddea | ||
|
|
2aaa1a57e7 | ||
|
|
b5d5a9acba | ||
|
|
0d94af6532 | ||
|
|
95abd2e337 | ||
|
|
b1db851e29 | ||
|
|
f18c59fe89 | ||
|
|
2fb774e4fa | ||
|
|
60c25f8241 | ||
|
|
47a6523e24 | ||
|
|
4a9f31cef5 | ||
|
|
dd908c3861 | ||
|
|
5c1f60b3b8 | ||
|
|
55da42e91f | ||
|
|
ab3e6fa1e2 | ||
|
|
e2f7fa6d19 | ||
|
|
2c8ad83d43 | ||
|
|
3fd074ff6d | ||
|
|
e26a7cd9e8 | ||
|
|
09cea73e50 | ||
|
|
3235d4ceca | ||
|
|
5a488ae86e | ||
|
|
55898dd1d4 | ||
|
|
2a16f80d8d | ||
|
|
cecc699a70 | ||
|
|
4949856336 | ||
|
|
9826e03b4e | ||
|
|
69aa6b050b | ||
|
|
5675784916 | ||
|
|
0d4a871d0c | ||
|
|
aac95ee16b | ||
|
|
028814b292 | ||
|
|
2bd0672b52 | ||
|
|
dc1dacddc2 | ||
|
|
6dde3ec2b1 | ||
|
|
a2ac804238 | ||
|
|
f8929eb686 | ||
|
|
a07a5f931a | ||
|
|
c6022c70f9 | ||
|
|
7efaadc1c1 | ||
|
|
21300db8e8 | ||
|
|
1e9ffccd6b | ||
|
|
b2186ab032 | ||
|
|
855b160752 | ||
|
|
da7ec59474 | ||
|
|
2ed3dcee58 | ||
|
|
9b18f77e06 | ||
|
|
1ae83e187e | ||
|
|
1b0657bd76 | ||
|
|
f75e082e67 | ||
|
|
f1273798cd | ||
|
|
bb814a46ff | ||
|
|
be7256ce4c | ||
|
|
d37f10f1c3 | ||
|
|
b98ee8a6fb | ||
|
|
df0de97a68 | ||
|
|
49a0a953e5 | ||
|
|
64eb34cdff | ||
|
|
cd0c08f348 | ||
|
|
6a5364e053 | ||
|
|
ec78fc148d | ||
|
|
9d9be17542 | ||
|
|
1d1bbfe612 | ||
|
|
b1257b6983 | ||
|
|
687decca28 | ||
|
|
307afbf3c0 | ||
|
|
fecd2415f6 | ||
|
|
e36318f7a5 | ||
|
|
feddca19d6 | ||
|
|
95378ff1da | ||
|
|
c8529b8a99 | ||
|
|
7a66d7849d | ||
|
|
9ad09c32b0 | ||
|
|
6b63df8c3d | ||
|
|
72d3130c88 | ||
|
|
f6518b4d7e | ||
|
|
bf6ee2bb2c | ||
|
|
077f898283 | ||
|
|
779539d1b5 | ||
|
|
34a65f9c4a | ||
|
|
97cce8c755 | ||
|
|
fe98fadf61 | ||
|
|
32c7026558 | ||
|
|
76866a7c76 | ||
|
|
f19ca02e05 | ||
|
|
1f5eaf0386 | ||
|
|
a82f09ea50 | ||
|
|
a5144a925c | ||
|
|
2bdf4ef6a0 | ||
|
|
3ba9f2821e | ||
|
|
0104e87750 | ||
|
|
1f818096db | ||
|
|
bb873e8a7a | ||
|
|
d4ef4d55e0 | ||
|
|
fc8963da99 | ||
|
|
c520803c84 | ||
|
|
7349f3180d | ||
|
|
2414b6328e | ||
|
|
5605012245 | ||
|
|
52849777dd | ||
|
|
6f060896bf | ||
|
|
3e0b531110 | ||
|
|
8cc02759b8 | ||
|
|
40b3205274 | ||
|
|
15470426eb | ||
|
|
b22bb11b31 | ||
|
|
134c94fc6c | ||
|
|
f1a2b300f7 | ||
|
|
396170b438 | ||
|
|
eb186cac3c | ||
|
|
4acf9d7f85 | ||
|
|
e596723ba5 | ||
|
|
d7ec91b0f1 | ||
|
|
3e5ced1655 | ||
|
|
aabfc1afe7 | ||
|
|
45b698beb5 | ||
|
|
de6336ba42 | ||
|
|
c876767755 | ||
|
|
d1fc3d8720 | ||
|
|
a78ceaba51 | ||
|
|
6c15a7b1cf | ||
|
|
45ddb444a7 | ||
|
|
9df3262d30 | ||
|
|
5d9609b5ee | ||
|
|
622f133f05 | ||
|
|
482f12256e | ||
|
|
86b8e59c95 | ||
|
|
1b8038d8e8 | ||
|
|
a2d13cf83b | ||
|
|
6f6aa6e90a | ||
|
|
0513ea23a4 | ||
|
|
72aa28e6c4 | ||
|
|
a7cf44249d | ||
|
|
0e6ebe7bc6 | ||
|
|
dced0c66a4 | ||
|
|
2ced576204 | ||
|
|
61a0cb244f | ||
|
|
aeea670064 | ||
|
|
b0836e1c93 | ||
|
|
a32946be44 | ||
|
|
01a85c475c | ||
|
|
43b2edcbb5 | ||
|
|
d770c0c3a9 | ||
|
|
a5db0fe71e | ||
|
|
c44fd89ed1 | ||
|
|
6c395709cf | ||
|
|
0754d0b101 | ||
|
|
2435096f32 | ||
|
|
25952cf226 | ||
|
|
eb1ee85d24 | ||
|
|
1e34a67384 | ||
|
|
a1cfab6fe9 | ||
|
|
a46e31e710 | ||
|
|
032b10752e | ||
|
|
e7d63a3859 | ||
|
|
2b47bd8b10 | ||
|
|
2f74d5ecb9 | ||
|
|
f8abadfc18 | ||
|
|
164b775206 | ||
|
|
b7211468b2 | ||
|
|
fb6cccc8b1 | ||
|
|
ae02164b78 | ||
|
|
a5063cc816 | ||
|
|
89267a9f41 | ||
|
|
e599daf4d9 | ||
|
|
e09913af5a | ||
|
|
416daa36d2 | ||
|
|
b7f280141f | ||
|
|
2b8d99f69d | ||
|
|
18072c9c60 | ||
|
|
1d0d4afdbf | ||
|
|
f5cee25299 | ||
|
|
6351aa6054 | ||
|
|
a7cbd1a6f7 | ||
|
|
9c7b7c54e5 | ||
|
|
48c2a4bfe1 | ||
|
|
4c5ee6143c | ||
|
|
faffdca592 | ||
|
|
15e25ca50b | ||
|
|
c71e61da77 | ||
|
|
0f2ed5cc16 | ||
|
|
1d674e587c | ||
|
|
713ba17e37 | ||
|
|
43abb8ef25 | ||
|
|
27af984f28 | ||
|
|
aab842d6d3 | ||
|
|
a9256dbed7 | ||
|
|
200a2efeb8 | ||
|
|
76a80badff | ||
|
|
095db7375c | ||
|
|
299cae8a4e | ||
|
|
baf5c4158f | ||
|
|
01df46f79f | ||
|
|
92b690aef1 | ||
|
|
08bc2b6a89 | ||
|
|
ad3d6261af | ||
|
|
f04b31cec7 | ||
|
|
5f898d4209 | ||
|
|
807ed86ef6 | ||
|
|
525ed6a61d | ||
|
|
b308380201 | ||
|
|
7da51b4ec8 | ||
|
|
5764d439c3 | ||
|
|
5f372b462a | ||
|
|
67af54b46e | ||
|
|
5a699de1ca | ||
|
|
1b473a7873 | ||
|
|
9223f8da7c | ||
|
|
8c9b645196 | ||
|
|
2aa4bce089 | ||
|
|
46c62ebefa | ||
|
|
152e6d4328 | ||
|
|
33fff5acba | ||
|
|
2ae1c867b5 | ||
|
|
c990110646 | ||
|
|
5872583fbb | ||
|
|
c8db3915ea | ||
|
|
547e7d66a9 | ||
|
|
bfeca0ac32 | ||
|
|
40d563801a | ||
|
|
e271908109 | ||
|
|
72f75fe754 | ||
|
|
6cb352629a | ||
|
|
d53bb73055 | ||
|
|
ff51035494 | ||
|
|
0ed4f88da2 | ||
|
|
caeba27846 | ||
|
|
a2e254b934 | ||
|
|
8b14466da2 | ||
|
|
5a039ae369 | ||
|
|
aab6b9275b | ||
|
|
26a1086623 | ||
|
|
c00831a72a | ||
|
|
3a120dd400 | ||
|
|
4dc0a7cca5 | ||
|
|
4930a89970 | ||
|
|
72f0f182a6 | ||
|
|
5173554281 | ||
|
|
c2b693c97e | ||
|
|
051094813e | ||
|
|
edf3f95854 | ||
|
|
80887d6098 | ||
|
|
5d5964a327 | ||
|
|
80f80fb707 | ||
|
|
bfc138251a | ||
|
|
7dab5fb9c6 | ||
|
|
8d4c85cc52 | ||
|
|
fc17754996 | ||
|
|
0371624afb | ||
|
|
eed1c4619d | ||
|
|
170398ab6f | ||
|
|
d4e95dcd47 | ||
|
|
e1fedf7231 | ||
|
|
9a2975b154 | ||
|
|
271a995455 | ||
|
|
056178b433 | ||
|
|
2285c9def1 | ||
|
|
6afc9e3c0d | ||
|
|
b06d28e7f6 | ||
|
|
7b90f210b9 | ||
|
|
c75d2bde5a | ||
|
|
9e6b88f60e | ||
|
|
dc6afdd021 | ||
|
|
978cd5953e | ||
|
|
b869c31ba3 | ||
|
|
67fc22237b | ||
|
|
d9f2983ea7 | ||
|
|
3120612e35 | ||
|
|
2a93ece4ba | ||
|
|
b26fa13044 | ||
|
|
7ff46af192 | ||
|
|
6d4b6284ad | ||
|
|
d8456fb9a3 | ||
|
|
b41d9629e1 | ||
|
|
10477a7c8f | ||
|
|
8f6302b446 | ||
|
|
87e924d1d8 | ||
|
|
7fab01e5cb | ||
|
|
4911088dc1 | ||
|
|
086ff512b6 | ||
|
|
96e33834bd | ||
|
|
765b095035 | ||
|
|
358b90516b | ||
|
|
dd0dc26232 | ||
|
|
1dea752a29 | ||
|
|
9f3edd60ae | ||
|
|
0b92294586 | ||
|
|
a52ef29a84 | ||
|
|
97deb93ee7 | ||
|
|
b67186a25b | ||
|
|
258782e3c3 | ||
|
|
acc95d8ee0 | ||
|
|
e9b82fbe9d | ||
|
|
c3bcb4b99d | ||
|
|
cfaf4657ce | ||
|
|
7966f8d505 | ||
|
|
839a7f0abc | ||
|
|
0f751d82cc | ||
|
|
aa8161f764 | ||
|
|
31740b3949 | ||
|
|
e99cf20887 | ||
|
|
cc5542833f | ||
|
|
0568d8ae87 | ||
|
|
c2180d3691 | ||
|
|
42036c23ab | ||
|
|
7bcbcb4008 | ||
|
|
0047f49d2c | ||
|
|
5cc1117f75 | ||
|
|
de3ec7d97a | ||
|
|
89a937fb32 | ||
|
|
830b29ce49 | ||
|
|
269f9984ef | ||
|
|
9e5c23f37d | ||
|
|
36cd4341a7 | ||
|
|
c34dfce6c3 | ||
|
|
84ed406c8e | ||
|
|
f4e1f9d218 | ||
|
|
8eb2c2de95 | ||
|
|
bdf676e05a | ||
|
|
6c7e11db4d | ||
|
|
a53b03265d | ||
|
|
560ffa2cdf | ||
|
|
d89546bec7 | ||
|
|
818dfa3882 | ||
|
|
772107d25b | ||
|
|
c61371005a | ||
|
|
7a0bd67fc0 | ||
|
|
efc420b4ce | ||
|
|
fd2b2908f3 | ||
|
|
eb1fd50add | ||
|
|
a60506a645 | ||
|
|
8b9b4d60ad | ||
|
|
a90eace4d0 | ||
|
|
7c2ae84e32 | ||
|
|
63d692b322 | ||
|
|
1a3ca8704e | ||
|
|
d6ebcb6233 | ||
|
|
48805b5988 | ||
|
|
005daade55 |
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Editor / OS noise
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Odoo runtime
|
||||
*.pyc-tmp
|
||||
|
||||
# Local-only diagnostic logs from test runs
|
||||
_test_*.log
|
||||
.superpowers/
|
||||
@@ -77,6 +77,7 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
|
||||
## Cursor-Managed Modules
|
||||
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||
- **fusion_repairs** — status and deferred work: [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) (bundles 1–11 shipped at `19.0.2.2.4`; not production-deployed)
|
||||
|
||||
## Workflow
|
||||
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
|
||||
186
CLAUDE.md
186
CLAUDE.md
@@ -12,9 +12,30 @@
|
||||
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
|
||||
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field.
|
||||
**`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field. **The Odoo 19 replacement for `category_id` is `res.groups.privilege`.** To make a module's groups appear as application-access dropdowns on the user form (Settings → Users → *Application Accesses*) instead of only in developer mode: define an `ir.module.category`, a `res.groups.privilege` (with `category_id` → that category), and set each group's `privilege_id` → that privilege. Groups under one privilege that form an `implied_ids` chain render as a single role dropdown; a standalone group in its own privilege renders as a separate row under the same category header. Verified in `fusion_clock/security/security.xml`; mirrors `fusion_plating`/`fusion_tasks`.
|
||||
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
|
||||
**`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like `<page>`, `<button>`, `<field>` is unrelated and still works.)
|
||||
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
|
||||
7. **Search views**: NO `group expand="0"` syntax.
|
||||
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
|
||||
9. **SQL constraints & indexes**: Odoo 19 dropped `_sql_constraints = [(name, def, msg), ...]` and the `init()`/raw-SQL pattern. Both still parse but only emit a warning and are silently ignored. Use declarative class attributes instead:
|
||||
```python
|
||||
_check_qty_positive = models.Constraint('CHECK (qty > 0)', 'Quantity must be positive.')
|
||||
_user_time_idx = models.Index('(user_id, event_time DESC)')
|
||||
```
|
||||
The attribute name after the leading underscore becomes the SQL object name suffix (`{table}_{suffix}`). `models.Index` accepts `DESC`, `WHERE` predicates, and `USING btree (...)`. Sources: `odoo/orm/model_classes.py` (warns at registry build), `odoo/orm/table_objects.py` (Constraint + Index classes).
|
||||
10. **`res.users._login` is an instance method in Odoo 19**, not a classmethod as in earlier versions. Signature is `def _login(self, credential, user_agent_env)` — there is no `db` parameter. Override it like any normal instance method (`super()._login(credential, user_agent_env)`). When called via `authenticate()` on an empty recordset, `self` carries the right env. Older recipes that build a separate `api.Environment` from `odoo.modules.registry.Registry(db)` no longer apply. Source: `odoo/addons/base/models/res_users.py:760`.
|
||||
11. **Inherited `ir.ui.view` records cannot have `groups`/`group_ids` on the record itself.** Odoo 19 raises `ParseError: Inherited view cannot have 'groups' defined on the record. Use 'groups' attributes inside the view definition` at install time. Move the gate to the inner XML nodes — every `<button>`, `<page>`, `<field>`, `<xpath>`, `<group>` etc. supports a `groups="base.group_system"` attribute. For an inherited form with a smart button + admin tab, put `groups=` on the button and the page individually; leave the `<record model="ir.ui.view">` clean.
|
||||
12. **`mail.template` QWeb/inline_template `ctx` IS `self.env.context`** — not a nested dict you can pass. `MailRenderMixin._render_eval_context()` sets `ctx = self.env.context`, so `ctx.get('foo')` in subject/body resolves to `env.context.get('foo')`. To pass dynamic data to a template, spread keys directly into the context: `tmpl.with_context(**my_data).send_mail(res_id, ...)`. Calling `tmpl.with_context(ctx=my_data)` puts the dict at `env.context['ctx']`, and the template's `ctx.get('foo')` becomes `env.context.get('foo')` → `None` (looks like a silent rendering bug — subject ends up blank).
|
||||
13. **`ir.cron` dropped `numbercall`** in Odoo 19. Old recipes set `<field name="numbercall">-1</field>` for "run forever"; that now raises `ValueError: Invalid field 'numbercall' in 'ir.cron'` at install time. Just omit the field — recurring crons keep running as long as `active=True`. Source: `odoo/addons/base/models/ir_cron.py` field list.
|
||||
14. **`cr.commit()` / `cr.rollback()` raise AssertionError inside `TransactionCase`** — they are NOT silent no-ops in Odoo 19. The test cursor explicitly refuses both ("Cannot commit or rollback a cursor from inside a test, this will lead to a broken cursor when trying to rollback the test. Please rollback to a specific savepoint instead..."). For cron/worker code that needs per-row isolation so one bad row doesn't roll back the whole batch, use `with self.env.cr.savepoint(): ...` inside the loop instead of `cr.commit()`. Savepoints work in both prod (under the outer cron transaction) and tests (under the outer test transaction). The cron transaction commits the whole batch when the method returns; in tests everything rolls back cleanly. Source: `odoo/sql_db.py::TestCursor.commit` and `Cursor.savepoint()`.
|
||||
|
||||
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
|
||||
|
||||
16. **Renaming a module's technical name needs a DB rename, not just a folder rename.** The technical name is baked into the database: `ir_module_module.name`, every external ID in `ir_model_data.module`, each view's `ir_ui_view.key` prefix, and the `ir_module_module_dependency.name` rows of every module that depends on it. Rename only the folder + in-code references and Odoo treats the new name as a fresh uninstalled module — installing it **duplicates** groups/templates/menus and **orphans** all existing data. On every DB that already has it installed, run an in-place SQL rename (the 4 tables above) **before** `-u <newname>`; a fresh DB needs nothing. Reference script + full rationale: [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql) (written for the `fusion_authorizer_portal` → `fusion_portal` rename). Also update cross-module `depends`, `inherit_id="<old>.view"`, `t-call`, `env.ref('<old>.xmlid')`, asset paths (`<old>/static/...`), and `from odoo.addons.<old>... import`.
|
||||
|
||||
17. **`url_encode` (and werkzeug url helpers) are NOT available in the Odoo 19 `mail.template` QWeb render context.** Using `url_encode({...})` inside a template `body_html` (e.g. to build a fallback link) makes the template fail Odoo's save-time render validation **at install**, surfacing as the opaque `ParseError: ... Oops! We couldn't save your template due to an issue with this value: <the entire body html>` (the real `NameError` is hidden, and `--log-handler odoo.tools.convert:DEBUG` does NOT reveal it). Build URLs with plain string methods instead: `'https://…?q=' + (value or '').replace(' ', '+')`. Found installing `fusion_repairs` (post-visit NPS template). **That same opaque "issue with this value" error wraps ANY render failure in a mail.template body** — when you see it, suspect an undefined name / bad field reference in the template, not malformed XML.
|
||||
|
||||
## Card Styling — Copy Odoo's Kanban Pattern
|
||||
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
||||
@@ -75,12 +96,21 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
- Canadian English for all user-facing text
|
||||
- Currency: `$` sign with Monetary fields + currency_id
|
||||
|
||||
## Cursor-Managed Modules
|
||||
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||
## Module-Specific Notes
|
||||
- **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`).
|
||||
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.3.0`** (Plan-1 maintenance foundation added 2026-06-02). **NOT Community-installable** — it transitively pulls in Enterprise `ai` + `knowledge` (`fusion_repairs → fusion_portal → fusion_claims → ai`; `fusion_portal → knowledge`), so it can NOT be installed or tested on local `odoo-modsdev` (Community) — the old `-d fusion-dev -u fusion_repairs` recipe does NOT work. **Test on Enterprise:** an isolated `westin-fr-test` DB on the `odoo-westin` host (clone of prod `westin-v19`; a fresh-DB clone install also needs a one-time orphaned-FK cleanup because prod has orphaned account/tax m2m rows). First-ever clean install surfaced + fixed 2 bugs (url_encode → rule 17; menu parent defined after its children) in commit `903ceb10`. **Not production-deployed** to Westin yet. **Test-runner gotchas on that prod-config container:** `--test-enable` SILENTLY SKIPS all tests without `--workers 0`; the conf's `log_level=warn` hides test output (add `--log-level=test`); the post_install phase also trips on a pre-existing module, so verify behaviour via `odoo shell` rather than the test runner. `mail_template_data.xml` is `noupdate=1` → template edits load on a FRESH install (the prod deploy) but NOT on `-u` of an already-installed DB. Outstanding: maintenance booking (Plan 2), visit log (Plan 3), backfill wizard (Plan 4), office follow-up crons (Plan 5), RingCentral SMS.
|
||||
- **fusion_portal** (formerly `fusion_authorizer_portal`) — authorizer/sales-rep portal; **ENTERPRISE-only** (depends `knowledge` → cannot run on local Community; verify on a westin clone, see *Westin Prod* below). **Assessment-visit flow LIVE on westin, v19.0.2.10.1.** A `fusion.assessment.visit` bundles the assessments from one home visit and, on completion (`action_complete_visit`), groups them by funding workflow (`x_fc_sale_type`) into ONE draft sale order per workflow (MoD/ADP/ODSP/WSIB/private/hardship/insurance) — never one combined SO, never one-per-item-within-a-funding. ADP devices group into one order (combination guard: ≤1 seated {wheelchair/powerchair/scooter} + ≤1 walker); accessibility items group per funding. Reps enter via the "Start a Visit" dashboard tile → `/my/visit/new`; the express/accessibility forms carry `?visit_id=` and defer SO creation to the visit. Renaming the technical name needs a DB rename — see [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql).
|
||||
|
||||
## Workflow
|
||||
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
- Local URL: http://localhost:8069
|
||||
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
- Local URL: http://localhost:8082
|
||||
- **Running module tests requires ephemeral ports.** The dev container's main Odoo process holds 8069 and 8072; a `docker exec ... odoo --test-enable` will die with `Address already in use` unless you also pass `--http-port=0 --gevent-port=0`. This is because Odoo 19 forces `http_spawn()` when `--test-enable` is set, even when `--no-http` is passed. Canonical test invocation:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /<module> \
|
||||
-u <module> --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
- **`fusion_centralize_billing` tests run on odoo-trial (VM 316).** Local dev is Community and cannot install this module. Use `bash scripts/fcb_test_on_trial.sh` from the repo root. The script uses `--http-port 8070` to avoid the port 8069 conflict with the live odoo-trial-app container. Pass = `FCB_EXIT=0`. Takes ~1-2 min.
|
||||
- **Python deps not bundled with `odoo:19` image:** `user_agents` (used by `fusion_login_audit`), and likely others. Install ephemerally with `docker exec -u 0 odoo-modsdev-app pip install <pkg> --break-system-packages`. The install is LOST when the container is recreated (e.g. `docker compose up -d` after a compose edit). When this happens, the symptom is `ModuleNotFoundError` deep in the auth or report code. Re-run the pip install. A persistent fix would be a custom Dockerfile or a startup hook on the compose service — not done yet.
|
||||
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
||||
|
||||
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
|
||||
@@ -110,3 +140,149 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
|
||||
- `fusionapps.issues` — known issues and fixes
|
||||
- `fusionapps.code_snippets` — reference code
|
||||
- `fusionapps.quick_commands` — deployment and admin commands
|
||||
|
||||
## Westin Prod — Deploy & Clone-Verify (fusion_portal et al.)
|
||||
|
||||
Westin prod: host `odoo-westin`, app container `odoo-dev-app`, db container `odoo-dev-db`, DB `westin-v19` (user `odoo`, pw `DevSecure2025!`), addons `/opt/odoo/custom-addons` → `/mnt/extra-addons`, Enterprise `/mnt/enterprise-addons`, conf `/etc/odoo/odoo.conf`. ENTERPRISE env — modules depending on `knowledge` (fusion_portal → fusion_claims) cannot run on local Community, so verify on a clone before prod.
|
||||
|
||||
**Clone-verify a change (prod-safe, isolated — prod files + live DB untouched):**
|
||||
1. Clone online: `docker exec -e PGPASSWORD='DevSecure2025!' odoo-dev-db sh -c 'dropdb -U odoo --if-exists westin-v19-visittest; createdb -U odoo -O odoo westin-v19-visittest && pg_dump -U odoo westin-v19 | psql -U odoo -q -d westin-v19-visittest'` (~2 min, ~152M -Fc).
|
||||
2. Stage the branch module into an isolated dir INSIDE the addons path: `/opt/odoo/custom-addons/_test/<module>`, then `-u <module> --stop-after-init --no-http --db_host db --db_port 5432 --db_user odoo --db_password 'DevSecure2025!' --addons-path=/usr/lib/python3/dist-packages/odoo/addons,/usr/lib/python3/dist-packages/addons,/mnt/extra-addons/_test,/mnt/enterprise-addons,/mnt/extra-addons`. The `/mnt/extra-addons/_test` prefix SHADOWS prod's copy (first matching path wins); deps load from the real `/mnt/extra-addons`.
|
||||
3. Smoke-test via `odoo shell -d westin-v19-visittest` (same addons-path); `env.cr.rollback()` at the end. To exercise email paths WITHOUT sending: `UPDATE ir_mail_server SET active=false;` AND in the shell `env['ir.mail_server'].__class__.send_email = lambda self, message, *a, **k: 'noop'` (`odoo shell` rejects `--smtp-server`).
|
||||
|
||||
**THE ORPHANED-TAX-FK TRAP** (cost real diagnosis time): westin-v19 has ~3300 orphaned rows in `product_taxes_rel` + ~3300 in `product_supplier_taxes_rel` (`tax_id` → deleted `account_tax`), under FKs that are `convalidated=true` (taxes deleted via an FK-bypassing path; PG never re-checks a validated constraint). A plain `pg_dump | psql` clone can't recreate a *validating* FK over orphaned data → the FK is lost on the clone → Odoo `check_foreign_keys` tries to add it → `ForeignKeyViolation: Key (tax_id)=(N) is not present in account_tax` → "Failed to load registry". **Fix ON THE CLONE only:** `DELETE FROM <t> WHERE tax_id NOT IN (SELECT id FROM account_tax)` across every `%_rel` table with a tax column. **Prod `-u` is SAFE without touching the orphans** — prod's FK already exists, so Odoo skips it (it never re-validates a present FK); proven empirically by replicating FK-present+orphan on a clone and running `-u` (exit 0, orphan untouched). Owner is auditing the orphans — do NOT delete them on prod without sign-off.
|
||||
|
||||
**Deploy:** backup (`docker exec ... pg_dump -Fc -U odoo westin-v19 > /opt/odoo/backups/<name>.dump` + `cp -r` the module dir to `/opt/odoo/backups/` — OUTSIDE the addons path, never a `*.bak` dir inside it) → `scp` branch to `/opt/odoo/staging/<module>` → swap into `/opt/odoo/custom-addons/<module>` → `-u <module>` → `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` → `docker restart odoo-dev-app`. **Gate the restart on `-u` exit 0**; on failure restore the dir backup and do NOT restart. When a feature branch predates main's other merges, merge to `main` **surgically** (temp worktree off `origin/main` + `git checkout <branch> -- <module>` → commit → fast-forward push) so you don't revert parallel sessions' work.
|
||||
|
||||
## Fusion Helpdesk — Customer Follow-up + Embedded Inbox (deployment + handoff)
|
||||
|
||||
Two modules: **`fusion_helpdesk`** (client — runs on each client deployment, e.g. entech)
|
||||
and **`fusion_helpdesk_central`** (runs on the central Odoo = nexa). The client forwards
|
||||
tickets to central over **XML-RPC**; central find-or-creates the customer partner +
|
||||
follower; the client shows a server-side-scoped "My Tickets" inbox + systray unread badge.
|
||||
|
||||
### Where each runs / how to deploy
|
||||
- **Central = nexa** (`erp.nexasystems.ca`, VM 315 on pve-worker1, Docker, DB `nexamain`).
|
||||
Source on host: `/opt/odoo/custom-addons/fusion_helpdesk_central`. Upgrade (brief downtime):
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 315 --timeout 590 -- bash -c 'docker stop odoo-nexa-app; docker run --rm --network odoo_odoo-network -v odoo_odoo-data:/var/lib/odoo -v /opt/odoo/custom-addons:/mnt/extra-addons -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons -v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf odoo-nexa:19 odoo -d nexamain -u fusion_helpdesk_central --stop-after-init --http-port=0 --gevent-port=0 > /tmp/up.log 2>&1; docker start odoo-nexa-app'"
|
||||
```
|
||||
Use `;` (not `&&`) before `docker start` so the app ALWAYS restarts even if the upgrade
|
||||
fails. nexa `odoo.conf` has `log_level=warn`, so test/INFO lines are suppressed — verify
|
||||
the result via DB query, not the upgrade log.
|
||||
- **Client = entech** (LXC 111 on pve-worker5, **native systemd `odoo.service`**, DB `admin`,
|
||||
config `/etc/odoo/odoo.conf`, source `/mnt/extra-addons/custom/fusion_helpdesk`). No host
|
||||
bind mount — get files in with `scp` to pve-worker5 then `pct push 111 <file> <dest>`.
|
||||
Upgrade as the `odoo` user (NOT root):
|
||||
```bash
|
||||
pct exec 111 -- bash -lc "systemctl stop odoo; runuser -u odoo -- /usr/bin/odoo --config /etc/odoo/odoo.conf -d admin -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log; systemctl start odoo"
|
||||
```
|
||||
**Backup dir MUST live OUTSIDE the addons path** (e.g. `/root/`). A dir named `*.bak.*`
|
||||
*inside* `/mnt/extra-addons/custom` makes Odoo try to load it as a module →
|
||||
`FileNotFoundError: Invalid module name: fusion_helpdesk.bak.predeploy` → whole registry
|
||||
load fails. (Learned the hard way; auto-rollback restored it.) Current rollback copy:
|
||||
`/root/fh_bak_predeploy`.
|
||||
|
||||
### REQUIRED prerequisite on the central service account (easy to miss)
|
||||
The keystone passes `partner_email`, so central find-or-creates the partner. The XML-RPC
|
||||
service account (**`support@nexasystems.ca`, uid 33** on nexa) MUST have the **Contact
|
||||
Creation** group (`base.group_partner_manager`). Without it, `helpdesk.ticket.create`
|
||||
faults with *"not allowed to create 'Contact' (res.partner)"* for any reporter who isn't
|
||||
already a contact. Granted on nexa 2026-05-27. **Every new client deployment needs this
|
||||
grant on the central account.**
|
||||
|
||||
### Testing lesson
|
||||
Client logic (scope domain, seen model, vals, `_norm_email`) is unit-tested in
|
||||
`fusion_helpdesk/tests/` and runs on local Community (`-d modsdev`). **Smoke tests must
|
||||
call the controller endpoints, not re-implement their logic** — the Phase 6 smoke test
|
||||
replicated `build_scope_domain` directly and so missed a `NameError` (`_norm_email`
|
||||
referenced but never imported) that broke every inbox endpoint. Run
|
||||
`docker exec odoo-modsdev-app python3 -m pyflakes <file>` after editing controllers — it
|
||||
catches undefined names instantly.
|
||||
|
||||
### Two non-obvious gotchas the first ship hit (fixed 2026-05-27 afternoon)
|
||||
1. **`group_reporter_admin` had zero members on install** — `res.groups` doesn't auto-grant
|
||||
to the deployment admin, so the "All (deployment)" toggle never appeared and admins were
|
||||
stuck with the per-user `partner_email` filter. Fix lives in
|
||||
`fusion_helpdesk/security/fusion_helpdesk_groups.xml`: extend `base.group_system.implied_ids`
|
||||
with `(4, ref('fusion_helpdesk.group_reporter_admin'))`. The (4, id) tuple is additive — it
|
||||
never replaces base's existing implied groups. Verified live: all six entech
|
||||
`base.group_system` members now return True for
|
||||
`has_group('fusion_helpdesk.group_reporter_admin')` after the upgrade.
|
||||
2. **Historical tickets had NULL `x_fc_client_label` + NULL `partner_email`** — anything
|
||||
created before the customer-followup ship was invisible in "My Tickets" because the scope
|
||||
filter requires both fields. The reporter identity was preserved only in the description
|
||||
HTML (the diag block's "User" row). Backfill recipe (50 ENTECH + 1 WESTIN, all in one
|
||||
transaction):
|
||||
```sql
|
||||
UPDATE helpdesk_ticket
|
||||
SET x_fc_client_label = substring(name from '^\[([A-Z]+)\]'),
|
||||
partner_email = lower(substring(
|
||||
substring(description from 'User</td><td[^>]*><code>([^<]+)</code>')
|
||||
from ', ([^)]+)\)')),
|
||||
partner_name = regexp_replace(
|
||||
substring(description from 'User</td><td[^>]*><code>([^<]+)</code>'),
|
||||
' \(#\d+, [^)]+\)$', '')
|
||||
WHERE name ~ '^\[[A-Z]+\]'
|
||||
AND description ~ 'User</td>'
|
||||
AND x_fc_client_label IS NULL;
|
||||
```
|
||||
Safe: SQL UPDATE bypasses the central `helpdesk.ticket.create` override, so no duplicate
|
||||
ack emails. Per-deployment label inferred from the `[XXX]` name prefix the old code was
|
||||
already adding. Note: users whose `login != email` (e.g. uid=2 on entech has login
|
||||
`gsinghpal@outlook.com` and email `gs@nexasystems.ca`) get tagged with their *login* in
|
||||
backfill — they won't see their old tickets in "Mine", only in "All". New tickets are
|
||||
tagged with the profile email (`user.email` first, `user.login` fallback).
|
||||
|
||||
### STATUS (handoff 2026-05-27 afternoon)
|
||||
- **Merged to `main`** as squash commit `6c15a7b1` (initial ship). Today's followup is the
|
||||
group/backfill fix described above — committed separately.
|
||||
- **Deployed live**: nexa `fusion_helpdesk_central` **19.0.1.1.0**; entech `fusion_helpdesk`
|
||||
**19.0.1.5.0** (bumped from 19.0.1.4.1 for the implied_ids fix). Both services healthy.
|
||||
- **Historical entech tickets backfilled** on nexa (51 rows: 50 ENTECH + 1 WESTIN).
|
||||
- **Smoke-tested live end-to-end** (entech→nexa): partner resolved + follower + `ENTECH`
|
||||
label, branded ack email queued, support reply visible in thread, inbox scope finds own
|
||||
ticket, no cross-deployment leak. The "Mine" view for non-admins and the "All" view for
|
||||
the entech owner both populate as expected.
|
||||
- **Browser confirmation**: hard-refresh entech (DevTools → Empty Cache and Hard Reload),
|
||||
open the systray helpdesk dialog. The Mine/All toggle appears for the owner; "All" shows
|
||||
all 50 ENTECH tickets, "Mine" shows the count matching the owner's profile email.
|
||||
Tracebacks live in `/var/log/odoo/odoo-server.log` on entech (LXC 111 / pve-worker5).
|
||||
|
||||
## Fusion Centralized Billing (`fusion_centralize_billing`) — engine + test harness
|
||||
|
||||
Odoo (`odoo-nexa`, live DB `nexamain`) is being made the single billing brain for every
|
||||
NexaSystems app (NexaCloud, NexaDesk/Fusion-Chat, NexaMaps), **superseding Lago**. The
|
||||
module adds only the metering + integration layer (service registry, identity links,
|
||||
metric/charge catalog, aggregate-push usage engine, inbound Lago-shaped REST API at
|
||||
`/api/billing/v1/*`, outbound HMAC webhooks, dual-run reconciliation); all financial
|
||||
behaviour is native Odoo **Enterprise** (`sale_subscription` + `payment_stripe` +
|
||||
`account_accountant`). Design + rollout live in `docs/superpowers/specs/`
|
||||
(`2026-05-27-nexa-billing-centralized-design.md` = architecture;
|
||||
`2026-06-02-nexacloud-odoo-billing-cutover-design.md` = NexaCloud pilot: build → import →
|
||||
dual-run → gated flip) and `docs/superpowers/plans/`.
|
||||
|
||||
**Testing it — NOT on local `odoo-modsdev` (community) and NEVER `-u` against live `nexamain`.**
|
||||
It needs Enterprise deps, so tests run on `odoo-nexa` in an **isolated throwaway container**
|
||||
against a **fresh** DB with the Canadian localization:
|
||||
```
|
||||
ssh odoo-nexa
|
||||
# fresh DB (inside odoo-nexa-db): dropdb --if-exists fcb_test; createdb fcb_test
|
||||
cp -a /opt/odoo/custom-addons /opt/odoo/custom-addons-staging # edit/sync HERE, never the live module dir
|
||||
docker run --rm --network odoo_odoo-network \
|
||||
-v /opt/odoo/custom-addons-staging:/mnt/extra-addons:ro -v /opt/odoo/enterprise-addons:/mnt/enterprise-addons:ro \
|
||||
-v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf:ro -v /opt/odoo/staging-data:/var/lib/odoo \
|
||||
odoo-nexa:19 -c /etc/odoo/odoo.conf -d fcb_test --db_host=db --db_user=odoo \
|
||||
--addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/enterprise-addons \
|
||||
--without-demo=all --test-enable --test-tags /fusion_centralize_billing \
|
||||
-i l10n_ca,fusion_centralize_billing --stop-after-init --no-http
|
||||
```
|
||||
Iterate with `-u fusion_centralize_billing` (reuse fcb_test). Gotchas that cost hours:
|
||||
- **`l10n_ca` is required** — the ledger tests need a Canadian CoA + active CAD + 13% HST.
|
||||
- A **prod clone is the wrong base** — its existing rows collide with fixed-code test fixtures
|
||||
(`nexacloud` service / `cpu_seconds` metric) across 5 test files.
|
||||
- odoo.conf sets `log_level=warn`, so **passing tests log nothing** — exit 0 alone does NOT
|
||||
prove tests ran (a tag matching zero tests is also exit 0). Confirm execution with
|
||||
`--log-handler=odoo.addons.fusion_centralize_billing.tests:INFO` (look for `Starting
|
||||
<Class>.<method>`). The **exit code is authoritative** (1 on any failure).
|
||||
- Do **NOT** pass `--workers=0` (blanks captured stdout) or `--logfile=/dev/stdout` (errors out).
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
'website',
|
||||
'mail',
|
||||
'fusion_claims',
|
||||
'fusion_authorizer_portal',
|
||||
'fusion_portal',
|
||||
],
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
|
||||
194
docs/plans/fusion_maintenance_brainstorm.md
Normal file
194
docs/plans/fusion_maintenance_brainstorm.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# fusion_maintenance — Brainstorm & Handoff Brief
|
||||
|
||||
> Status: **research/brainstorm only — no code, no final decisions.** Written from a
|
||||
> Claude Code *web* session that could **not** reach the private network (no Tailscale,
|
||||
> no docker daemon, Supabase KB unreachable). Resume from a **Tailscale-connected env**
|
||||
> (dev box or a host that can reach Westin production) and do the live inspection in
|
||||
> Step 0 **before** committing to the design.
|
||||
|
||||
## Goal (user's words, paraphrased)
|
||||
Automated maintenance follow-ups for mobility/accessibility equipment we've sold, to turn
|
||||
service into **recurring revenue**. Reminder emails → client books maintenance → booking
|
||||
happens in **real time** and **lands in our calendar**. Leverage Odoo Enterprise's
|
||||
appointment system. Decide whether this lives in `fusion_repairs` or a new module — the
|
||||
result must be **seamless and production-ready**.
|
||||
|
||||
## Decisions locked with the user (this session)
|
||||
- **Same DB**: `fusion_claims` + `fusion_repairs` run on one database → new module may depend on both.
|
||||
- **Enterprise `appointment` is available** → build real-time booking ON it (`appointment.type` /
|
||||
`appointment.slot` / `calendar.event`), do **not** hand-roll a calendar.
|
||||
- **Public self-serve booking** → reminder email carries a token link to a no-login slot picker
|
||||
(extend the existing `/repairs/maintenance/book/<token>` pattern). Elderly clients shouldn't log in.
|
||||
- **Target box for grounding = Westin production** (where `fusion_claims` runs day-to-day).
|
||||
|
||||
## Key findings from repo exploration
|
||||
|
||||
### `fusion_repairs` (v19.0.2.2.6) ALREADY has a maintenance engine — reuse it, don't fork
|
||||
- `fusion.repair.maintenance.contract`: interval, due/last-service dates, state machine.
|
||||
Auto-spawned on SO confirm when `product.template.x_fc_maintenance_interval_months > 0`.
|
||||
- Daily reminder cron `cron_maintenance_due_reminders` → 30/7/1-day bands → branded email
|
||||
`email_template_maintenance_due_reminder` with tokenized link `/repairs/maintenance/book/<token>`.
|
||||
- Booking controller: `controllers/portal_maintenance_booking.py` — **single date-confirm form,
|
||||
NO slot availability, NO conflict check, NO calendar event.** ← this is the real gap.
|
||||
- Contract **roll-forward** on technician-task completion (`next_due_date += interval`).
|
||||
- `fusion.repair.service.plan.subscription`: pre-paid visit plans (recurring-revenue primitive).
|
||||
- Deps: `repair, maintenance, sale_management, stock, purchase, website, portal, fusion_tasks,
|
||||
fusion_poynt, fusion_authorizer_portal`. ~8.3k LOC, 25+ models.
|
||||
|
||||
### `fusion_claims` (v19.0.9.2.0) is the ideal trigger source
|
||||
- Claim container = `sale.order` (`x_fc_sale_type`: adp, odsp, wsib, insurance, march_of_dimes, …).
|
||||
- **Equipment unit** = `sale.order.line.x_fc_serial_number` + `product_id`.
|
||||
- **Equipment category** = `fusion.adp.device.code.device_type` (wheelchair, walker, hospital bed,
|
||||
stair lift, porch lift, custom ramp, …) — matches the user's "sale groups".
|
||||
- **Schedule anchors**: `x_fc_adp_delivery_date`, `x_fc_service_start_date`; gate on `x_fc_adp_approved`.
|
||||
- Customer = `sale.order.partner_id`; prescriber = `x_fc_authorizer_id`.
|
||||
- Already depends on `calendar, fusion_tasks, ai, fusion_ringcentral`.
|
||||
|
||||
## Proposed architecture (PENDING live verification)
|
||||
**New module `fusion_maintenance`** depending on `fusion_repairs`, `fusion_claims`, `appointment`.
|
||||
Reuses the existing contract/reminder/roll-forward engine; adds the 3 genuinely-missing pieces:
|
||||
|
||||
1. **`fusion.maintenance.policy`** (ops-configurable, no code per category):
|
||||
`device_type` → `interval_months`, reminder bands, `service_product_id` (priced visit),
|
||||
`appointment_type_id`, required technician skill. Turns "stair lift = 6 mo, $X" into data.
|
||||
2. **Claims bridge** (daily cron): scan `fusion_claims` `sale.order.line` for delivered+approved
|
||||
devices whose `device_type` matches an active policy → ensure a maintenance contract exists,
|
||||
anchored at `delivery_date + interval`. Idempotent (key on serial / sale-line). Extend the
|
||||
reused contract with `x_fc_source_claim_line_id`, `x_fc_device_type`, `x_fc_policy_id` so the
|
||||
repairs path and claims path both feed **one** contract model.
|
||||
3. **Real-time booking on `appointment`**: token link → slot picker backed by `appointment.type`
|
||||
(partner pre-resolved from token, no login). Slot pick → real `calendar.event` → hook spawns
|
||||
`repair.order` + technician task, assigns by skill/zone, advances reminder band, rolls contract
|
||||
forward.
|
||||
|
||||
**Recurring revenue**: each policy carries `service_product_id` → booked visit drafts a priced
|
||||
SO/invoice; optional pre-paid annual plan via existing `service.plan.subscription`; optional
|
||||
door payment via existing `fusion_poynt`.
|
||||
|
||||
## STEP 0 — run on Westin production FIRST (grounding before any decision)
|
||||
> Replace `APP`/`DB` with the real Westin container + database. CLAUDE.md rule #1: never code
|
||||
> from memory — read the real Enterprise `appointment` source before building the booking layer.
|
||||
|
||||
```bash
|
||||
# RESOLVED 2026-06-02 — Westin Odoo prod migrated OFF Digital Ocean onto the on-prem Proxmox
|
||||
# cluster. Old DO IPs (152.42.146.204 / 178.128.229.92) are DEAD (:22 timeout). Live box:
|
||||
# host `odoo-westin` = 192.168.1.40 via the `supabase-prod` Tailscale jump (Windows OpenSSH
|
||||
# ProxyCommand → run `ssh odoo-westin ...` from PowerShell). App container `odoo-dev-app`
|
||||
# (odoo:19, Enterprise); DB container `odoo-dev-db`; DB `westin-v19`; user `odoo` (local-socket
|
||||
# trust inside odoo-dev-db). Enterprise addons → /mnt/enterprise-addons, custom → /mnt/extra-addons.
|
||||
# SQL: ssh odoo-westin 'docker exec odoo-dev-db psql -U odoo -d westin-v19 -c "..."'
|
||||
# FS read: ssh odoo-westin 'docker exec odoo-dev-app sed -n 1,160p /mnt/enterprise-addons/...'
|
||||
APP=odoo-dev-app ; DB=westin-v19 ; DBC=odoo-dev-db
|
||||
|
||||
# 1) Install matrix — confirm same-DB + Enterprise appointment present + versions
|
||||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||
"SELECT name,state,latest_version FROM ir_module_module \
|
||||
WHERE name IN ('fusion_claims','fusion_repairs','fusion_maintenance','calendar','maintenance','repair') \
|
||||
OR name LIKE 'appointment%' ORDER BY name;"
|
||||
|
||||
# 2) Real device_type distribution (drives per-category policies)
|
||||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||
"SELECT device_type, count(*) FROM fusion_adp_device_code GROUP BY device_type ORDER BY 2 DESC;"
|
||||
|
||||
# 3) Locate the Enterprise appointment source (read, don't guess the API)
|
||||
docker exec "$APP" bash -lc 'ls -d /mnt/enterprise-addons/appointment 2>/dev/null || \
|
||||
find / -maxdepth 6 -type d -name appointment 2>/dev/null | grep -i addons | head'
|
||||
|
||||
# 4) Appointment model surface to build booking on (adjust path from #3)
|
||||
docker exec "$APP" cat <appointment_path>/models/appointment_type.py | head -160
|
||||
docker exec "$APP" ls <appointment_path>/controllers/ # find the public booking controller
|
||||
|
||||
# 5) How fusion_repairs maintenance contracts already look in live data
|
||||
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||
"SELECT state, count(*) FROM fusion_repair_maintenance_contract GROUP BY state;"
|
||||
```
|
||||
|
||||
## STEP 0 — RESULTS (ran 2026-06-02 against Westin prod `westin-v19`)
|
||||
> Grounding facts only — **no design decisions made**. These correct several assumptions above.
|
||||
|
||||
**Connection (resolved):** host `odoo-westin` (192.168.1.40) via the `supabase-prod` Tailscale jump.
|
||||
App container `odoo-dev-app` (odoo:19, Enterprise), DB container `odoo-dev-db`, DB `westin-v19`,
|
||||
user `odoo`. Old Digital Ocean boxes are DEAD — Westin migrated on-prem.
|
||||
|
||||
**1) Install matrix** — `appointment` **19.0.1.3 installed** (+ `appointment_account_payment`,
|
||||
`_crm`, `_hr`, `_microsoft_calendar`, `_sms`). All deps present: `calendar`, `maintenance`, `repair`,
|
||||
`sale_management`, `portal`, `website`, `resource`, `phone_validation`, `web_gantt`. `fusion_claims`
|
||||
**19.0.9.2.0 installed**. `fusion_repairs` and `fusion_maintenance` are **absent entirely** (no
|
||||
records). → a module depending on `appointment` installs cleanly; "reuse the fusion_repairs engine"
|
||||
means *deploy fusion_repairs to Westin first* (heavy) **or** own a lean contract model here. Note
|
||||
Odoo's native `maintenance` (CMMS) is installed — an under-considered third reuse option.
|
||||
|
||||
**2) device_type** — 119 distinct values, but `fusion.adp.device.code` is the ADP billing-code
|
||||
**CATALOG** (`_order='device_type, device_code'`), so counts are catalog codes per type, **NOT units
|
||||
installed**. Top entries are seating COMPONENTS (Seat Cushion 564, Back Support 375, Headrest 193).
|
||||
The maintainable **equipment classes** ≈ wheelchairs (manual + power tilt), power bases, power
|
||||
scooters, wheeled walkers / walking frames, paediatric standing frames, specialty strollers (~6-8
|
||||
clean categories). → `device_type` can't be a 1:1 policy key (119 values, mostly parts); needs a
|
||||
grouping/whitelist. **Real install base sized on `sale.order.line`** (`x_fc_adp_device_type` [stored compute from
|
||||
product's `x_fc_adp_device_code_id.device_type`], `x_fc_serial_number`, `x_fc_adp_approved`; delivery
|
||||
dates `x_fc_adp_delivery_date` / `x_fc_service_start_date`) — **see the Install-base sizing block below.**
|
||||
|
||||
**3) + 4) Enterprise appointment source** — `/mnt/enterprise-addons/appointment`. The no-login token
|
||||
slot-picker is **mostly NATIVE — don't hand-roll it**: public booking (`auth="public"`), invite
|
||||
tokens (`appointment.invite`, `/appointment/<id>?…invite_token`), live availability
|
||||
(`/appointment/<id>/update_available_slots`, jsonrpc/public), slot submit → real `calendar.event`
|
||||
(`/appointment/<id>/submit`), auto/manual staff+resource assignment, capacity, booked/cancelled mail
|
||||
templates. Model `appointment.type`; controller `controllers/appointment.py`. → the module mainly
|
||||
needs to: seed an `appointment.type` per category, drop a partner-bound invite link into the reminder
|
||||
email, and hook `calendar.event` create → spawn the service task + advance the contract.
|
||||
`appointment_account_payment` is installed → native pay-to-book is on the table for the revenue mechanic.
|
||||
|
||||
**5) Maintenance-contract state** — `relation "fusion_repair_maintenance_contract" does not exist`
|
||||
→ confirms the fusion_repairs maintenance engine is **not** on Westin.
|
||||
|
||||
**Headline correction:** Westin's ADP data has **zero** stair lifts / porch lifts / ramps / hospital
|
||||
beds — those belong to the fusion_repairs / EN-Tech (mobility) domain. Westin's recurring-revenue
|
||||
play is **wheelchairs / power bases / scooters / walkers / seating**. Open questions updated below.
|
||||
|
||||
**Install-base sizing (ran 2026-06-02 — the REAL units, complementing #2's catalog counts).** Big tell:
|
||||
serial numbers are captured **~only on actual equipment** (every part/option/mod device_type shows 0
|
||||
serials), so `x_fc_serial_number` is already a de-facto "trackable unit" marker — convenient, because the
|
||||
bridge's idempotency key is the serial.
|
||||
|
||||
- **Addressable base ≈ 138 serial-tracked units across ~136 customers** (all funders). By equipment
|
||||
family (serial-tracked / of which delivered): **Walkers & walking frames 68 (55)**, **Wheelchairs 45
|
||||
(40)**, **Power bases 7 (6)**, **Scooters 4 (3)**, plus **14 units with no ADP device_type** (likely
|
||||
private-pay) and 1 misc.
|
||||
- **Funder split** (serial-tracked): adp 109, direct_private 13, adp_odsp 10, march_of_dimes 7;
|
||||
wsib / insurance / standalone-odsp / rental / regular = **0 serials**. → an ADP-only gate
|
||||
(`x_fc_adp_approved`) captures ~110 and **misses ~28** real units. The bridge should likely key on
|
||||
**serial (funder-agnostic)**, not approval.
|
||||
- **Two data gaps the design must absorb:** (a) the 14 serial units with no ADP device_type can't be
|
||||
classified by a device_type→policy map → need a product-level or manual category override; (b) non-ADP
|
||||
units have no `x_fc_adp_delivery_date` → the contract anchor (`delivery_date + interval`) needs a
|
||||
fallback (invoice/order date).
|
||||
- Deliveries span **2022-10 → 2026-05** (active program) — history to anchor intervals + a live pipeline.
|
||||
- Top serial-tracked device_types: Adult Wheeled Walker Type 3 (47), Adult Manual Dynamic Tilt Type 5
|
||||
Wheelchair (23), Adult Lightweight Performance Type 3 (11), Adult Lightweight Standard Type 1 (10),
|
||||
Adult Wheeled Walker Type 2 (9), Adult Power Base Type 3 (5), Power Scooter (3). (1 line ≈ 1 unit;
|
||||
equipment device_types are 1 base line each.)
|
||||
|
||||
## Open questions to resolve with the user (in the connected session)
|
||||
- **MVP cut**: which categories first? Sizing surfaces a real tension: **by volume** it's walkers (68) +
|
||||
wheelchairs (45) ≈ 82% of the base, but rollators/walkers are mechanically low-service; **by
|
||||
service-revenue-per-unit** the targets are the powered units (power bases 7 + scooters 4 + power
|
||||
wheelchairs) — high maintenance value but only ~11–15 units today. Volume vs. margin — or phase it
|
||||
(powered units first to prove the booking loop, then walkers/manual chairs for reach)?
|
||||
- **Revenue mechanic**: auto-draft a priced SO/invoice per booking, vs. pre-paid annual plan, vs.
|
||||
pay-at-door via Poynt — which is the default?
|
||||
- **Technician assignment**: auto-assign by skill+zone at booking time, or leave dispatch manual
|
||||
(fusion_tasks) and only reserve the calendar slot?
|
||||
- **Booking-portal strategy**: Step 0 shows Enterprise `appointment` already ships public,
|
||||
token-based real-time booking (`appointment.invite` + `/appointment/<id>/...`, `auth="public"`).
|
||||
Ride on that (generate an invite per reminder, partner pre-bound, no login) vs. a custom
|
||||
`/maintenance/book/<token>` route? (The `/repairs/...` route is moot — fusion_repairs isn't on Westin.)
|
||||
|
||||
## Applicable CLAUDE.md rules (don't relearn the hard way)
|
||||
- Rule #1: read reference files from the running instance before coding (esp. the appointment source).
|
||||
- Odoo 19: `res.users.group_ids` (not `groups_id`); `ir.cron` has no `numbercall`; declarative
|
||||
`models.Constraint`/`models.Index`; HTTP routes `type="jsonrpc"`; OWL uses standalone `rpc()`.
|
||||
- No `sale.subscription` model exists — a subscription is a `sale.order` with `is_subscription=True`.
|
||||
- New fields use `x_fc_` prefix; Canadian English; `$` Monetary + `currency_id`.
|
||||
- Route attachment opens through `fusion_pdf_preview` (`att.action_fusion_preview(...)`).
|
||||
- Tests need `--http-port=0 --gevent-port=0`. Westin prod is Enterprise; local dev is Community
|
||||
(so the appointment-dependent module can't be installed/tested on `odoo-modsdev-app`).
|
||||
166
docs/superpowers/2026-05-27-fusion-billing-session-handoff.md
Normal file
166
docs/superpowers/2026-05-27-fusion-billing-session-handoff.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# fusion_centralize_billing — Session Handoff (2026-05-27)
|
||||
|
||||
Resume point for the centralized-billing initiative. Read this first, then continue
|
||||
from **"Decision pending"** below.
|
||||
|
||||
## Where we are
|
||||
|
||||
- **Sub-project #1 (core billing engine): DONE and on `main`** (tip `d770c0c3`, pushed to
|
||||
GitHub + Gitea).
|
||||
- 11/11 plan tasks, TDD, Opus code-reviewed; all Critical/High bugs fixed
|
||||
(cross-billing cron → match by `plan_id`; `/usage` authz vs IDOR; input validation →
|
||||
4xx not 500; correct billing-period window; idempotency scoped to `(sub, metric, key)`;
|
||||
webhook sign-exact-bytes + event-id + SSRF guard).
|
||||
- **39 tests green on Odoo 19 Enterprise.**
|
||||
- Note: the 14 billing commits were rebased off the old login-audit/helpdesk stack and
|
||||
landed cleanly on `main`.
|
||||
|
||||
- **`fusion_login_audit`: also landed on `main`** (2026-05-27). Its 19 commits were rebased
|
||||
onto `main` and the `feat/fusion-login-audit` branch was deleted. This also restored
|
||||
Odoo-19 rules #9–14 in `CLAUDE.md`, which had gone missing on `main` when billing landed
|
||||
alone (they were authored alongside login_audit and never existed on the old base).
|
||||
- A concurrent `feat/helpdesk-customer-followup` session still carries pre-landing copies
|
||||
of the billing + login_audit commits; when it merges, replay its helpdesk-only commits
|
||||
onto `main`.
|
||||
|
||||
- **Reference docs (on `main`):**
|
||||
- Spec: `docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`
|
||||
- Core plan: `docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md`
|
||||
|
||||
## Next: sub-project #2 — NexaCloud adapter + dual-run reconciliation
|
||||
|
||||
Per spec §12, each sub-project is its own spec → plan → build cycle. #2 decomposes into
|
||||
four chunks (dependency order):
|
||||
|
||||
| Chunk | What | Risk |
|
||||
|-------|------|------|
|
||||
| **2a — Mapping + importer** | Read `nexacloud` DB → create `res.partner` + `account.link`, `product.template` + subscription plans, one subscription `sale.order` per deployment | **Low** — read-only on NexaCloud, writes only into Odoo |
|
||||
| **2b — Usage metering wiring** | NexaCloud `usage_metering.py` pushes CPU-seconds → Odoo `/usage`; verify aggregation → draft invoice w/ quota + overage + HST | Edits NexaCloud code |
|
||||
| **2c — Control loop** | NexaCloud consumes Odoo's outbound webhooks (`invoice.payment_failed` → suspend via existing `network_isolation`/`throttle_checker`; `subscription.terminated` → deprovision) | Edits NexaCloud code |
|
||||
| **2d — Dual-run reconciliation** | `fusion.billing.reconciliation` diffs Odoo-computed vs NexaCloud-actual per customer/period for ≥ 1 cycle before any flip | Safety gate before flipping real billing |
|
||||
|
||||
The core engine already built the *receiving* side (`/usage`, webhook engine, charge math).
|
||||
#2 is about **connecting NexaCloud to it and proving the numbers match before flipping.**
|
||||
|
||||
## Decision pending (resume here)
|
||||
|
||||
We were in the `superpowers:brainstorming` flow for #2 and stopped at: **which slice to
|
||||
start with?**
|
||||
|
||||
- **(recommended) 2a — Mapping + importer** — lowest risk, foundation for everything else.
|
||||
- 2d — Reconciliation first (front-load the trust mechanism).
|
||||
- Full #2 design as one spec, then one plan.
|
||||
- Just write the #2 plan, no code this session.
|
||||
|
||||
## Open questions to resolve before building #2
|
||||
|
||||
- **Spec §15 Q2 — NexaCloud billing granularity:** confirm **one subscription per
|
||||
deployment** (spec leans this way) vs one subscription per customer with deployment line
|
||||
items.
|
||||
- **Access / environments needed:**
|
||||
- Read access to the `nexacloud` DB schema (LXC 102 / its Postgres on LXC 201) to design
|
||||
the importer mapping.
|
||||
- A NexaCloud staging or safe path for 2b/2c (they edit live NexaCloud code).
|
||||
- Test target for the Odoo side stays the odoo-trial Enterprise sandbox.
|
||||
- **Resolved already:** Stripe is one account (`acct_1ShlA9IkwUB1dVox`) for everything — no
|
||||
account migration (spec §11 / §15 Q1). Branch strategy — land on `main`, branch new work
|
||||
off `main`.
|
||||
|
||||
## How to run / test
|
||||
|
||||
- **Billing tests:** `bash scripts/fcb_test_on_trial.sh` from repo root → pass = `FCB_EXIT=0`
|
||||
(~1–2 min). Syncs the module to the odoo-trial Enterprise sandbox (Proxmox VM 316, db
|
||||
`trial`) and runs `--test-enable`. Local dev Odoo is Community and **cannot** install this
|
||||
module.
|
||||
|
||||
## Branch hygiene (lesson from this session)
|
||||
|
||||
Cut each new feature branch from `main`, and land it before starting the next. For any
|
||||
cross-branch git surgery, use a **throwaway `git worktree`** — never switch the shared
|
||||
working dir's branch, because a concurrent session may be working on it.
|
||||
|
||||
---
|
||||
|
||||
## UPDATE — sub-project #2 complete (2026-05-27, later session)
|
||||
|
||||
All four chunks of #2 are now built. The brainstorm "which slice" question resolved to
|
||||
2a-first; everything else followed.
|
||||
|
||||
**Done + on `main` in `Odoo-Modules` (fully tested on odoo-trial, suite `FCB_EXIT=0`):**
|
||||
- **2a — importer** (`fusion.billing.import.wizard`): read-only `psycopg2` reader split
|
||||
from pure-Odoo writes; users→partners+links, plans→`cpu_seconds` charge catalog
|
||||
(`plan_id` NULL), deployments→one **draft shadow** `sale.order` each with the flat price.
|
||||
Shadow-safe by construction (draft + no token + NULL `plan_id`). Idempotent, dry-run,
|
||||
Test-Connection guard, README runbook.
|
||||
- **2d — reconciliation** (`fusion.billing.reconciliation`): `_compute_reconciliation` +
|
||||
`_reconcile_rows` (Odoo flat+overage vs NexaCloud actual, status match/delta), reader for
|
||||
NexaCloud usage+invoice actuals, "Run Reconciliation" button. **Upsert key is
|
||||
`(service, external_subscription_id, period)`** — per subscription, so a customer with
|
||||
two deployments doesn't collide.
|
||||
- **/usage enabler**: `_api_record_usage` resolves a subscription by the source app's own
|
||||
id (`x_fc_nexacloud_subscription_id`) so NexaCloud can push against shadow subs.
|
||||
- Core-engine bug fixed in passing: `charge.price_per_unit` is now `Float(16,6)` and
|
||||
`_compute_billable` keeps 6-dp precision (was `Monetary`/cent-rounded → would under-bill
|
||||
sub-cent rates and drift from NexaCloud's 4-dp amounts).
|
||||
|
||||
**Code-complete in `Nexa-Cloud` (feature-flagged, NOT deployed, NOT integration-tested):**
|
||||
- **2b — usage push**: `services/odoo_billing_client.py` + a hook in `usage_metering.py`
|
||||
posting cpu-seconds to Odoo `/usage`. **2c — control loop**:
|
||||
`routers/odoo_billing.py` (`POST /api/v1/billing/webhooks/central`, HMAC-verified) +
|
||||
`services/odoo_billing_integration.py` (suspend/restore/deprovision). All INERT unless
|
||||
`ODOO_BILLING_ENABLED`. Implemented as NEW modules + edits to clean files only —
|
||||
NexaCloud `main` had concurrent **Cursor uncommitted WIP** (`routers/billing.py`,
|
||||
`scheduler.py`, `stripe_service.py`, `models/billing.py`, …) which was deliberately not
|
||||
touched. Commits: `94542ec` + `956abb2` (only my files staged).
|
||||
|
||||
**Deployment status (2026-05-27):**
|
||||
- **odoo-nexa (production `nexamain`): DEPLOYED** — `fusion_centralize_billing` (core + 2a
|
||||
+ 2d) **fresh-installed** (#1 had never actually been deployed here; `DIR_ABSENT` before).
|
||||
`ir_module_module.state = installed`, `odoo-nexa-app` healthy. **INERT**: no
|
||||
`nexacloud_dsn`, all charges `plan_id` NULL (rating cron no-op), no webhooks queued
|
||||
(dispatch cron no-op), inbound API 401s with no key configured. Synced to
|
||||
`/opt/odoo/custom-addons` + `-i` via the restart-safe recipe.
|
||||
- **NexaCloud (prod, `vps.nexasystems.ca` / 192.168.1.250): DEPLOYED — INERT.** Did NOT
|
||||
use `./deploy.sh` (it `rsync --delete`s the working tree → would have shipped the
|
||||
concurrent **uncommitted Cursor WIP** (7 files) AND wiped the gitignored prod `.env`
|
||||
files). Instead deployed **surgically**: rsync of ONLY my 6 committed billing files (no
|
||||
`--delete`; `.env` + Cursor's files untouched), `docker compose build backend`,
|
||||
**boot-tested in a throwaway container** (`run --rm --no-deps backend python -c "import
|
||||
app.main"` → BOOT_OK) before swapping, then `up -d backend`. `nexacloud-api` healthy,
|
||||
`/health` OK. Feature OFF: `ODOO_BILLING_ENABLED` unset → `/billing/webhooks/central`
|
||||
returns 404 and no usage is pushed. Activate later by setting `ODOO_BILLING_*` in
|
||||
`/opt/nexacloud/.env` (+ compose env passthrough) once the Odoo side is wired.
|
||||
**NOTE:** Cursor's 7-file WIP remains uncommitted locally and was never deployed — when
|
||||
Cursor finishes, a normal `./deploy.sh` will ship it (and re-sync `.env`).
|
||||
|
||||
**Dual-run stand-up results (2026-05-27) — STOPPED here for review, NOT flipped:**
|
||||
- Read-only role `odoo_billing_ro` created on nexacloud Postgres (192.168.1.50); DSN set in
|
||||
`ir.config_parameter` `fusion_billing.nexacloud_dsn` on nexamain. Test Connection OK
|
||||
(read 7 users / 232 plans / 87 subscriptions).
|
||||
- **Shadow import committed on nexamain**: 7 partners, 232 plan catalogs, 87 draft shadow
|
||||
subscriptions; 0 skipped, 0 failed. (NOTE: importer takes ALL plans/subs regardless of
|
||||
active status → ~464 NC-* products now in the prod ERP catalog. Consider filtering to
|
||||
`is_active` plans / active subscriptions, or prune the shadow records — all reversible.)
|
||||
- **Reconciliation pass**: 9 (sub,period) rows had real billing activity → **2 match, 7
|
||||
delta**, 0 failed. The 7 deltas, MUST resolve before flipping:
|
||||
1. **One-off / non-subscription invoices** (3 rows: $877.99, $872.66, $32.20) — nexacloud
|
||||
invoices with NULL subscription_id (fees/manual/credits); not modeled per-subscription.
|
||||
2. **List-price ≠ actual-invoiced** (4 rows: Odoo $200/$50 vs actual ~$9.1x) — likely
|
||||
proration or NexaCloud invoicing ≠ plan list price.
|
||||
- **2d bug surfaced (analysis-only, not safety):** `_reconcile_rows` with an empty
|
||||
`subscription_external_id` matches NULL-field orders instead of skipping → spurious
|
||||
delta rows for the one-off invoices. Add `if not sub_ext: skip`.
|
||||
|
||||
**Remaining before go-live (gated on infra / ops you do):**
|
||||
1. Grant the read-only DSN (`fusion_billing.nexacloud_dsn`) — see the module README — then
|
||||
Test Connection → dry-run import → review → real import.
|
||||
2. Run a dual-run cycle (Run Reconciliation), confirm all rows `match`.
|
||||
3. **2c needs the Odoo side to actually EMIT** `invoice.payment_failed` /
|
||||
`payment_succeeded` / `subscription.terminated` webhooks with `deployment_id` in the
|
||||
payload — that emission isn't wired yet (it belongs to the live billing flow). The
|
||||
NexaCloud receiver is built to that contract; confirm the payload shape when wiring it.
|
||||
4. Integration-test + deploy the NexaCloud changes (no test harness in that repo).
|
||||
5. The flip: set `charge.plan_id`, attach Stripe tokens, confirm the shadow subs.
|
||||
|
||||
Specs/plans: `specs/2026-05-27-nexacloud-billing-importer-design.md`,
|
||||
`specs/2026-05-27-nexacloud-reconciliation-design.md`, and the matching plans.
|
||||
2703
docs/superpowers/plans/2026-05-26-fusion-login-audit.md
Normal file
2703
docs/superpowers/plans/2026-05-26-fusion-login-audit.md
Normal file
File diff suppressed because it is too large
Load Diff
1104
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
Normal file
1104
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,477 @@
|
||||
# Fusion Helpdesk — Customer Follow-up & Embedded Inbox Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Attach real customer identity to every helpdesk ticket and give client-deployment staff an in-app ticket inbox (read replies + follow up without leaving their Odoo), while external customers use the native Enterprise portal + magic link.
|
||||
|
||||
**Architecture:** Keystone = pass `partner_email`/`partner_name`/`x_fc_client_label` in the ticket-create payload; native helpdesk then creates the partner + subscribes the follower. Client module (`fusion_helpdesk`) gains read/reply RPC endpoints + a tabbed dialog + unread badge, all scoped server-side by the logged-in user's identity. Central module (`fusion_helpdesk_central`) adds the `x_fc_client_label` field + a branded acknowledgement email.
|
||||
|
||||
**Tech Stack:** Odoo 19 (Enterprise on central, Community on client deployments), Python 3.11, OWL 2, XML-RPC client→central, `helpdesk` (Enterprise), `portal.mixin`, `mail.thread.cc`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-fusion-helpdesk-customer-followup-design.md`
|
||||
|
||||
**Testability note:** `fusion_helpdesk` depends only on base/web/mail → installable + testable on local Community (`odoo-modsdev-app`, DB `modsdev`). Pure logic (scope-domain, message filtering, vals builder, unread math) is extracted into `fusion_helpdesk/utils.py` and unit-tested with no live remote. `fusion_helpdesk_central` depends on `helpdesk` (Enterprise) → install/test on the deploy target (odoo-nexa) or odoo-trial.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**`fusion_helpdesk` (client)**
|
||||
- `utils.py` *(new)* — pure helpers: `build_scope_domain`, `is_public_message`, `build_ticket_vals`, `compute_unread_count`. No Odoo env needed → trivially unit-testable.
|
||||
- `controllers/main.py` *(modify)* — keystone payload in `submit()`; new endpoints `my_tickets`, `ticket_detail`, `ticket_reply`, `unread_count`; a mockable `_rpc(model, method, args, kw)` seam.
|
||||
- `models/__init__.py`, `models/fusion_helpdesk_ticket_seen.py` *(new)* — `fusion.helpdesk.ticket.seen` read-tracking model.
|
||||
- `security/ir.model.access.csv` *(modify)* — ACL for the seen model.
|
||||
- `security/fusion_helpdesk_groups.xml` *(new)* — `group_reporter_admin`.
|
||||
- `static/src/js/fusion_helpdesk_dialog.js` *(modify)* — tabs (New / My Tickets), list, thread, reply.
|
||||
- `static/src/xml/fusion_helpdesk_dialog.xml` *(modify)* — tab markup + list/thread/reply templates + confirmed-email field.
|
||||
- `static/src/js/fusion_helpdesk_systray.js` *(modify)* — unread badge.
|
||||
- `static/src/xml/fusion_helpdesk_systray.xml` *(modify)* — badge markup.
|
||||
- `static/src/scss/fusion_helpdesk.scss` *(modify)* — tab/list/thread/badge styles.
|
||||
- `tests/__init__.py`, `tests/test_utils.py`, `tests/test_seen.py` *(new)*.
|
||||
- `__manifest__.py` *(modify)* — version bump, register groups XML + tests dir + new model.
|
||||
|
||||
**`fusion_helpdesk_central` (central)**
|
||||
- `models/__init__.py`, `models/helpdesk_ticket.py` *(new)* — inherit `helpdesk.ticket`, add `x_fc_client_label`.
|
||||
- `views/helpdesk_ticket_views.xml` *(new)* — list column + search filter for `x_fc_client_label`.
|
||||
- `data/mail_template_ack.xml` *(new)* — branded acknowledgement template.
|
||||
- `data/helpdesk_ack_automation.xml` *(new)* OR create-override in `helpdesk_ticket.py` — send ack on create.
|
||||
- `tests/__init__.py`, `tests/test_identity.py` *(new)* — partner resolution + follower + label.
|
||||
- `__manifest__.py` *(modify)* — version bump, register models/views/data/tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Keystone identity
|
||||
|
||||
### Task 1: Pure `build_ticket_vals` helper (client)
|
||||
|
||||
**Files:** Create `fusion_helpdesk/utils.py`; Test `fusion_helpdesk/tests/test_utils.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
```python
|
||||
# fusion_helpdesk/tests/test_utils.py
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
from odoo.addons.fusion_helpdesk.utils import build_ticket_vals
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||
class TestBuildTicketVals(TransactionCase):
|
||||
def test_identity_fields_present(self):
|
||||
vals = build_ticket_vals(
|
||||
kind='bug', subject='X', body_html='<p>b</p>',
|
||||
team_id=1, client_label='ENTECH',
|
||||
reporter_name='John Doe', reporter_email='john@entech.com',
|
||||
company_name='ENTECH Inc',
|
||||
)
|
||||
self.assertEqual(vals['partner_email'], 'john@entech.com')
|
||||
self.assertEqual(vals['partner_name'], 'John Doe')
|
||||
self.assertEqual(vals['x_fc_client_label'], 'ENTECH')
|
||||
self.assertEqual(vals['partner_company_name'], 'ENTECH Inc')
|
||||
self.assertEqual(vals['team_id'], 1)
|
||||
self.assertIn('X', vals['name'])
|
||||
|
||||
def test_no_email_omits_partner_email(self):
|
||||
vals = build_ticket_vals(
|
||||
kind='feature', subject='Y', body_html='<p>b</p>',
|
||||
team_id=False, client_label='', reporter_name='Jane',
|
||||
reporter_email='', company_name='',
|
||||
)
|
||||
self.assertNotIn('partner_email', vals) # never send empty email
|
||||
self.assertNotIn('team_id', vals) # omit falsy team
|
||||
self.assertEqual(vals['partner_name'], 'Jane')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect ImportError/FAIL**
|
||||
Run: `docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_helpdesk -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -30`
|
||||
|
||||
- [ ] **Step 3: Implement `build_ticket_vals`**
|
||||
```python
|
||||
# fusion_helpdesk/utils.py
|
||||
"""Pure helpers for fusion_helpdesk — no Odoo env, unit-testable in isolation."""
|
||||
|
||||
def build_ticket_vals(kind, subject, body_html, team_id, client_label,
|
||||
reporter_name, reporter_email, company_name):
|
||||
"""Construct helpdesk.ticket create vals. Identity fields drive native
|
||||
partner find-or-create + follower subscription on the central Odoo."""
|
||||
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
|
||||
prefix = ('[%s] ' % client_label) if client_label else ''
|
||||
vals = {
|
||||
'name': '%s%s: %s' % (prefix, kind_label, subject or '(untitled)'),
|
||||
'description': body_html,
|
||||
'partner_name': reporter_name or '',
|
||||
}
|
||||
if team_id:
|
||||
vals['team_id'] = team_id
|
||||
if reporter_email:
|
||||
vals['partner_email'] = reporter_email
|
||||
if company_name:
|
||||
vals['partner_company_name'] = company_name
|
||||
if client_label:
|
||||
vals['x_fc_client_label'] = client_label
|
||||
return vals
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — expect PASS** (same command as Step 2)
|
||||
- [ ] **Step 5: Commit** `git add fusion_helpdesk/utils.py fusion_helpdesk/tests/ && git commit -m "feat(fusion_helpdesk): pure build_ticket_vals helper (identity keystone)"`
|
||||
|
||||
### Task 2: Wire keystone into `submit()` (client)
|
||||
|
||||
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||
|
||||
- [ ] **Step 1:** In `submit()`, accept new arg `reply_email=None`. Replace the inline `ticket_vals` block with:
|
||||
```python
|
||||
from odoo.addons.fusion_helpdesk.utils import build_ticket_vals
|
||||
# ...
|
||||
user = request.env.user
|
||||
reporter_email = (reply_email or user.email or user.login or '').strip()
|
||||
body_html = '\n'.join(body_parts)
|
||||
ticket_vals = build_ticket_vals(
|
||||
kind=kind, subject=subject, body_html=body_html,
|
||||
team_id=cfg['team_id'], client_label=cfg['client_label'],
|
||||
reporter_name=user.name, reporter_email=reporter_email,
|
||||
company_name=request.env.company.name,
|
||||
)
|
||||
```
|
||||
- [ ] **Step 2:** Keep the existing create + attachment + return logic. Verify `_build_diag_block` still appends.
|
||||
- [ ] **Step 3: Manual sanity** — `docker exec odoo-modsdev-app odoo -d modsdev -u fusion_helpdesk --stop-after-init 2>&1 | tail -20` (module upgrades clean).
|
||||
- [ ] **Step 4: Commit** `git commit -am "feat(fusion_helpdesk): send partner identity in ticket payload"`
|
||||
|
||||
### Task 3: `x_fc_client_label` field on central
|
||||
|
||||
**Files:** Create `fusion_helpdesk_central/models/__init__.py`, `models/helpdesk_ticket.py`; Modify `__init__.py`, `__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test** (runs on Enterprise env)
|
||||
```python
|
||||
# fusion_helpdesk_central/tests/test_identity.py
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
||||
class TestTicketIdentity(TransactionCase):
|
||||
def test_label_field_and_partner_resolution(self):
|
||||
team = self.env['helpdesk.team'].search([], limit=1)
|
||||
t = self.env['helpdesk.ticket'].create({
|
||||
'name': 'T1', 'team_id': team.id,
|
||||
'partner_email': 'newperson@example.com',
|
||||
'partner_name': 'New Person',
|
||||
'x_fc_client_label': 'ENTECH',
|
||||
})
|
||||
self.assertEqual(t.x_fc_client_label, 'ENTECH')
|
||||
self.assertTrue(t.partner_id, "native create should resolve partner from email")
|
||||
self.assertIn(t.partner_id, t.message_partner_ids, "customer should be a follower")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement field**
|
||||
```python
|
||||
# fusion_helpdesk_central/models/helpdesk_ticket.py
|
||||
from odoo import fields, models
|
||||
|
||||
class HelpdeskTicket(models.Model):
|
||||
_inherit = 'helpdesk.ticket'
|
||||
|
||||
x_fc_client_label = fields.Char(
|
||||
string='Client Deployment', index=True, copy=False,
|
||||
help='Deployment tag (e.g. ENTECH) set by the in-app reporter. '
|
||||
'Scopes the embedded "My Tickets" inbox per client.',
|
||||
)
|
||||
```
|
||||
```python
|
||||
# fusion_helpdesk_central/models/__init__.py
|
||||
from . import helpdesk_ticket
|
||||
```
|
||||
- [ ] **Step 3:** `fusion_helpdesk_central/__init__.py` → add `from . import models`. `__manifest__.py` → `version` bump to `19.0.1.1.0`, add `'models'` import is implicit; add `views/helpdesk_ticket_views.xml` to `data`, add `tests` discovery (automatic).
|
||||
- [ ] **Step 4: Run on Enterprise** (deferred to Phase 6 deploy; can't run on local Community).
|
||||
- [ ] **Step 5: Commit** `git commit -am "feat(fusion_helpdesk_central): x_fc_client_label on helpdesk.ticket"`
|
||||
|
||||
### Task 4: Backend list/search exposure (central)
|
||||
|
||||
**Files:** Create `fusion_helpdesk_central/views/helpdesk_ticket_views.xml`
|
||||
- [ ] **Step 1:** Inherit the helpdesk ticket list + search to add `x_fc_client_label` (column `optional="show"`, search field + a group-by). Use `group_ids` not `groups_id` if gating (none needed here).
|
||||
```xml
|
||||
<odoo>
|
||||
<record id="fhc_ticket_list_label" model="ir.ui.view">
|
||||
<field name="name">fhc.helpdesk.ticket.list.label</field>
|
||||
<field name="model">helpdesk.ticket</field>
|
||||
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="partner_id" position="after">
|
||||
<field name="x_fc_client_label" optional="show"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
<record id="fhc_ticket_search_label" model="ir.ui.view">
|
||||
<field name="name">fhc.helpdesk.ticket.search.label</field>
|
||||
<field name="model">helpdesk.ticket</field>
|
||||
<field name="inherit_id" ref="helpdesk.helpdesk_tickets_view_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="partner_id" position="after">
|
||||
<field name="x_fc_client_label"/>
|
||||
<filter string="Client Deployment" name="group_client_label"
|
||||
context="{'group_by': 'x_fc_client_label'}"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
> NOTE at execution: verify the exact `inherit_id` external IDs by reading the live views (`helpdesk.helpdesk_ticket_view_tree`, `helpdesk.helpdesk_tickets_view_search`) on odoo-nexa — names differ across versions. Adjust before install.
|
||||
- [ ] **Step 2: Commit** `git commit -am "feat(fusion_helpdesk_central): expose client label in ticket views"`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Read APIs + scoping (client)
|
||||
|
||||
### Task 5: Pure scoping + message-filter + unread helpers
|
||||
|
||||
**Files:** Modify `fusion_helpdesk/utils.py`; Modify `fusion_helpdesk/tests/test_utils.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
```python
|
||||
from odoo.addons.fusion_helpdesk.utils import (
|
||||
build_scope_domain, is_public_message, compute_unread_count)
|
||||
|
||||
def test_regular_scope_binds_email_and_label(self):
|
||||
dom = build_scope_domain(label='ENTECH', email='john@entech.com', is_admin=False)
|
||||
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
||||
self.assertIn(('partner_email', '=ilike', 'john@entech.com'), dom)
|
||||
|
||||
def test_admin_scope_binds_label_only(self):
|
||||
dom = build_scope_domain(label='ENTECH', email='a@entech.com', is_admin=True)
|
||||
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
||||
self.assertFalse(any(t[0] == 'partner_email' for t in dom))
|
||||
|
||||
def test_admin_still_bounded_by_label(self):
|
||||
# label is ALWAYS present — no cross-deployment leakage
|
||||
self.assertTrue(build_scope_domain('ENTECH', 'a@x', True))
|
||||
|
||||
def test_internal_note_is_not_public(self):
|
||||
self.assertFalse(is_public_message({'subtype_is_internal': True}))
|
||||
self.assertTrue(is_public_message({'subtype_is_internal': False}))
|
||||
|
||||
def test_unread_count(self):
|
||||
tickets = [{'id': 1, 'last_support_msg_id': 10},
|
||||
{'id': 2, 'last_support_msg_id': 5},
|
||||
{'id': 3, 'last_support_msg_id': 0}]
|
||||
seen = {1: 10, 2: 3} # ticket 2 has newer support msg; 1 is read; 3 none
|
||||
self.assertEqual(compute_unread_count(tickets, seen), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
- [ ] **Step 3: Implement**
|
||||
```python
|
||||
def build_scope_domain(label, email, is_admin):
|
||||
"""Server-side ticket scope. label is ALWAYS bound (defense in depth)."""
|
||||
domain = [('x_fc_client_label', '=', label or '__none__')]
|
||||
if not is_admin:
|
||||
domain.append(('partner_email', '=ilike', email or '__none__'))
|
||||
return domain
|
||||
|
||||
def is_public_message(msg):
|
||||
"""True when a message is customer-visible (not an internal note)."""
|
||||
return not msg.get('subtype_is_internal', False)
|
||||
|
||||
def compute_unread_count(tickets, seen_by_id):
|
||||
"""Count tickets whose latest support message id exceeds the user's
|
||||
last-seen id for that ticket (0/absent = unseen baseline)."""
|
||||
n = 0
|
||||
for t in tickets:
|
||||
last = t.get('last_support_msg_id') or 0
|
||||
if last and last > (seen_by_id.get(t['id']) or 0):
|
||||
n += 1
|
||||
return n
|
||||
```
|
||||
- [ ] **Step 4: Run — PASS**; **Step 5: Commit**
|
||||
|
||||
### Task 6: `fusion.helpdesk.ticket.seen` model + ACL
|
||||
|
||||
**Files:** Create `fusion_helpdesk/models/__init__.py`, `models/fusion_helpdesk_ticket_seen.py`; Modify `__init__.py`, `security/ir.model.access.csv`, `__manifest__.py`; Test `fusion_helpdesk/tests/test_seen.py`
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
```python
|
||||
# tests/test_seen.py
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||
class TestSeen(TransactionCase):
|
||||
def test_mark_seen_upserts(self):
|
||||
Seen = self.env['fusion.helpdesk.ticket.seen']
|
||||
Seen._mark_seen(central_ticket_id=42, last_message_id=100)
|
||||
Seen._mark_seen(central_ticket_id=42, last_message_id=120)
|
||||
rec = Seen.search([('user_id', '=', self.env.uid),
|
||||
('central_ticket_id', '=', 42)])
|
||||
self.assertEqual(len(rec), 1)
|
||||
self.assertEqual(rec.last_seen_message_id, 120)
|
||||
|
||||
def test_seen_map(self):
|
||||
Seen = self.env['fusion.helpdesk.ticket.seen']
|
||||
Seen._mark_seen(1, 10); Seen._mark_seen(2, 20)
|
||||
self.assertEqual(Seen._seen_map([1, 2, 3]), {1: 10, 2: 20})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
- [ ] **Step 3: Implement model**
|
||||
```python
|
||||
# models/fusion_helpdesk_ticket_seen.py
|
||||
from odoo import api, fields, models
|
||||
|
||||
class FusionHelpdeskTicketSeen(models.Model):
|
||||
_name = 'fusion.helpdesk.ticket.seen'
|
||||
_description = 'Fusion Helpdesk — per-user read tracking (metadata only)'
|
||||
|
||||
user_id = fields.Many2one('res.users', required=True, index=True,
|
||||
default=lambda s: s.env.uid, ondelete='cascade')
|
||||
central_ticket_id = fields.Integer(required=True, index=True)
|
||||
last_seen_message_id = fields.Integer(default=0)
|
||||
|
||||
_user_ticket_uniq = models.Constraint(
|
||||
'UNIQUE(user_id, central_ticket_id)',
|
||||
'One seen-row per user per ticket.')
|
||||
|
||||
@api.model
|
||||
def _mark_seen(self, central_ticket_id, last_message_id):
|
||||
rec = self.search([('user_id', '=', self.env.uid),
|
||||
('central_ticket_id', '=', central_ticket_id)], limit=1)
|
||||
if rec:
|
||||
if last_message_id > rec.last_seen_message_id:
|
||||
rec.last_seen_message_id = last_message_id
|
||||
else:
|
||||
self.create({'central_ticket_id': central_ticket_id,
|
||||
'last_seen_message_id': last_message_id})
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _seen_map(self, central_ticket_ids):
|
||||
rows = self.search([('user_id', '=', self.env.uid),
|
||||
('central_ticket_id', 'in', central_ticket_ids)])
|
||||
return {r.central_ticket_id: r.last_seen_message_id for r in rows}
|
||||
```
|
||||
- [ ] **Step 4:** ACL CSV row:
|
||||
```csv
|
||||
access_fhd_seen_user,fusion.helpdesk.ticket.seen.user,model_fusion_helpdesk_ticket_seen,base.group_user,1,1,1,1
|
||||
```
|
||||
`models/__init__.py` → `from . import fusion_helpdesk_ticket_seen`; `__init__.py` → `from . import models`; manifest registers nothing extra (models auto).
|
||||
- [ ] **Step 5: Run — PASS**; **Step 6: Commit**
|
||||
|
||||
### Task 7: Admin group
|
||||
|
||||
**Files:** Create `fusion_helpdesk/security/fusion_helpdesk_groups.xml`; Modify `__manifest__.py` (add to `data`, FIRST so the group exists before ACLs reference it if needed)
|
||||
- [ ] **Step 1:**
|
||||
```xml
|
||||
<odoo>
|
||||
<record id="group_reporter_admin" model="res.groups">
|
||||
<field name="name">Helpdesk Reporter Admin</field>
|
||||
<field name="comment">Can view all tickets filed from this deployment in the in-app inbox.</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
> Odoo 19: NO `users`/`category_id` fields on res.groups. Keep the record minimal.
|
||||
- [ ] **Step 2:** Upgrade clean; **Step 3: Commit**
|
||||
|
||||
### Task 8: Read endpoints (`my_tickets`, `ticket_detail`, `unread_count`)
|
||||
|
||||
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||
|
||||
- [ ] **Step 1:** Add a mockable RPC seam + identity helper:
|
||||
```python
|
||||
def _identity(self):
|
||||
user = request.env.user
|
||||
cfg = self._read_config()
|
||||
return {
|
||||
'email': (user.email or user.login or '').strip(),
|
||||
'label': cfg['client_label'],
|
||||
'is_admin': user.has_group('fusion_helpdesk.group_reporter_admin'),
|
||||
'cfg': cfg,
|
||||
}
|
||||
|
||||
def _rpc(self, cfg, model, method, args, kw=None):
|
||||
uid, proxy = self._authenticate(cfg) # existing
|
||||
return proxy.execute_kw(cfg['db'], uid, cfg['password'], model, method, args, kw or {})
|
||||
```
|
||||
- [ ] **Step 2:** Implement endpoints (all `type='jsonrpc'`, `auth='user'`). `my_tickets` builds the scoped domain via `build_scope_domain`, `search_read` fields `[id, name, stage_id, write_date]`, plus a per-ticket latest public support message id (read `message_ids` or a dedicated query), then computes `has_unread` via the seen map. `ticket_detail` re-resolves the ticket through the scoped domain (reject if absent), reads public messages only (filter via `is_public_message` using each message's subtype internal flag fetched from central), and calls `_mark_seen`. `unread_count` returns `compute_unread_count(...)`.
|
||||
> Execution detail: fetch message subtype "internal" flag from central by reading `mail.message` fields `[author_id, date, body, message_type, subtype_id]` and resolving `subtype_id.internal` via a second read or by filtering `message_type='comment'` + excluding notes. Confirm the cleanest field set against the live `mail.message` model during execution.
|
||||
- [ ] **Step 3:** Manual: upgrade module; **Step 4: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Reply endpoint (client)
|
||||
|
||||
### Task 9: `ticket_reply`
|
||||
|
||||
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||
- [ ] **Step 1:** Endpoint `/fusion_helpdesk/ticket/<int:ticket_id>/reply`, `auth='user'`. Re-resolve ticket via scoped domain (reject if not in scope). Resolve author partner on central by the replier's email (find-or-create via `res.partner` search/create through bot, or pass `author_id` resolved from `partner_email`). Post:
|
||||
```python
|
||||
self._rpc(cfg, 'helpdesk.ticket', 'message_post', [ticket_id], {
|
||||
'body': body_html, # already-safe HTML (escape user text)
|
||||
'message_type': 'comment',
|
||||
'subtype_xmlid': 'mail.mt_comment',
|
||||
'author_id': author_partner_id,
|
||||
})
|
||||
```
|
||||
- [ ] **Step 2:** Escape the user's text to HTML server-side (reuse `_html_escape`). Mark seen after posting.
|
||||
- [ ] **Step 3:** Manual upgrade; **Step 4: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Client UI (dialog tabs, thread, badge)
|
||||
|
||||
### Task 10: Dialog tabs + My Tickets list + thread + reply + confirmed email
|
||||
|
||||
**Files:** Modify `static/src/js/fusion_helpdesk_dialog.js`, `static/src/xml/fusion_helpdesk_dialog.xml`, `static/src/scss/fusion_helpdesk.scss`
|
||||
- [ ] **Step 1:** Add to state: `tab:'new'|'list'|'thread'`, `tickets:[]`, `loadingList`, `current:{id,subject,messages,canReply}`, `replyBody`, `replyEmail` (default from a new `/fusion_helpdesk/whoami` or seeded via session user email — read `user.email` via `useService('user')`/`session`), `scope:'mine'|'all'`, `isAdmin`.
|
||||
- [ ] **Step 2:** Methods: `openList()` → rpc `/fusion_helpdesk/my_tickets` (with `scope`); `openTicket(id)` → rpc detail, switch to thread, refresh list badge; `sendReply()` → rpc reply then reload thread; `setScope()` (admin toggle). Add confirmed **Your email** input on the New tab bound to `state.replyEmail`, passed as `reply_email` in submit payload.
|
||||
- [ ] **Step 3:** Template: a tab header (New | My Tickets); New pane = existing form + email field; List pane = table (ref, subject, stage chip, unread dot) + admin Mine/All toggle; Thread pane = messages (author, date, body, attachments) + reply box + Back. Use `Markup`-safe rendering: render message bodies with `t-out` (OWL) since central returns sanitized HTML.
|
||||
- [ ] **Step 4:** SCSS for tabs/list/thread (follow Odoo kanban hex pattern + dark-mode `$o-webclient-color-scheme` branch per CLAUDE.md).
|
||||
- [ ] **Step 5:** Manual QA locally (dialog opens, tabs switch). **Step 6: Commit**
|
||||
|
||||
### Task 11: Systray unread badge
|
||||
|
||||
**Files:** Modify `static/src/js/fusion_helpdesk_systray.js`, `static/src/xml/fusion_helpdesk_systray.xml`, SCSS
|
||||
- [ ] **Step 1:** On setup, call `/fusion_helpdesk/unread_count`; store `state.unread`. Poll on an interval (e.g. 120s) and on dialog close. Show a badge bubble when `unread > 0`.
|
||||
- [ ] **Step 2:** Badge markup over the icon. **Step 3: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Central acknowledgement email
|
||||
|
||||
### Task 12: Branded acknowledgement template + send-on-create
|
||||
|
||||
**Files:** Create `fusion_helpdesk_central/data/mail_template_ack.xml`; Modify `models/helpdesk_ticket.py`, `__manifest__.py`
|
||||
- [ ] **Step 1:** `mail.template` on `helpdesk.ticket` with subject "We received your request [{{ object.ticket_ref }}]" and a body using the company email layout + a prominent button to `{{ object.get_base_url() }}{{ object.access_url }}` (magic link). Canadian English.
|
||||
- [ ] **Step 2:** Send on create via a create-override (central inherit), gated:
|
||||
```python
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
tickets = super().create(vals_list)
|
||||
tmpl = self.env.ref('fusion_helpdesk_central.mail_template_ticket_ack', raise_if_not_found=False)
|
||||
for t in tickets:
|
||||
if tmpl and t.partner_email and t.x_fc_client_label: # in-app channel only → avoid double-ack with native web form
|
||||
tmpl.send_mail(t.id, force_send=False)
|
||||
return tickets
|
||||
```
|
||||
> Decision: gate on `x_fc_client_label` so only in-app-channel tickets get OUR ack; external web/email customers rely on native confirmation (verify native behavior during deploy; widen the gate if native sends nothing).
|
||||
- [ ] **Step 3:** Register template data in manifest; **Step 4: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Review, fix, deploy, smoke test
|
||||
|
||||
### Task 13: Code review + fix
|
||||
- [ ] Run the code-review skill / pr-review-toolkit `code-reviewer` + `silent-failure-hunter` over the diff. Fix HIGH/MEDIUM findings. Re-run client tests locally. Commit fixes.
|
||||
|
||||
### Task 14: Deploy + test central on odoo-nexa
|
||||
- [ ] Copy/confirm `fusion_helpdesk_central` source is visible to odoo-nexa (`/opt/odoo/custom-addons`).
|
||||
- [ ] Run module tests on nexa: `-u fusion_helpdesk_central --test-enable --test-tags /fusion_helpdesk_central --stop-after-init` (ephemeral http port). Fix failures.
|
||||
- [ ] Upgrade live: `-u fusion_helpdesk_central --stop-after-init` then restart `odoo-nexa-app`.
|
||||
|
||||
### Task 15: Deploy client on odoo-entech
|
||||
- [ ] Look up entech access (memory: DB `admin`; confirm container/SSH via Supabase quick_commands). Confirm entech's `fusion_helpdesk.client_label` (e.g. ENTECH) + remote config points at nexa.
|
||||
- [ ] Ensure `fusion_helpdesk` source present on entech; upgrade `-u fusion_helpdesk --stop-after-init`; restart.
|
||||
|
||||
### Task 16: Smoke test (one ticket)
|
||||
- [ ] From entech: file ONE test ticket via the dialog (or simulate the controller path).
|
||||
- [ ] On nexa: confirm the new ticket has `partner_id` resolved, `partner_email`/`partner_name`/`x_fc_client_label` set, customer is a follower, ack email queued/sent.
|
||||
- [ ] Reply as agent on nexa → confirm notification email to the reporter w/ magic link; confirm the entech dialog "My Tickets" shows the ticket + reply and the badge increments.
|
||||
- [ ] Confirm pre-existing identity-less tickets are untouched (the "lots already submitted" set) and do NOT leak across deployments in the inbox query.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (run before execution)
|
||||
- **Spec coverage:** keystone (T1-3), label field+views (T3-4), scoping (T5,8,9), seen/badge (T6,10,11), admin group (T7), ack email (T12), portal/native (config — verified live, no code), tests (T1,5,6 local + T3 enterprise), deploy+smoke (T14-16). ✓
|
||||
- **Placeholders:** none — code shown for all Python/XML; JS tasks specify state/methods/markup concretely. JS is manually QA'd (OWL unit tests out of scope).
|
||||
- **Type consistency:** `build_scope_domain(label,email,is_admin)`, `is_public_message(msg)`, `compute_unread_count(tickets,seen)`, `_mark_seen(central_ticket_id,last_message_id)`, `_seen_map(ids)`, `x_fc_client_label` — names consistent across tasks. ✓
|
||||
956
docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md
Normal file
956
docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md
Normal file
@@ -0,0 +1,956 @@
|
||||
# NexaCloud → Odoo Billing Importer (Sub-project #2a) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a one-time, re-runnable, read-only importer that backfills NexaCloud customers/plans/deployments into Odoo as a shadow copy (drafts, no charge) for dual-run reconciliation.
|
||||
|
||||
**Architecture:** A `fusion.billing.import.wizard` transient model. `_read_nexacloud_rows()` opens a read-only `psycopg2` connection (DSN from `ir.config_parameter`) and returns plain row dicts — the only code touching NexaCloud. `_import_rows(data, dry_run)` is pure Odoo: it upserts the `nexacloud` service, a `cpu_seconds` metric, Monthly/Yearly recurrences, partners+links (reusing `_resolve_or_create_partner`), a per-plan catalog (product + CPU-overage product + `fusion.billing.charge` with `plan_id` left NULL), and one **draft** shadow `sale.order` per deployment with the flat price set explicitly on the line. Shadow-safety holds by construction: draft + no payment token + charge `plan_id` NULL.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise (Python 3.12), `sale_subscription`, `account_accountant`, `payment_stripe`, `psycopg2`. Tests: `odoo.tests.common.TransactionCase` on odoo-trial.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-billing-importer-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Conventions for every task
|
||||
|
||||
- **Never code Odoo internals from memory** (repo CLAUDE.md rule #1). The uncertain internals (`recurring_invoice`, `is_subscription` on a draft order, `sale.subscription.plan` fields, `price_unit` stickiness, `sale.subscription.plan` `billing_period_unit` values) are *verified by the tests themselves* on odoo-trial — when a test fails because an assumption is wrong, fix the source, do not weaken the assertion.
|
||||
- **Models, not UI:** all logic lives in `_import_rows` / `_do_import` / `_import_*` model methods; the wizard button only calls them. This keeps everything testable under `TransactionCase`.
|
||||
- **Money:** CAD, prices are `Float`/`Monetary`. CPU overage: `price_per_unit=0.0075`, `unit_batch=3600`.
|
||||
- **New fields on native models:** `x_fc_*` prefix.
|
||||
- **Registering tests:** append `from . import test_importer` to `tests/__init__.py` in the task that creates it; commit `__init__.py` alongside so the package always imports.
|
||||
|
||||
## Test environment
|
||||
|
||||
Tests run on **odoo-trial** (Proxmox VM 316, Odoo 19 Enterprise, db `trial`) — local dev is Community and cannot install this module. One runner:
|
||||
|
||||
```bash
|
||||
bash scripts/fcb_test_on_trial.sh
|
||||
```
|
||||
|
||||
- It re-syncs the module to the sandbox and runs `-u fusion_centralize_billing --test-enable --test-tags /fusion_centralize_billing`.
|
||||
- **Pass condition:** output contains `FCB_EXIT=0`.
|
||||
- The script runs the **whole** FCB suite (it cannot target one test); every "run the test" step below means "run the suite, ~1–2 min".
|
||||
- **Never** run `--test-enable` against production `nexamain`.
|
||||
|
||||
## File structure (this plan)
|
||||
|
||||
```
|
||||
fusion_centralize_billing/
|
||||
__init__.py # + from . import wizards
|
||||
models/
|
||||
__init__.py # + from . import res_partner
|
||||
sale_order.py # + x_fc_* fields on the existing SaleOrder inherit
|
||||
res_partner.py # NEW: x_fc_stripe_customer_id
|
||||
wizards/
|
||||
__init__.py # NEW
|
||||
import_wizard.py # NEW: the importer (read + import logic)
|
||||
views/
|
||||
import_wizard_views.xml # NEW: wizard form + action + menu
|
||||
security/
|
||||
ir.model.access.csv # + wizard ACL line
|
||||
__manifest__.py # + views file
|
||||
tests/
|
||||
__init__.py # + from . import test_importer
|
||||
test_importer.py # NEW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffolding — x_fc fields, partner inherit, wizard skeleton, security, manifest
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/models/sale_order.py`
|
||||
- Create: `fusion_centralize_billing/models/res_partner.py`
|
||||
- Modify: `fusion_centralize_billing/models/__init__.py`
|
||||
- Create: `fusion_centralize_billing/wizards/__init__.py`
|
||||
- Create: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||
- Create: `fusion_centralize_billing/views/import_wizard_views.xml`
|
||||
- Modify: `fusion_centralize_billing/__init__.py`
|
||||
- Modify: `fusion_centralize_billing/security/ir.model.access.csv`
|
||||
- Modify: `fusion_centralize_billing/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Add `x_fc_*` fields to the existing `sale.order` inherit**
|
||||
|
||||
In `models/sale_order.py`, add these fields to the `SaleOrder` class (keep `_fc_rate_usage`):
|
||||
```python
|
||||
x_fc_nexacloud_subscription_id = fields.Char(
|
||||
index=True, copy=False,
|
||||
help="Source NexaCloud subscription id — the importer's idempotency key.")
|
||||
x_fc_nexacloud_deployment_id = fields.Char(index=True, copy=False)
|
||||
x_fc_billing_service_id = fields.Many2one(
|
||||
"fusion.billing.service", index=True, copy=False, ondelete="set null")
|
||||
x_fc_shadow = fields.Boolean(
|
||||
default=False, copy=False,
|
||||
help="Imported in shadow mode: Odoo computes but must not charge/post/email.")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the `res.partner` inherit**
|
||||
|
||||
`fusion_centralize_billing/models/res_partner.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = "res.partner"
|
||||
|
||||
x_fc_stripe_customer_id = fields.Char(
|
||||
index=True, copy=False,
|
||||
help="Existing Stripe customer id imported from a source app, reused at flip.")
|
||||
```
|
||||
Append to `models/__init__.py`: `from . import res_partner`.
|
||||
|
||||
- [ ] **Step 3: Create the wizard skeleton**
|
||||
|
||||
`fusion_centralize_billing/wizards/__init__.py`:
|
||||
```python
|
||||
from . import import_wizard
|
||||
```
|
||||
|
||||
`fusion_centralize_billing/wizards/import_wizard.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
NEXACLOUD_CODE = "nexacloud"
|
||||
CPU_METRIC_CODE = "cpu_seconds"
|
||||
CPU_RATE_PER_CORE_HOUR = 0.0075 # NexaCloud CPU rate, CAD per core-hour
|
||||
CPU_SECONDS_PER_CORE_HOUR = 3600.0 # one core-hour = 3600 cpu-seconds
|
||||
|
||||
|
||||
class FusionBillingImportWizard(models.TransientModel):
|
||||
_name = "fusion.billing.import.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Importer"
|
||||
|
||||
dry_run = fields.Boolean(
|
||||
default=True,
|
||||
help="Read and report what would be imported, without writing anything.")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
|
||||
def action_run_import(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_rows()
|
||||
summary = self._import_rows(data, dry_run=self.dry_run)
|
||||
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": self._name,
|
||||
"res_id": self.id,
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_rows(self):
|
||||
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
|
||||
ir.config_parameter 'fusion_billing.nexacloud_dsn') and return rows as dicts.
|
||||
Raises UserError on a missing DSN or a failed connection."""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError(
|
||||
"NexaCloud DSN not configured. Set the 'fusion_billing.nexacloud_dsn' "
|
||||
"system parameter to a read-only Postgres connection string.")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001 - surface as a user error
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
data = {}
|
||||
cur.execute(
|
||||
"SELECT id, email, full_name, company, billing_email, billing_address, "
|
||||
"billing_city, billing_state, billing_postal_code, billing_country, "
|
||||
"tax_id, stripe_customer_id FROM users")
|
||||
data["users"] = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT id, name, price_monthly, price_yearly, cpu_seconds_quota, "
|
||||
"is_active FROM plans")
|
||||
data["plans"] = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT id, user_id, deployment_id, plan_id, status, billing_cycle, "
|
||||
"current_period_start, current_period_end FROM subscriptions")
|
||||
data["subscriptions"] = [dict(r) for r in cur.fetchall()]
|
||||
return data
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ----- import side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _import_rows(self, data, dry_run=False):
|
||||
"""Upsert NexaCloud rows into Odoo. Idempotent. With dry_run=True the writes
|
||||
happen inside a savepoint that is rolled back, so nothing persists."""
|
||||
if not dry_run:
|
||||
return self._do_import(data)
|
||||
result = {}
|
||||
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
result.update(self._do_import(data))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
result["dry_run"] = True
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _do_import(self, data):
|
||||
return {"created": {}, "updated": {}, "skipped": [], "failed": []}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the wizard view + action + menu**
|
||||
|
||||
`fusion_centralize_billing/views/import_wizard_views.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_billing_import_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.import.wizard.form</field>
|
||||
<field name="model">fusion.billing.import.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Import from NexaCloud">
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_run_import" type="object" string="Run Import"
|
||||
class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_billing_import_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Import from NexaCloud</field>
|
||||
<field name="res_model">fusion.billing.import.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_billing_root" name="Fusion Billing"
|
||||
parent="account.menu_finance" sequence="90"/>
|
||||
<menuitem id="menu_fusion_billing_import" name="Import from NexaCloud"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fusion_billing_import_wizard" sequence="10"
|
||||
groups="base.group_system"/>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Wire module imports, security, manifest**
|
||||
|
||||
Append to `fusion_centralize_billing/__init__.py`: `from . import wizards`.
|
||||
(Confirm it already has `from . import models` and `from . import controllers`; add the wizards line.)
|
||||
|
||||
Append to `security/ir.model.access.csv`:
|
||||
```
|
||||
access_fusion_billing_import_wizard,fusion.billing.import.wizard,model_fusion_billing_import_wizard,base.group_system,1,1,1,1
|
||||
```
|
||||
|
||||
In `__manifest__.py`, add the view to `data` (after the cron):
|
||||
```python
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"data/ir_cron.xml",
|
||||
"views/import_wizard_views.xml",
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify the module upgrades cleanly on odoo-trial**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0` (the 39 existing tests still pass; new model/fields/view load with no traceback).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/models/sale_order.py fusion_centralize_billing/models/res_partner.py fusion_centralize_billing/models/__init__.py fusion_centralize_billing/wizards/ fusion_centralize_billing/views/import_wizard_views.xml fusion_centralize_billing/__init__.py fusion_centralize_billing/security/ir.model.access.csv fusion_centralize_billing/__manifest__.py
|
||||
git commit -m "feat(billing): importer scaffold — x_fc fields, wizard, security, view"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Identity import (users → partners + links)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||
- Create: `fusion_centralize_billing/tests/test_importer.py`
|
||||
- Modify: `fusion_centralize_billing/tests/__init__.py`
|
||||
|
||||
- [ ] **Step 1: Register + write the failing test**
|
||||
|
||||
Append to `tests/__init__.py`: `from . import test_importer`.
|
||||
|
||||
`fusion_centralize_billing/tests/test_importer.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
def _fixture():
|
||||
"""Two users, one plan, two subscriptions (monthly + yearly) — the canonical
|
||||
NexaCloud row dicts the importer consumes."""
|
||||
return {
|
||||
"users": [
|
||||
{"id": "u-1", "email": "ar@acme.test", "full_name": "Acme Inc",
|
||||
"company": "Acme", "billing_email": "billing@acme.test",
|
||||
"billing_address": "1 Main St", "billing_city": "Toronto",
|
||||
"billing_state": "ON", "billing_postal_code": "M1M1M1",
|
||||
"billing_country": "CA", "tax_id": "123456789RT0001",
|
||||
"stripe_customer_id": "cus_ACME"},
|
||||
{"id": "u-2", "email": "ops@globex.test", "full_name": "Globex",
|
||||
"company": "Globex", "billing_email": None, "billing_address": None,
|
||||
"billing_city": None, "billing_state": None, "billing_postal_code": None,
|
||||
"billing_country": None, "tax_id": None, "stripe_customer_id": "cus_GLBX"},
|
||||
],
|
||||
"plans": [
|
||||
{"id": "p-1", "name": "Starter", "price_monthly": 20.0,
|
||||
"price_yearly": 200.0, "cpu_seconds_quota": 18000.0, "is_active": True},
|
||||
],
|
||||
"subscriptions": [
|
||||
{"id": "s-1", "user_id": "u-1", "deployment_id": "d-1", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "monthly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"},
|
||||
{"id": "s-2", "user_id": "u-2", "deployment_id": "d-2", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "yearly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2027-05-01"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterIdentity(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
self.Link = self.env['fusion.billing.account.link'].sudo()
|
||||
|
||||
def test_imports_users_as_partners_and_links(self):
|
||||
self.Wizard._import_rows({'users': _fixture()['users']})
|
||||
svc = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')])
|
||||
self.assertTrue(svc, "importer must find-or-create the nexacloud service")
|
||||
link1 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-1')])
|
||||
self.assertEqual(len(link1), 1)
|
||||
self.assertEqual(link1.partner_id.email, 'billing@acme.test') # billing_email wins
|
||||
self.assertEqual(link1.partner_id.city, 'Toronto')
|
||||
self.assertEqual(link1.partner_id.vat, '123456789RT0001')
|
||||
self.assertEqual(link1.partner_id.x_fc_stripe_customer_id, 'cus_ACME')
|
||||
self.assertEqual(link1.partner_id.country_id.code, 'CA')
|
||||
link2 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-2')])
|
||||
self.assertEqual(link2.partner_id.email, 'ops@globex.test') # falls back to email
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect failure**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: FAIL — `_do_import` returns the empty stub; no partners/links created.
|
||||
|
||||
- [ ] **Step 3: Implement service/metric/recurrence helpers + user import**
|
||||
|
||||
Replace the stub `_do_import` and add helpers in `wizards/import_wizard.py`:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_service(self):
|
||||
Service = self.env['fusion.billing.service']
|
||||
svc = Service.search([('code', '=', NEXACLOUD_CODE)], limit=1)
|
||||
return svc or Service.create({'name': 'NexaCloud', 'code': NEXACLOUD_CODE})
|
||||
|
||||
@api.model
|
||||
def _fc_cpu_metric(self):
|
||||
Metric = self.env['fusion.billing.metric']
|
||||
m = Metric.search([('code', '=', CPU_METRIC_CODE)], limit=1)
|
||||
return m or Metric.create({
|
||||
'name': 'CPU seconds', 'code': CPU_METRIC_CODE,
|
||||
'aggregation': 'sum', 'unit_label': 'CPU-seconds'})
|
||||
|
||||
@api.model
|
||||
def _fc_recurrence_plan(self, unit):
|
||||
Plan = self.env['sale.subscription.plan']
|
||||
plan = Plan.search([('billing_period_value', '=', 1),
|
||||
('billing_period_unit', '=', unit)], limit=1)
|
||||
if plan:
|
||||
return plan
|
||||
label = 'Monthly' if unit == 'month' else 'Yearly'
|
||||
return Plan.create({'name': label, 'billing_period_value': 1,
|
||||
'billing_period_unit': unit})
|
||||
|
||||
@api.model
|
||||
def _fc_resolve_country(self, value):
|
||||
Country = self.env['res.country']
|
||||
if not value:
|
||||
return Country.browse()
|
||||
v = value.strip()
|
||||
return Country.search(['|', ('code', '=ilike', v), ('name', '=ilike', v)], limit=1)
|
||||
|
||||
@staticmethod
|
||||
def _bump(summary, created, key):
|
||||
bucket = 'created' if created else 'updated'
|
||||
summary[bucket][key] = summary[bucket].get(key, 0) + 1
|
||||
|
||||
@api.model
|
||||
def _import_user(self, service, urow):
|
||||
Link = self.env['fusion.billing.account.link']
|
||||
ext = str(urow['id'])
|
||||
email = (urow.get('billing_email') or urow.get('email') or '').strip().lower() or None
|
||||
name = urow.get('full_name') or urow.get('company') or email or ext
|
||||
existed = bool(Link.search(
|
||||
[('service_id', '=', service.id), ('external_id', '=', ext)], limit=1))
|
||||
link = Link._resolve_or_create_partner(service, ext, name=name, email=email)
|
||||
vals = {}
|
||||
if urow.get('billing_address'):
|
||||
vals['street'] = urow['billing_address']
|
||||
if urow.get('billing_city'):
|
||||
vals['city'] = urow['billing_city']
|
||||
if urow.get('billing_postal_code'):
|
||||
vals['zip'] = urow['billing_postal_code']
|
||||
if urow.get('tax_id'):
|
||||
vals['vat'] = urow['tax_id']
|
||||
if urow.get('stripe_customer_id'):
|
||||
vals['x_fc_stripe_customer_id'] = urow['stripe_customer_id']
|
||||
country = self._fc_resolve_country(urow.get('billing_country'))
|
||||
if country:
|
||||
vals['country_id'] = country.id
|
||||
if vals:
|
||||
link.partner_id.write(vals)
|
||||
return link, not existed
|
||||
|
||||
@api.model
|
||||
def _do_import(self, data):
|
||||
service = self._fc_service()
|
||||
summary = {'created': {}, 'updated': {}, 'skipped': [], 'failed': []}
|
||||
partner_by_user = {}
|
||||
for u in data.get('users', []):
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
link, created = self._import_user(service, u)
|
||||
partner_by_user[str(u['id'])] = link.partner_id
|
||||
self._bump(summary, created, 'partners')
|
||||
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||
summary['failed'].append(
|
||||
{'kind': 'user', 'id': str(u.get('id')), 'error': str(e)})
|
||||
return summary
|
||||
```
|
||||
|
||||
> **Note:** `partner_by_user` and (Task 3) `plan_ctx_by_id` are **method-local** dicts — never set them as attributes on `self` (Odoo recordsets reject arbitrary attribute assignment). Tasks 3 and 4 add their loops to this same `_do_import` method, so the locals stay in scope.
|
||||
|
||||
- [ ] **Step 4: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0`; `TestImporterIdentity` passes. If `country_id.code` assertion fails, fix `_fc_resolve_country` (don't weaken the assertion).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py fusion_centralize_billing/tests/__init__.py
|
||||
git commit -m "feat(billing): importer identity (NexaCloud users -> partners + links)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Catalog import (plans → metric + products + charge, plan_id NULL)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterCatalog(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_imports_plan_as_charge_with_null_plan_id(self):
|
||||
self.Wizard._import_rows({'plans': _fixture()['plans']})
|
||||
metric = self.env['fusion.billing.metric'].search([('code', '=', 'cpu_seconds')])
|
||||
self.assertTrue(metric)
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
self.assertEqual(len(charge), 1)
|
||||
self.assertEqual(charge.metric_id, metric)
|
||||
self.assertEqual(charge.included_quota, 18000.0) # = plan.cpu_seconds_quota
|
||||
self.assertEqual(charge.unit_batch, 3600.0) # one core-hour
|
||||
self.assertAlmostEqual(charge.price_per_unit, 0.0075) # CAD per core-hour
|
||||
self.assertEqual(charge.charge_model, 'standard')
|
||||
self.assertFalse(charge.plan_id, "shadow: charge.plan_id must be NULL so the "
|
||||
"rating cron never auto-mutates order lines")
|
||||
self.assertTrue(charge.product_id, "charge needs an overage product")
|
||||
self.assertTrue(charge.product_id.recurring_invoice is False
|
||||
or charge.product_id.recurring_invoice in (False, None))
|
||||
|
||||
def test_charge_math_matches_nexacloud(self):
|
||||
# 18000 quota + 2 core-hours overage (7200s) -> 2 batches * $0.0075 = $0.015
|
||||
self.Wizard._import_rows({'plans': _fixture()['plans']})
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
_overage, amount = charge._compute_billable(18000.0 + 7200.0)
|
||||
self.assertAlmostEqual(amount, 0.015, places=4)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect failure**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: FAIL — no charge created (catalog import not implemented).
|
||||
|
||||
- [ ] **Step 3: Implement catalog import**
|
||||
|
||||
Add to `wizards/import_wizard.py`:
|
||||
```python
|
||||
@api.model
|
||||
def _import_plan(self, metric, prow):
|
||||
Product = self.env['product.product']
|
||||
Charge = self.env['fusion.billing.charge']
|
||||
plan_code = str(prow['id'])
|
||||
name = prow.get('name') or plan_code
|
||||
price_monthly = float(prow.get('price_monthly') or 0.0)
|
||||
price_yearly = float(prow.get('price_yearly') or 0.0)
|
||||
|
||||
sub_code = 'NC-PLAN-%s' % plan_code
|
||||
sub_product = Product.search([('default_code', '=', sub_code)], limit=1)
|
||||
created = False
|
||||
if not sub_product:
|
||||
sub_product = Product.create({
|
||||
'name': 'NexaCloud %s' % name, 'default_code': sub_code,
|
||||
'type': 'service', 'recurring_invoice': True,
|
||||
'list_price': price_monthly})
|
||||
created = True
|
||||
|
||||
ov_code = 'NC-CPU-OVG-%s' % plan_code
|
||||
ov_product = Product.search([('default_code', '=', ov_code)], limit=1)
|
||||
if not ov_product:
|
||||
ov_product = Product.create({
|
||||
'name': 'NexaCloud CPU overage (%s)' % name, 'default_code': ov_code,
|
||||
'type': 'service', 'list_price': 0.0})
|
||||
|
||||
charge_vals = {
|
||||
'name': 'NexaCloud CPU overage — %s' % name,
|
||||
'plan_code': plan_code, 'metric_id': metric.id, 'product_id': ov_product.id,
|
||||
'included_quota': float(prow.get('cpu_seconds_quota') or 0.0),
|
||||
'price_per_unit': CPU_RATE_PER_CORE_HOUR, 'unit_batch': CPU_SECONDS_PER_CORE_HOUR,
|
||||
'charge_model': 'standard',
|
||||
# plan_id intentionally omitted (NULL) — shadow safety guarantee #3
|
||||
}
|
||||
charge = Charge.search(
|
||||
[('plan_code', '=', plan_code), ('metric_id', '=', metric.id)], limit=1)
|
||||
if charge:
|
||||
charge.write(charge_vals)
|
||||
else:
|
||||
charge = Charge.create(charge_vals)
|
||||
created = True
|
||||
return {'sub_product': sub_product, 'overage_product': ov_product,
|
||||
'charge': charge, 'price_monthly': price_monthly,
|
||||
'price_yearly': price_yearly}, created
|
||||
```
|
||||
In `_do_import`, after the users loop, add the plans loop:
|
||||
```python
|
||||
metric = self._fc_cpu_metric()
|
||||
plan_ctx_by_id = {}
|
||||
for p in data.get('plans', []):
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
ctx, created = self._import_plan(metric, p)
|
||||
plan_ctx_by_id[str(p['id'])] = ctx
|
||||
self._bump(summary, created, 'plans')
|
||||
except Exception as e: # noqa: BLE001
|
||||
summary['failed'].append(
|
||||
{'kind': 'plan', 'id': str(p.get('id')), 'error': str(e)})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0`; both catalog tests pass. If `product.product` rejects `recurring_invoice` or `type='service'`, read the field on odoo-trial and fix the source.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "feat(billing): importer catalog (plans -> products + CPU charge, plan_id NULL)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Subscription import (deployments → draft shadow sale.order)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterSubscriptions(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_imports_one_draft_shadow_subscription_per_deployment(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
SaleOrder = self.env['sale.order']
|
||||
sub1 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
self.assertEqual(len(sub1), 1)
|
||||
self.assertTrue(sub1.is_subscription)
|
||||
self.assertTrue(sub1.x_fc_shadow)
|
||||
self.assertEqual(sub1.x_fc_nexacloud_deployment_id, 'd-1')
|
||||
self.assertNotEqual(sub1.subscription_state, '3_progress') # left in draft
|
||||
# monthly flat price set explicitly on the plan product line
|
||||
plan_line = sub1.order_line.filtered(
|
||||
lambda l: l.product_id.default_code == 'NC-PLAN-p-1')
|
||||
self.assertEqual(len(plan_line), 1)
|
||||
self.assertAlmostEqual(plan_line.price_unit, 20.0) # price_monthly
|
||||
# the yearly subscription gets the yearly price + yearly recurrence
|
||||
sub2 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-2')])
|
||||
line2 = sub2.order_line.filtered(lambda l: l.product_id.default_code == 'NC-PLAN-p-1')
|
||||
self.assertAlmostEqual(line2.price_unit, 200.0) # price_yearly
|
||||
self.assertEqual(sub2.plan_id.billing_period_unit, 'year')
|
||||
|
||||
def test_subscription_skipped_when_user_or_plan_unresolved(self):
|
||||
data = _fixture()
|
||||
data['subscriptions'].append(
|
||||
{"id": "s-3", "user_id": "u-missing", "deployment_id": "d-3", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "monthly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"})
|
||||
summary = self.Wizard._import_rows(data)
|
||||
self.assertFalse(self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-3')]))
|
||||
self.assertTrue(any(s.get('id') == 's-3' for s in summary['skipped']))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect failure**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: FAIL — no subscriptions created (subscription import not implemented).
|
||||
|
||||
- [ ] **Step 3: Implement subscription import**
|
||||
|
||||
Add to `wizards/import_wizard.py`:
|
||||
```python
|
||||
@api.model
|
||||
def _import_subscription(self, service, partner, plan_ctx, recurrence_plans, srow):
|
||||
SaleOrder = self.env['sale.order']
|
||||
SaleOrderLine = self.env['sale.order.line']
|
||||
sub_ext = str(srow['id'])
|
||||
cycle = (srow.get('billing_cycle') or 'monthly').lower()
|
||||
rec_plan = recurrence_plans['yearly'] if cycle == 'yearly' else recurrence_plans['monthly']
|
||||
price = plan_ctx['price_yearly'] if cycle == 'yearly' else plan_ctx['price_monthly']
|
||||
product = plan_ctx['sub_product']
|
||||
order_vals = {
|
||||
'partner_id': partner.id, 'plan_id': rec_plan.id,
|
||||
'x_fc_nexacloud_subscription_id': sub_ext,
|
||||
'x_fc_nexacloud_deployment_id': str(srow.get('deployment_id') or ''),
|
||||
'x_fc_billing_service_id': service.id, 'x_fc_shadow': True,
|
||||
}
|
||||
existing = SaleOrder.search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
|
||||
if existing:
|
||||
existing.write(order_vals)
|
||||
line = existing.order_line.filtered(lambda l: l.product_id == product)
|
||||
line_vals = {'product_uom_qty': 1, 'price_unit': price}
|
||||
if line:
|
||||
line.write(line_vals)
|
||||
else:
|
||||
SaleOrderLine.create(dict(order_id=existing.id, product_id=product.id, **line_vals))
|
||||
order = existing
|
||||
created = False
|
||||
else:
|
||||
order_vals['order_line'] = [(0, 0, {
|
||||
'product_id': product.id, 'product_uom_qty': 1, 'price_unit': price})]
|
||||
order = SaleOrder.create(order_vals)
|
||||
created = True
|
||||
# guarantee the explicit price stuck (a pricelist compute may have overwritten it)
|
||||
line = order.order_line.filtered(lambda l: l.product_id == product)
|
||||
if line and line.price_unit != price:
|
||||
line.price_unit = price
|
||||
return order, created
|
||||
```
|
||||
In `_do_import`, before `return summary`, add the recurrences + subscriptions loop:
|
||||
```python
|
||||
recurrence_plans = {'monthly': self._fc_recurrence_plan('month'),
|
||||
'yearly': self._fc_recurrence_plan('year')}
|
||||
for s in data.get('subscriptions', []):
|
||||
partner = partner_by_user.get(str(s.get('user_id') or ''))
|
||||
ctx = plan_ctx_by_id.get(str(s.get('plan_id') or ''))
|
||||
if not partner or not ctx:
|
||||
summary['skipped'].append({
|
||||
'kind': 'subscription', 'id': str(s.get('id')),
|
||||
'reason': 'unresolved %s' % ('user' if not partner else 'plan')})
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
_order, created = self._import_subscription(
|
||||
service, partner, ctx, recurrence_plans, s)
|
||||
self._bump(summary, created, 'subscriptions')
|
||||
except Exception as e: # noqa: BLE001
|
||||
summary['failed'].append(
|
||||
{'kind': 'subscription', 'id': str(s.get('id')), 'error': str(e)})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0`. If `is_subscription` is False on the draft order, that disproves the design assumption — read `sale_order.py` in `sale_subscription` on odoo-trial and adjust how the subscription is created (e.g. set the field driving `is_subscription`), never weaken the assertion. If `billing_period_unit` rejects `'year'`, read the selection values and fix `_fc_recurrence_plan`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "feat(billing): importer subscriptions (one draft shadow sale.order per deployment)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Idempotency + dry-run
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterIdempotencyDryRun(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def _counts(self):
|
||||
return (
|
||||
self.env['fusion.billing.account.link'].search_count([]),
|
||||
self.env['fusion.billing.charge'].search_count([]),
|
||||
self.env['sale.order'].search_count([('x_fc_shadow', '=', True)]),
|
||||
)
|
||||
|
||||
def test_rerun_updates_not_duplicates(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
before = self._counts()
|
||||
# change a value and re-run; counts stay the same, value updates
|
||||
data = _fixture()
|
||||
data['plans'][0]['cpu_seconds_quota'] = 99999.0
|
||||
self.Wizard._import_rows(data)
|
||||
self.assertEqual(self._counts(), before, "re-run must upsert, not duplicate")
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
self.assertEqual(charge.included_quota, 99999.0)
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
summary = self.Wizard._import_rows(_fixture(), dry_run=True)
|
||||
self.assertTrue(summary.get('dry_run'))
|
||||
self.assertEqual(self._counts(), (0, 0, 0), "dry-run must not persist anything")
|
||||
# the nexacloud service is created inside the rolled-back savepoint too
|
||||
self.assertFalse(self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')]))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0` — idempotency and dry-run already hold from Tasks 2–4 + the savepoint in `_import_rows`. If the dry-run leaves a `nexacloud` service behind, the savepoint isn't wrapping `_fc_service` — confirm `_do_import` (which creates the service) runs entirely inside the `with self.env.cr.savepoint()` block.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "test(billing): importer idempotency + dry-run"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Shadow-mode safety assertions
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterShadowSafety(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_import_creates_no_invoice_and_no_payment_token(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
self.assertTrue(subs)
|
||||
partners = subs.mapped('partner_id')
|
||||
# no posted/draft customer invoice for any imported partner
|
||||
invoices = self.env['account.move'].search([
|
||||
('partner_id', 'in', partners.ids), ('move_type', '=', 'out_invoice')])
|
||||
self.assertFalse(invoices, "shadow import must not create any invoice")
|
||||
# no Stripe payment token -> charging is physically impossible
|
||||
tokens = self.env['payment.token'].search([('partner_id', 'in', partners.ids)])
|
||||
self.assertFalse(tokens, "shadow import must not attach a payment token")
|
||||
# every imported charge has a NULL plan_id so the rating cron skips it
|
||||
charges = self.env['fusion.billing.charge'].search([('plan_code', 'like', 'p-%')])
|
||||
self.assertTrue(charges)
|
||||
self.assertFalse(any(charges.mapped('plan_id')))
|
||||
|
||||
def test_rating_cron_leaves_shadow_subscriptions_untouched(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
lines_before = sum(len(s.order_line) for s in subs)
|
||||
self.env['fusion.billing.usage']._cron_rate_open_periods()
|
||||
subs.invalidate_recordset()
|
||||
lines_after = sum(len(s.order_line) for s in subs)
|
||||
self.assertEqual(lines_before, lines_after,
|
||||
"charges with NULL plan_id must keep the rating cron a no-op")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0` — the safety properties hold by construction (draft, no token, NULL plan_id). If `payment.token` is not a valid model name in this build, read the `payment` model names on odoo-trial and use the correct one (don't drop the assertion). If an invoice *is* found, the draft-import guarantee is broken — investigate whether `sale.order.create` auto-invoices, and stop confirming/posting.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "test(billing): importer shadow-mode safety (no invoice/token, cron no-op)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Error handling — malformed rows isolated
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterErrorIsolation(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_one_bad_user_does_not_abort_the_batch(self):
|
||||
data = _fixture()
|
||||
# a row with no id -> str(urow['id']) raises KeyError, must be caught per-row
|
||||
data['users'].insert(0, {"email": "broken@x.test"})
|
||||
summary = self.Wizard._import_rows(data)
|
||||
# the two good users still import
|
||||
self.assertEqual(
|
||||
self.env['fusion.billing.account.link'].search_count([]), 2)
|
||||
self.assertTrue(summary['failed'], "the bad row must be recorded in failed[]")
|
||||
self.assertTrue(any(f['kind'] == 'user' for f in summary['failed']))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0` — the per-row `try/except` + `savepoint` already isolates failures. If the whole batch aborts, the `savepoint` is missing around `_import_user` or the broad `except` is too narrow — fix so one bad row never poisons the cursor.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "test(billing): importer per-row error isolation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Read path — DSN guard
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterReadGuard(TransactionCase):
|
||||
|
||||
def test_missing_dsn_raises_usererror(self):
|
||||
# ensure no DSN is configured in the test DB
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||
with self.assertRaises(UserError):
|
||||
wiz._read_nexacloud_rows()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0` — `_read_nexacloud_rows` raises `UserError` when the DSN param is empty (implemented in Task 1). If `psycopg2` import fails on odoo-trial, confirm it ships with the image (it does — Odoo depends on it).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "test(billing): importer read-path DSN guard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Full suite + static checks
|
||||
|
||||
**Files:** none (verification task)
|
||||
|
||||
- [ ] **Step 1: Full test run**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0`, no `FAIL`/`ERROR` lines for `fusion_centralize_billing`.
|
||||
|
||||
- [ ] **Step 2: No `_sql_constraints` regressions**
|
||||
|
||||
Run: `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo "clean"`
|
||||
Expected: `clean`.
|
||||
|
||||
- [ ] **Step 3: No bare `sale.subscription` model references**
|
||||
|
||||
Run: `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ || echo "clean"`
|
||||
Expected: `clean` (only `sale.subscription.plan` is valid).
|
||||
|
||||
- [ ] **Step 4: Pyflakes the new Python**
|
||||
|
||||
Run: `docker exec odoo-modsdev-app python3 -m pyflakes fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/models/res_partner.py 2>&1 | tail -20 || true`
|
||||
Expected: no undefined names (catches the kind of `_norm_email` NameError the helpdesk smoke test missed).
|
||||
|
||||
- [ ] **Step 5: Commit (if any fixes)**
|
||||
|
||||
```bash
|
||||
git add -A fusion_centralize_billing/
|
||||
git commit -m "test(billing): 2a importer full suite green + static checks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Done = 2a importer complete
|
||||
|
||||
A NexaCloud backfill produces, idempotently: unified partners + links, a `cpu_seconds` charge catalog (`plan_id` NULL), and one draft shadow `sale.order` per deployment carrying the exact NexaCloud flat price — with zero customer-visible billing in Odoo (no invoice, no token, rating cron a no-op). The `psycopg2` read path is ready; the live run is gated only on the read-only DSN grant.
|
||||
|
||||
## Next (not this plan)
|
||||
|
||||
- 2b: NexaCloud `usage_metering.py` pushes cpu-seconds (= core-hours × 3600) to `POST /usage`.
|
||||
- 2c: NexaCloud consumes `invoice.payment_failed` / `subscription.terminated` webhooks → throttle/deprovision.
|
||||
- 2d: `fusion.billing.reconciliation` diffs Odoo-computed (flat + `charge._compute_billable`) vs NexaCloud actuals per period; flip when within tolerance (set `charge.plan_id`, attach tokens, confirm subs).
|
||||
637
docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
Normal file
637
docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# NexaCloud → Odoo Invoice Ledger — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Ingest NexaCloud's real (Stripe-billed) invoices into Odoo as posted `account.move` customer invoices with reconciled payments + HST, so Odoo is the accounting system of record — all history + ongoing, revenue split by service family, draft-first on the live books.
|
||||
|
||||
**Architecture:** A new ingester in `fusion_centralize_billing` mirroring the importer's read/write split: `_read_nexacloud_invoices` (read-only psycopg2 via the existing DSN) → `_ingest_invoices` (pure Odoo: create `account.move` drafts idempotently, map lines to per-family income accounts, derive tax, reconcile Stripe payments) → `_post_ingested` (bulk-post after review). Reuses the `account.link` partner mapping. Native Odoo accounting does the rest.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise, `account_accountant`, `psycopg2`. Tests: `TransactionCase` on odoo-trial (`bash scripts/fcb_test_on_trial.sh`, pass = `FCB_EXIT=0`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
- **Never code accounting internals from memory** (CLAUDE rule #1). Reference confirmed on trial: `account.move` has `invoice_line_ids`/`invoice_date`/`action_post`; `account.payment.register` exists; `account_type='income'`/`'asset_receivable'` valid; sale taxes are Canadian (find HST 13% by `amount=13` / name). Where a step says "read reference", confirm before relying on it.
|
||||
- **Models, not UI:** logic in model methods; the wizard only calls them. Testable under `TransactionCase`.
|
||||
- **New fields on native models:** `x_fc_*`. Declarative `models.Constraint` only.
|
||||
- Tests run on **odoo-trial** (`bash scripts/fcb_test_on_trial.sh`, full suite, ~1–2 min). Register each new `tests/test_*.py` in `tests/__init__.py` in the same task.
|
||||
|
||||
## File structure
|
||||
```
|
||||
fusion_centralize_billing/
|
||||
models/
|
||||
account_move.py # NEW: account.move inherit (x_fc_nexacloud_invoice_id, x_fc_stripe_invoice_id)
|
||||
__init__.py # + account_move
|
||||
wizards/
|
||||
invoice_ledger.py # NEW: the ingester (read + ingest + post + family/tax/payment helpers)
|
||||
__init__.py # + invoice_ledger
|
||||
views/
|
||||
invoice_ledger_views.xml # NEW: wizard form + action + menu + cron
|
||||
security/ir.model.access.csv # + ledger wizard ACL
|
||||
__manifest__.py # + views/invoice_ledger_views.xml
|
||||
tests/
|
||||
test_invoice_ledger.py # NEW
|
||||
__init__.py # + test_invoice_ledger
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffold — account.move fields + ledger wizard skeleton
|
||||
|
||||
**Files:** create `models/account_move.py`, `wizards/invoice_ledger.py`, `views/invoice_ledger_views.xml`; modify `models/__init__.py`, `wizards/__init__.py`, `security/ir.model.access.csv`, `__manifest__.py`.
|
||||
|
||||
- [ ] **Step 1: account.move inherit** — `models/account_move.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
x_fc_nexacloud_invoice_id = fields.Char(
|
||||
index=True, copy=False, help="Source NexaCloud invoice id — ledger idempotency key.")
|
||||
x_fc_stripe_invoice_id = fields.Char(index=True, copy=False)
|
||||
|
||||
_fc_nc_invoice_uniq = models.Constraint(
|
||||
"unique(x_fc_nexacloud_invoice_id)",
|
||||
"One Odoo invoice per NexaCloud invoice id.")
|
||||
```
|
||||
Add `from . import account_move` to `models/__init__.py`.
|
||||
|
||||
- [ ] **Step 2: ledger wizard skeleton** — `wizards/invoice_ledger.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
_name = "fusion.billing.invoice.ledger.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Invoice Ledger Ingester"
|
||||
|
||||
dry_run = fields.Boolean(default=True)
|
||||
auto_post = fields.Boolean(
|
||||
default=False, help="Post invoices immediately (else leave draft for review).")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
|
||||
def _ingest_invoices(self, data, post=False):
|
||||
return {"created": 0, "updated": 0, "posted": 0, "skipped": [], "failed": [], "by_family": {}}
|
||||
```
|
||||
Add `from . import invoice_ledger` to `wizards/__init__.py`.
|
||||
|
||||
- [ ] **Step 3: view + action + menu** — `views/invoice_ledger_views.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fc_invoice_ledger_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.invoice.ledger.wizard.form</field>
|
||||
<field name="model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Ingest NexaCloud Invoices">
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
<field name="auto_post"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_run" type="object" string="Run" class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="action_fc_invoice_ledger_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Ingest NexaCloud Invoices</field>
|
||||
<field name="res_model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
<menuitem id="menu_fc_invoice_ledger" name="Ingest NexaCloud Invoices"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fc_invoice_ledger_wizard" sequence="20"
|
||||
groups="base.group_system"/>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: security + manifest** — append to `security/ir.model.access.csv`:
|
||||
```
|
||||
access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1
|
||||
```
|
||||
Add `"views/invoice_ledger_views.xml"` to `__manifest__.py` `data`.
|
||||
|
||||
- [ ] **Step 5: verify upgrade** — `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0` (existing tests pass; new model/fields/view load).
|
||||
|
||||
- [ ] **Step 6: commit** — `feat(billing): invoice-ledger scaffold (account.move x_fc fields + wizard)`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Service-family classification + income account
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`; create `tests/test_invoice_ledger.py` (+ register in `tests/__init__.py`).
|
||||
|
||||
- [ ] **Step 1: failing test** — `tests/test_invoice_ledger.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerFamily(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_family_classification(self):
|
||||
f = self.W._fc_family_for
|
||||
self.assertEqual(f('Odoo ERP Hosting (2026-05-01 to 2026-06-01)'), 'hosting')
|
||||
self.assertEqual(f('WordPress Website Hosting - Managed (at $50.00 / month)'), 'hosting')
|
||||
self.assertEqual(f('Managed Odoo - Standard (at $49.99 / month)'), 'managed')
|
||||
self.assertEqual(f('Daily Backup Protection'), 'addons')
|
||||
self.assertEqual(f('Remaining time on Daily Backup Protection after 27 May 2026'), 'addons')
|
||||
self.assertEqual(f('Something Unmapped'), 'other')
|
||||
|
||||
def test_income_account_per_family_distinct(self):
|
||||
a_host = self.W._fc_income_account('hosting')
|
||||
a_add = self.W._fc_income_account('addons')
|
||||
self.assertEqual(a_host.account_type, 'income')
|
||||
self.assertNotEqual(a_host, a_add) # split by family
|
||||
self.assertEqual(self.W._fc_income_account('hosting'), a_host) # idempotent
|
||||
```
|
||||
Append `from . import test_invoice_ledger` to `tests/__init__.py`.
|
||||
|
||||
- [ ] **Step 2: run** → FAIL (`_fc_family_for` missing).
|
||||
|
||||
- [ ] **Step 3: implement** — in `wizards/invoice_ledger.py`:
|
||||
```python
|
||||
_FAMILY_KEYWORDS = [
|
||||
('hosting', ['odoo erp hosting', 'wordpress website hosting']),
|
||||
('managed', ['managed']),
|
||||
('addons', ['daily backup', 'whatsapp', 'forms builder', 'white label']),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _fc_family_for(self, description):
|
||||
import re
|
||||
d = (description or '').lower()
|
||||
m = re.match(r'remaining time on (.+?)(?: after| from |\s*\()', d)
|
||||
if m:
|
||||
d = m.group(1) # classify proration by the prorated item
|
||||
for fam, kws in self._FAMILY_KEYWORDS:
|
||||
if any(k in d for k in kws):
|
||||
return fam
|
||||
return 'other'
|
||||
|
||||
@api.model
|
||||
def _fc_income_account(self, family):
|
||||
Account = self.env['account.account']
|
||||
code = 'NCR-' + family.upper()[:6]
|
||||
acc = Account.search([('code', '=', code)], limit=1)
|
||||
if not acc:
|
||||
acc = Account.create({
|
||||
'code': code, 'name': 'NexaCloud %s Revenue' % family.title(),
|
||||
'account_type': 'income'})
|
||||
return acc
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If `account.account.create` needs more required fields on this build, read `account_account.py` on trial and add them — don't weaken the test.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ledger service-family classification + per-family income accounts`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Tax derivation (match NexaCloud's invoice.tax)
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerTax(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
|
||||
tax = self.W._fc_tax_for(100.0, 13.0)
|
||||
self.assertTrue(tax, "expected an HST/13% sale tax on the Canadian COA")
|
||||
self.assertEqual(tax.type_tax_use, 'sale')
|
||||
# the chosen tax computes 13.00 on 100.00
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 13.0, places=2)
|
||||
|
||||
def test_tax_for_zero_is_zero_or_empty(self):
|
||||
tax = self.W._fc_tax_for(100.0, 0.0)
|
||||
if tax:
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 0.0, places=2)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement**:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_tax_for(self, subtotal, tax_amount):
|
||||
"""Map a NexaCloud invoice's (subtotal, tax_amount) to the Odoo sale tax whose
|
||||
computed tax equals it. Picks by effective percent; falls back to a 0% sale tax."""
|
||||
Tax = self.env['account.tax']
|
||||
sub = float(subtotal or 0.0)
|
||||
tax_amt = float(tax_amount or 0.0)
|
||||
if sub <= 0 or tax_amt <= 0:
|
||||
return Tax.search([('type_tax_use', '=', 'sale'), ('amount', '=', 0.0)], limit=1)
|
||||
rate = round(100.0 * tax_amt / sub)
|
||||
tax = Tax.search([('type_tax_use', '=', 'sale'), ('amount_type', '=', 'percent'),
|
||||
('amount', '=', float(rate))], limit=1)
|
||||
if not tax:
|
||||
tax = Tax.search([('type_tax_use', '=', 'sale'), ('name', 'ilike', '%s' % rate)], limit=1)
|
||||
return tax
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (Read reference if no 13% sale tax exists: `docker exec odoo-trial-app ... grep -i hst` the l10n_ca data; on nexamain confirm the HST 13% record from `nexa_coa_setup`.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ledger tax derivation matching source invoice tax`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Ingest invoices → draft account.move (idempotent)
|
||||
|
||||
**Read reference first:**
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"def action_post|invoice_line_ids|move_type\\\" /mnt/enterprise-addons/account_accountant/../account/models/account_move.py | head\"'"
|
||||
```
|
||||
Confirm `account.move.create({'move_type':'out_invoice','partner_id':..,'invoice_line_ids':[(0,0,{'name','quantity','price_unit','account_id','tax_ids'})]})` and `move.amount_untaxed/amount_tax/amount_total`.
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append) — uses a fixture invoice dict shaped like `_read_nexacloud_invoices` output:
|
||||
```python
|
||||
def _inv_fixture():
|
||||
return [{
|
||||
'id': 'inv-1', 'stripe_invoice_id': 'in_test1', 'invoice_number': 'NEX-0001',
|
||||
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
||||
'subtotal': 100.0, 'tax': 13.0, 'amount_paid': 0.0, 'paid_at': None,
|
||||
'items': [{'description': 'Odoo ERP Hosting (2026-05-01 to 2026-06-01)',
|
||||
'quantity': 1.0, 'unit_price': 100.0, 'amount': 100.0}],
|
||||
}]
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerIngest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.svc = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
|
||||
def test_ingest_creates_draft_invoice_with_right_totals(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(len(mv), 1)
|
||||
self.assertEqual(mv.move_type, 'out_invoice')
|
||||
self.assertEqual(mv.state, 'draft')
|
||||
self.assertAlmostEqual(mv.amount_untaxed, 100.0, places=2)
|
||||
self.assertAlmostEqual(mv.amount_tax, 13.0, places=2) # equals source tax
|
||||
self.assertAlmostEqual(mv.amount_total, 113.0, places=2)
|
||||
self.assertEqual(mv.partner_id.email, 'ar@acme.test')
|
||||
line = mv.invoice_line_ids
|
||||
self.assertEqual(line.account_id, self.W._fc_income_account('hosting'))
|
||||
|
||||
def test_ingest_is_idempotent(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.assertEqual(self.env['account.move'].search_count(
|
||||
[('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement** the partner resolver + `_ingest_invoices`:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_partner_for(self, inv):
|
||||
"""Resolve the unified partner for an invoice via the nexacloud account.link
|
||||
(by user_external_id); create partner+link if missing (covers NULL-subscription
|
||||
invoices, which still carry a user)."""
|
||||
service = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')], limit=1)
|
||||
link = self.env['fusion.billing.account.link']._resolve_or_create_partner(
|
||||
service, str(inv.get('user_external_id')),
|
||||
name=inv.get('partner_name'), email=inv.get('partner_email'))
|
||||
return link.partner_id
|
||||
|
||||
@api.model
|
||||
def _ingest_invoices(self, data, post=False):
|
||||
Move = self.env['account.move']
|
||||
cad = self.env.ref('base.CAD', raise_if_not_found=False) or self.env.company.currency_id
|
||||
summary = {'created': 0, 'updated': 0, 'posted': 0, 'skipped': [], 'failed': [], 'by_family': {}}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get('id') or '')
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
existing = Move.search([('x_fc_nexacloud_invoice_id', '=', nc_id)], limit=1)
|
||||
if existing:
|
||||
if existing.state != 'draft':
|
||||
summary['skipped'].append({'id': nc_id, 'reason': 'already posted'})
|
||||
continue
|
||||
existing.invoice_line_ids.unlink() # draft: replace lines
|
||||
move = existing
|
||||
else:
|
||||
move = Move.create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': self._fc_partner_for(inv).id,
|
||||
'invoice_date': inv.get('invoice_date'),
|
||||
'ref': inv.get('invoice_number'),
|
||||
'currency_id': cad.id,
|
||||
'x_fc_nexacloud_invoice_id': nc_id,
|
||||
'x_fc_stripe_invoice_id': inv.get('stripe_invoice_id'),
|
||||
})
|
||||
tax = self._fc_tax_for(inv.get('subtotal'), inv.get('tax'))
|
||||
line_vals = []
|
||||
for it in inv.get('items', []):
|
||||
fam = self._fc_family_for(it.get('description'))
|
||||
summary['by_family'][fam] = round(
|
||||
summary['by_family'].get(fam, 0.0) + float(it.get('amount') or 0.0), 2)
|
||||
line_vals.append((0, 0, {
|
||||
'name': it.get('description') or 'NexaCloud',
|
||||
'quantity': float(it.get('quantity') or 1.0),
|
||||
'price_unit': float(it.get('unit_price') or it.get('amount') or 0.0),
|
||||
'account_id': self._fc_income_account(fam).id,
|
||||
'tax_ids': [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||
}))
|
||||
move.write({'invoice_line_ids': line_vals})
|
||||
summary['updated' if existing else 'created'] += 1
|
||||
if post:
|
||||
move.action_post()
|
||||
summary['posted'] += 1
|
||||
self._fc_reconcile_payment(move, inv)
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Ledger ingest: invoice %s failed", nc_id)
|
||||
summary['failed'].append({'id': nc_id, 'error': '%s: %s' % (type(e).__name__, e)})
|
||||
return summary
|
||||
|
||||
@api.model
|
||||
def _fc_reconcile_payment(self, move, inv):
|
||||
"""Placeholder until Task 5; defined so post=True doesn't AttributeError."""
|
||||
return False
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If tax computes to 13.00 only when the company/fiscal position allows it, read the tax setup on trial; if `amount_tax` ≠ 13.00, the chosen tax is wrong — fix `_fc_tax_for`, never weaken the assertion.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ingest NexaCloud invoices -> draft account.move (idempotent)`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Reconcile Stripe payments (paid invoices show paid)
|
||||
|
||||
**Read reference first:** confirm the payment-register flow on trial:
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"_create_payments|def action_create_payments\\\" /mnt/enterprise-addons/account/wizard/account_payment_register.py | head\"'"
|
||||
```
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
def test_paid_invoice_is_reconciled_and_shows_paid(self):
|
||||
data = _inv_fixture()
|
||||
data[0].update({'status': 'paid', 'amount_paid': 113.0, 'paid_at': '2026-05-02'})
|
||||
self.W._ingest_invoices(data, post=True)
|
||||
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||
```
|
||||
(Add this inside `TestLedgerIngest`.)
|
||||
|
||||
- [ ] **Step 2: run** → FAIL (payment not reconciled).
|
||||
|
||||
- [ ] **Step 3: implement** `_fc_reconcile_payment` + a journal helper (replace the placeholder):
|
||||
```python
|
||||
@api.model
|
||||
def _fc_stripe_journal(self):
|
||||
Journal = self.env['account.journal']
|
||||
j = Journal.search([('code', '=', 'NCSTR')], limit=1)
|
||||
if not j:
|
||||
j = Journal.create({'name': 'NexaCloud Stripe', 'code': 'NCSTR', 'type': 'bank'})
|
||||
return j
|
||||
|
||||
@api.model
|
||||
def _fc_reconcile_payment(self, move, inv):
|
||||
paid = float(inv.get('amount_paid') or 0.0)
|
||||
if (inv.get('status') != 'paid' and paid <= 0) or move.state != 'posted':
|
||||
return False
|
||||
reg = self.env['account.payment.register'].with_context(
|
||||
active_model='account.move', active_ids=move.ids).create({
|
||||
'journal_id': self._fc_stripe_journal().id,
|
||||
'payment_date': inv.get('paid_at') or move.invoice_date or fields.Date.today(),
|
||||
'amount': paid or move.amount_total,
|
||||
})
|
||||
reg._create_payments()
|
||||
return True
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If `payment_state` is `in_payment` rather than `paid`, that's expected when the bank journal isn't reconciled to a statement — accept both, as the assertion does.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): reconcile Stripe payments so ingested invoices show paid`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Reader + wizard actions + bulk-post + cron
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `views/invoice_ledger_views.xml`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** for bulk-post + DSN guard (append):
|
||||
```python
|
||||
def test_post_ingested_posts_drafts(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
n = self.W._post_ingested()
|
||||
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertGreaterEqual(n, 1)
|
||||
|
||||
def test_read_invoices_guards_missing_dsn(self):
|
||||
from odoo.exceptions import UserError
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
with self.assertRaises(UserError):
|
||||
self.W._read_nexacloud_invoices()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement** `_post_ingested`, `_read_nexacloud_invoices`, `action_run`, and a cron entry:
|
||||
```python
|
||||
@api.model
|
||||
def _post_ingested(self):
|
||||
moves = self.env['account.move'].search([
|
||||
('x_fc_nexacloud_invoice_id', '!=', False),
|
||||
('state', '=', 'draft'), ('move_type', '=', 'out_invoice')])
|
||||
posted = 0
|
||||
for mv in moves:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
mv.action_post()
|
||||
posted += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.exception("Ledger post: move %s failed", mv.id)
|
||||
return posted
|
||||
|
||||
def _read_nexacloud_invoices(self, since=None):
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
dsn = self.env['ir.config_parameter'].sudo().get_param('fusion_billing.nexacloud_dsn')
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
where = "WHERE i.created_at >= %(since)s" if since else ""
|
||||
cur.execute(
|
||||
"SELECT i.id, i.stripe_invoice_id, i.invoice_number, i.user_id AS user_external_id, "
|
||||
"u.full_name AS partner_name, COALESCE(u.billing_email,u.email) AS partner_email, "
|
||||
"i.created_at AS invoice_date, i.currency, i.status, i.subtotal, i.tax, "
|
||||
"i.amount_paid, i.paid_at "
|
||||
"FROM invoices i JOIN users u ON u.id = i.user_id " + where +
|
||||
" ORDER BY i.created_at", {'since': since})
|
||||
invoices = {str(r['id']): dict(r, items=[]) for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"SELECT ii.invoice_id, ii.description, ii.quantity, ii.unit_price, ii.amount "
|
||||
"FROM invoice_items ii WHERE ii.invoice_id = ANY(%(ids)s)",
|
||||
{'ids': list(invoices.keys())})
|
||||
for r in cur.fetchall():
|
||||
inv = invoices.get(str(r['invoice_id']))
|
||||
if inv:
|
||||
inv['items'].append({'description': r['description'], 'quantity': r['quantity'],
|
||||
'unit_price': r['unit_price'], 'amount': r['amount']})
|
||||
for inv in invoices.values():
|
||||
inv['id'] = str(inv['id'])
|
||||
inv['user_external_id'] = str(inv['user_external_id'])
|
||||
return list(invoices.values())
|
||||
except psycopg2.Error as e:
|
||||
raise UserError("Failed reading NexaCloud invoices — schema may have changed:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def action_run(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_invoices()
|
||||
if self.dry_run:
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
res = {}
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
res.update(self._ingest_invoices(data, post=False))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
res['dry_run'] = True
|
||||
else:
|
||||
res = self._ingest_invoices(data, post=self.auto_post)
|
||||
self.result_summary = json.dumps(res, indent=2, default=str)
|
||||
if res.get('failed'):
|
||||
_logger.error("Ledger ingest: %s failed: %s", len(res['failed']), res['failed'])
|
||||
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||
```
|
||||
Add a daily cron to `views/invoice_ledger_views.xml`:
|
||||
```xml
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Ingest NexaCloud invoices (daily)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
```
|
||||
And `_cron_ingest_recent` (ingest invoices from the last 2 days, idempotent):
|
||||
```python
|
||||
def _cron_ingest_recent(self):
|
||||
from datetime import timedelta
|
||||
since = fields.Datetime.to_string(fields.Datetime.now() - timedelta(days=2))
|
||||
return self._ingest_invoices(self._read_nexacloud_invoices(since=since), post=True)
|
||||
```
|
||||
(Cron ships `active=False` — enabled only after the backfill is reviewed.)
|
||||
|
||||
- [ ] **Step 4: run** → PASS.
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): invoice-ledger reader, wizard actions, bulk-post, daily cron`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Prune obsolete metered shadow data
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
def test_prune_shadow_removes_shadow_subs_only(self):
|
||||
# a shadow sub + a normal order
|
||||
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
||||
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
||||
n = self.W._fc_prune_metered_shadow()
|
||||
self.assertFalse(shadow.exists())
|
||||
self.assertGreaterEqual(n.get('subscriptions', 0), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement**:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_prune_metered_shadow(self):
|
||||
"""Delete the superseded metered shadow data (shadow sale.orders, NC-* products,
|
||||
NexaCloud charges, reconciliation rows). Reversible only by re-import."""
|
||||
counts = {}
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
counts['subscriptions'] = len(subs)
|
||||
subs.unlink()
|
||||
prods = self.env['product.product'].search([('default_code', '=like', 'NC-%')])
|
||||
counts['products'] = len(prods)
|
||||
prods.unlink()
|
||||
ch = self.env['fusion.billing.charge'].search([])
|
||||
counts['charges'] = len(ch)
|
||||
ch.unlink()
|
||||
rec = self.env['fusion.billing.reconciliation'].search([])
|
||||
counts['reconciliations'] = len(rec)
|
||||
rec.unlink()
|
||||
return counts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If a product can't unlink due to references, archive instead — read the error and adjust.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): prune obsolete metered shadow data helper`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full suite + static checks
|
||||
|
||||
- [ ] `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0`.
|
||||
- [ ] `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo clean` → clean.
|
||||
- [ ] `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ | grep -v "sale.subscription.plan"` → only docstring.
|
||||
- [ ] commit any fixes.
|
||||
|
||||
## Done = invoice ledger ready to run
|
||||
|
||||
Then (separate, gated, NOT in this plan): on nexamain — prune shadow data, **dry-run** the full backfill (review the per-family $ summary + unmatched "Other" lines), ingest **as draft**, you review a sample, **bulk-post**, enable the daily cron.
|
||||
288
docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md
Normal file
288
docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# NexaCloud Dual-Run Reconciliation (Sub-project #2d) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Checkbox steps.
|
||||
|
||||
**Goal:** Compute, per shadow subscription + period, Odoo's would-be charge vs NexaCloud's actual charge and record the delta in `fusion.billing.reconciliation`, so the dual-run can prove parity before any flip.
|
||||
|
||||
**Architecture:** A pure `_compute_reconciliation(...)` (testable) + `_reconcile_rows(rows)` (resolves the shadow sub → flat + charge, upserts recon rows) + a read-only `_read_reconciliation_rows()` (psycopg2, integration glue). Triggered from the import wizard + cron. Odoo-only; reads NexaCloud, writes only reconciliation rows.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise, `psycopg2`. Tests: `TransactionCase` on odoo-trial (`bash scripts/fcb_test_on_trial.sh`, pass = `FCB_EXIT=0`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-reconciliation-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 2a amendment — store the NexaCloud plan id on the shadow subscription
|
||||
|
||||
**Files:** `models/sale_order.py`, `wizards/import_wizard.py`, `tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: failing test** (append to `TestImporterSubscriptions` in `tests/test_importer.py`):
|
||||
```python
|
||||
def test_subscription_records_nexacloud_plan_id(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
sub1 = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
self.assertEqual(sub1.x_fc_nexacloud_plan_id, 'p-1')
|
||||
```
|
||||
- [ ] **Step 2: run** `bash scripts/fcb_test_on_trial.sh` → FAIL (field missing).
|
||||
- [ ] **Step 3: add the field** to `models/sale_order.py` (next to the other `x_fc_*`):
|
||||
```python
|
||||
x_fc_nexacloud_plan_id = fields.Char(index=True, copy=False)
|
||||
```
|
||||
- [ ] **Step 4: set it in the importer.** In `wizards/import_wizard.py` `_import_subscription`, add the plan id to both the `shadow_vals` dict (so re-runs keep it current) :
|
||||
```python
|
||||
shadow_vals = {
|
||||
"x_fc_nexacloud_deployment_id": str(srow.get("deployment_id") or ""),
|
||||
"x_fc_nexacloud_plan_id": str(srow.get("plan_id") or ""),
|
||||
"x_fc_billing_service_id": service.id, "x_fc_shadow": True,
|
||||
}
|
||||
```
|
||||
- [ ] **Step 5: run** → PASS.
|
||||
- [ ] **Step 6: commit** `feat(billing): record NexaCloud plan id on shadow subscription (for reconciliation)`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: pure reconciliation math
|
||||
|
||||
**Files:** `models/reconciliation.py`, `tests/test_reconciliation.py` (new), `tests/__init__.py`
|
||||
|
||||
- [ ] **Step 1:** append `from . import test_reconciliation` to `tests/__init__.py`.
|
||||
- [ ] **Step 2: failing test** `tests/test_reconciliation.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconciliationMath(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'CPU', 'plan_code': 'p-1', 'metric_id': self.metric.id,
|
||||
'included_quota': 18000.0, 'price_per_unit': 0.0075,
|
||||
'unit_batch': 3600.0, 'charge_model': 'standard'})
|
||||
|
||||
def test_match_within_tolerance(self):
|
||||
# flat 20 + 0 overage (under quota) vs external 20.00 -> match
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 10000.0, 20.0, 0.01)
|
||||
self.assertAlmostEqual(odoo_amt, 20.0)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
def test_overage_match(self):
|
||||
# flat 20 + 2 core-hours overage (7200s -> $0.015) = 20.015 vs external 20.015
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 18000.0 + 7200.0, 20.015, 0.01)
|
||||
self.assertAlmostEqual(odoo_amt, 20.015, places=4)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
def test_delta_flags_mismatch(self):
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 18000.0, 25.0, 0.01) # external 25 vs odoo 20
|
||||
self.assertAlmostEqual(delta, -5.0, places=2)
|
||||
self.assertEqual(status, 'delta')
|
||||
```
|
||||
- [ ] **Step 3: run** → FAIL (`_compute_reconciliation` missing).
|
||||
- [ ] **Step 4: implement** in `models/reconciliation.py` (add `from odoo import api, fields, models`):
|
||||
```python
|
||||
@api.model
|
||||
def _compute_reconciliation(self, flat_amount, charge, cpu_seconds, external_amount,
|
||||
tolerance=0.01):
|
||||
"""Return (odoo_amount, delta, status). odoo = flat + overage(cpu_seconds);
|
||||
delta = odoo - external; status 'match' if |delta| <= tolerance else 'delta'."""
|
||||
_units, overage = charge._compute_billable(cpu_seconds) if charge else (0.0, 0.0)
|
||||
odoo_amount = round((flat_amount or 0.0) + (overage or 0.0), 2)
|
||||
delta = round(odoo_amount - (external_amount or 0.0), 2)
|
||||
status = 'match' if abs(delta) <= (tolerance or 0.0) else 'delta'
|
||||
return odoo_amount, delta, status
|
||||
```
|
||||
- [ ] **Step 5: run** → PASS.
|
||||
- [ ] **Step 6: commit** `feat(billing): reconciliation math (odoo-computed vs external)`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `_reconcile_rows` — resolve shadow sub and upsert recon rows
|
||||
|
||||
**Files:** `models/reconciliation.py`, `tests/test_reconciliation.py`
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileRows(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
from odoo.addons.fusion_centralize_billing.tests.test_importer import _fixture
|
||||
self.Wizard._import_rows(_fixture()) # creates shadow subs + p-1 charge
|
||||
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||
|
||||
def test_creates_one_row_per_subscription_with_status(self):
|
||||
# s-1 monthly flat 20, no overage; external 20.00 -> match.
|
||||
# s-2 yearly flat 200; external 250 -> delta -50.
|
||||
summary = self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0},
|
||||
{'subscription_external_id': 's-2', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 250.0},
|
||||
])
|
||||
rows = self.Recon.search([('period', '=', '2026-05')])
|
||||
self.assertEqual(len(rows), 2)
|
||||
s1 = rows.filtered(lambda r: r.odoo_amount == 20.0)
|
||||
self.assertEqual(s1.status, 'match')
|
||||
s2 = rows.filtered(lambda r: r.odoo_amount == 200.0)
|
||||
self.assertEqual(s2.status, 'delta')
|
||||
self.assertAlmostEqual(s2.delta, -50.0, places=2)
|
||||
self.assertEqual(summary['match'], 1)
|
||||
self.assertEqual(summary['delta'], 1)
|
||||
|
||||
def test_rerun_upserts(self):
|
||||
row = [{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0}]
|
||||
self.Recon._reconcile_rows(row)
|
||||
self.Recon._reconcile_rows(row)
|
||||
self.assertEqual(self.Recon.search_count(
|
||||
[('period', '=', '2026-05'),
|
||||
('partner_id', '=', self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-1')]).partner_id.id)]), 1)
|
||||
|
||||
def test_unknown_subscription_is_skipped(self):
|
||||
summary = self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 'nope', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 1.0}])
|
||||
self.assertTrue(any(s['id'] == 'nope' for s in summary['skipped']))
|
||||
```
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
- [ ] **Step 3: implement** in `models/reconciliation.py`:
|
||||
```python
|
||||
@api.model
|
||||
def _reconcile_rows(self, rows, tolerance=0.01):
|
||||
SaleOrder = self.env['sale.order']
|
||||
Charge = self.env['fusion.billing.charge']
|
||||
Service = self.env['fusion.billing.service']
|
||||
service = Service.search([('code', '=', 'nexacloud')], limit=1)
|
||||
summary = {'match': 0, 'delta': 0, 'skipped': [], 'failed': []}
|
||||
for r in rows:
|
||||
sub_ext = str(r.get('subscription_external_id') or '')
|
||||
period = str(r.get('period') or '')
|
||||
try:
|
||||
sub = SaleOrder.search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
|
||||
if not sub:
|
||||
summary['skipped'].append({'id': sub_ext, 'reason': 'unknown subscription'})
|
||||
continue
|
||||
charge = Charge.search(
|
||||
[('plan_code', '=', sub.x_fc_nexacloud_plan_id)], limit=1)
|
||||
plan_line = sub.order_line.filtered(
|
||||
lambda l: l.product_id.default_code
|
||||
and l.product_id.default_code.startswith('NC-PLAN-'))
|
||||
flat = plan_line[:1].price_unit
|
||||
odoo_amount, delta, status = self._compute_reconciliation(
|
||||
flat, charge, float(r.get('cpu_seconds') or 0.0),
|
||||
float(r.get('external_amount') or 0.0), tolerance)
|
||||
vals = {
|
||||
'service_id': service.id if service else False,
|
||||
'partner_id': sub.partner_id.id, 'period': period,
|
||||
'odoo_amount': odoo_amount,
|
||||
'external_amount': float(r.get('external_amount') or 0.0),
|
||||
'delta': delta, 'status': status,
|
||||
}
|
||||
existing = self.search([
|
||||
('service_id', '=', vals['service_id']),
|
||||
('partner_id', '=', sub.partner_id.id), ('period', '=', period)], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
self.create(vals)
|
||||
summary['match' if status == 'match' else 'delta'] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||
summary['failed'].append({'id': sub_ext, 'error': '%s: %s' % (type(e).__name__, e)})
|
||||
return summary
|
||||
```
|
||||
- [ ] **Step 4: run** → PASS.
|
||||
- [ ] **Step 5: commit** `feat(billing): reconcile shadow subscriptions -> fusion.billing.reconciliation`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: read NexaCloud actuals + wizard trigger
|
||||
|
||||
**Files:** `wizards/import_wizard.py`, `views/import_wizard_views.xml`
|
||||
|
||||
- [ ] **Step 1: add the reader** in `wizards/import_wizard.py` (reuses the DSN + the same connect/guard pattern as `_read_nexacloud_rows`). Aggregate usage cpu_hours per (subscription, period) and the invoice subtotal per (subscription, period); return rows shaped for `_reconcile_rows`:
|
||||
```python
|
||||
def _read_reconciliation_rows(self):
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
# period label = YYYY-MM of the usage period_start; cpu_seconds = cpu_hours*3600
|
||||
cur.execute("""
|
||||
SELECT u.subscription_id::text AS subscription_external_id,
|
||||
to_char(u.period_start, 'YYYY-MM') AS period,
|
||||
COALESCE(SUM(u.cpu_hours), 0) * 3600.0 AS cpu_seconds
|
||||
FROM usage_records u
|
||||
GROUP BY u.subscription_id, to_char(u.period_start, 'YYYY-MM')""")
|
||||
usage = {(r['subscription_external_id'], r['period']): r for r in cur.fetchall()}
|
||||
cur.execute("""
|
||||
SELECT i.subscription_id::text AS subscription_external_id,
|
||||
to_char(ii.period_start, 'YYYY-MM') AS period,
|
||||
COALESCE(SUM(i.subtotal), 0) AS external_amount
|
||||
FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id
|
||||
GROUP BY i.subscription_id, to_char(ii.period_start, 'YYYY-MM')""")
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
key = (r['subscription_external_id'], r['period'])
|
||||
rows.append({
|
||||
'subscription_external_id': r['subscription_external_id'],
|
||||
'period': r['period'],
|
||||
'cpu_seconds': float((usage.get(key) or {}).get('cpu_seconds') or 0.0),
|
||||
'external_amount': float(r['external_amount'] or 0.0)})
|
||||
return rows
|
||||
except psycopg2.Error as e:
|
||||
raise UserError("Failed reading NexaCloud actuals — schema may have changed:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def action_run_reconciliation(self):
|
||||
self.ensure_one()
|
||||
rows = self._read_reconciliation_rows()
|
||||
summary = self.env['fusion.billing.reconciliation']._reconcile_rows(rows)
|
||||
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||
self.failed_count = len(summary.get('failed') or [])
|
||||
if summary.get('delta') or summary.get('failed'):
|
||||
_logger.error("NexaCloud reconciliation: %s delta / %s failed row(s): %s",
|
||||
summary.get('delta'), len(summary.get('failed') or []), summary)
|
||||
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||
```
|
||||
- [ ] **Step 2: add the button** to `views/import_wizard_views.xml` footer:
|
||||
```xml
|
||||
<button name="action_run_reconciliation" type="object"
|
||||
string="Run Reconciliation" class="btn-secondary"/>
|
||||
```
|
||||
- [ ] **Step 3:** `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0` (module upgrades; reader is integration-only, not unit-tested).
|
||||
- [ ] **Step 4: commit** `feat(billing): NexaCloud reconciliation reader + wizard trigger`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: full suite + static checks
|
||||
|
||||
- [ ] `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0`.
|
||||
- [ ] `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo clean` → clean.
|
||||
- [ ] `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ | grep -v "sale.subscription.plan"` → only docstring.
|
||||
- [ ] commit any fixes.
|
||||
|
||||
## Done = 2d complete
|
||||
|
||||
The dual-run can be run each cycle (button/cron): it reads NexaCloud usage + invoice subtotals, computes Odoo's would-be charge, and records per-subscription `match`/`delta` rows. Flip happens (manually) once a cycle is all-match.
|
||||
@@ -0,0 +1,864 @@
|
||||
# Fusion Clock — Province-Aware Automatic Unpaid Break Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the unpaid meal break deduct automatically from worked hours on every path (portal, kiosk, NFC, cron, **and manual backend entry**), using a 2-tier per-province rule table (Ontario: 5h→30min, 10h→+30min), with no duplicated logic.
|
||||
|
||||
**Architecture:** A new `fusion.clock.break.rule` table holds the per-province thresholds. `hr.employee._get_fclk_break_rule()` resolves an employee's rule from its company's province (global default fallback). `hr.attendance.x_fclk_break_minutes` becomes a single stored **computed** field — `statutory_break(worked_hours) + Σ penalty_minutes` — that recomputes on every save and replaces the four scattered write sites (controller `_apply_break_deduction` ×3 call sites, the auto-clock-out cron, and the penalty code's manual write).
|
||||
|
||||
**Tech Stack:** Odoo 19, Python, QWeb/XML views, Odoo test framework (`TransactionCase`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Dev environment & sync (READ FIRST — applies to every task)
|
||||
|
||||
**Two working copies (per project memory `feedback_dual_path_fusion_clock`):**
|
||||
- **Git/source tree (edit + commit here):** `K:\Github\Odoo-Modules\fusion_clock`
|
||||
- **Docker/active tree (what the container loads):** `K:\Github\odoo-modsdev\addons\fusion_clock`
|
||||
|
||||
Edit in the **git tree**, then **mirror to the Docker tree before every test run**:
|
||||
|
||||
```powershell
|
||||
robocopy "K:\Github\Odoo-Modules\fusion_clock" "K:\Github\odoo-modsdev\addons\fusion_clock" /MIR /XD ".git" "__pycache__" /XF "*.pyc" /NFL /NDL /NJH /NJS; if ($LASTEXITCODE -lt 8) { "sync ok" } else { "sync FAILED" }
|
||||
```
|
||||
(robocopy exit codes < 8 = success.) **Preflight:** if `K:\Github\odoo-modsdev\addons\fusion_clock` does not exist, the dual-tree setup changed — STOP and confirm the active copy with the user before continuing.
|
||||
|
||||
**Container/DB:** `odoo-modsdev-app` / db `modsdev` (per memory `reference_docker_env_names`).
|
||||
|
||||
**Canonical commands** (note the ephemeral ports — `--test-enable` forces `http_spawn()` so 8069/8072 collide without them; per repo CLAUDE.md):
|
||||
|
||||
- Run this module's tests:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
|
||||
```
|
||||
- Plain upgrade (no tests):
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -50
|
||||
```
|
||||
- Pyflakes a changed Python file (catches undefined names instantly):
|
||||
```bash
|
||||
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/<relpath>.py
|
||||
```
|
||||
|
||||
**Commit:** only from the git tree (`git -C "K:/Github/Odoo-Modules" ...`). Per memory `feedback_always_push_to_main`, push after each commit on `main`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Created:**
|
||||
- `fusion_clock/models/clock_break_rule.py` — the `fusion.clock.break.rule` model + tier engine + constraints.
|
||||
- `fusion_clock/data/clock_break_rule_data.xml` — seed Ontario rule (`is_default`).
|
||||
- `fusion_clock/views/clock_break_rule_views.xml` — list/form/action for the rule.
|
||||
- `fusion_clock/migrations/19.0.4.1.0/post-migrate.py` — drop retired param + recompute break.
|
||||
- `fusion_clock/tests/test_break_rules.py` — all new tests.
|
||||
|
||||
**Modified:**
|
||||
- `fusion_clock/models/__init__.py` — import the new model.
|
||||
- `fusion_clock/models/hr_employee.py` — add `_get_fclk_break_rule()`.
|
||||
- `fusion_clock/models/hr_attendance.py` — `x_fclk_break_minutes` → stored compute; drop cron break-write.
|
||||
- `fusion_clock/controllers/clock_api.py` — delete `_apply_break_deduction`, its clock-out call, and the penalty break-write.
|
||||
- `fusion_clock/controllers/clock_kiosk.py` — delete the `_apply_break_deduction` call.
|
||||
- `fusion_clock/controllers/clock_nfc_kiosk.py` — delete the `_apply_break_deduction` call.
|
||||
- `fusion_clock/models/res_config_settings.py` — remove `fclk_break_threshold_hours`.
|
||||
- `fusion_clock/views/res_config_settings_views.xml` — remove threshold row; relabel default-break as scheduling-only; point to Break Rules.
|
||||
- `fusion_clock/data/ir_config_parameter_data.xml` — remove the `break_threshold_hours` seed record.
|
||||
- `fusion_clock/security/ir.model.access.csv` — manager access for the new model.
|
||||
- `fusion_clock/views/clock_menus.xml` — "Break Rules" config menu.
|
||||
- `fusion_clock/__manifest__.py` — version bump + new data/view files.
|
||||
- `fusion_clock/tests/__init__.py` — import the new test module.
|
||||
- `fusion_clock/tests/test_settings.py` — assert the retired field is gone.
|
||||
- `fusion_clock/CLAUDE.md` — model map, settings keys, break gotcha (Task 5).
|
||||
|
||||
**Behaviour-change note (intentional, approved by spec §4.3):** today a *late-in* penalty written at clock-in (e.g. +15) is silently swallowed at clock-out because `_apply_break_deduction` does `max(break, current)`. The new compute makes **all** penalty minutes strictly additive (`statutory + Σ penalties`), so a late-in penalty on a long shift is no longer lost. Net hours for such shifts will be correctly lower than before.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: New model `fusion.clock.break.rule`
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_clock/models/clock_break_rule.py`
|
||||
- Create: `fusion_clock/data/clock_break_rule_data.xml`
|
||||
- Create: `fusion_clock/views/clock_break_rule_views.xml`
|
||||
- Create: `fusion_clock/tests/test_break_rules.py`
|
||||
- Modify: `fusion_clock/models/__init__.py`
|
||||
- Modify: `fusion_clock/tests/__init__.py`
|
||||
- Modify: `fusion_clock/security/ir.model.access.csv`
|
||||
- Modify: `fusion_clock/views/clock_menus.xml`
|
||||
- Modify: `fusion_clock/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — create `fusion_clock/tests/test_break_rules.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo.tests import tagged, TransactionCase
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestBreakRules(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True')
|
||||
cls.Rule = cls.env['fusion.clock.break.rule']
|
||||
cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1)
|
||||
cls.employee = cls.env['hr.employee'].create({'name': 'FCLK Break Test'})
|
||||
|
||||
def _mk_att(self, hours):
|
||||
check_in = datetime(2026, 1, 5, 9, 0, 0)
|
||||
return self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': check_in,
|
||||
'check_out': check_in + timedelta(hours=hours),
|
||||
})
|
||||
|
||||
# ---- Task 1: tier engine + constraints ----
|
||||
def test_break_minutes_for_tiers(self):
|
||||
rule = self.Rule.create({
|
||||
'name': 'Tier Test', 'is_default': False,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
self.assertEqual(rule.break_minutes_for(4.99), 0.0)
|
||||
self.assertEqual(rule.break_minutes_for(5.0), 30.0)
|
||||
self.assertEqual(rule.break_minutes_for(9.99), 30.0)
|
||||
self.assertEqual(rule.break_minutes_for(10.0), 60.0)
|
||||
self.assertEqual(rule.break_minutes_for(12.0), 60.0)
|
||||
|
||||
def test_second_tier_must_exceed_first(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.Rule.create({
|
||||
'name': 'Bad', 'is_default': False,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 5.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
|
||||
def test_single_default_enforced(self):
|
||||
self.assertTrue(self.default_rule, "seed default rule must exist")
|
||||
with self.assertRaises(ValidationError):
|
||||
self.Rule.create({
|
||||
'name': 'Another Default', 'is_default': True, 'active': True,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
```
|
||||
|
||||
Append the import to `fusion_clock/tests/__init__.py` (add the line if not already present):
|
||||
|
||||
```python
|
||||
from . import test_break_rules
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the model** — `fusion_clock/models/clock_break_rule.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class FusionClockBreakRule(models.Model):
|
||||
_name = 'fusion.clock.break.rule'
|
||||
_description = 'Statutory Break Rule'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
country_id = fields.Many2one('res.country', string='Country')
|
||||
state_id = fields.Many2one(
|
||||
'res.country.state',
|
||||
string='Province / State',
|
||||
help="Employees whose company is in this province use this rule.",
|
||||
)
|
||||
is_default = fields.Boolean(
|
||||
string='Default Rule',
|
||||
help="Used when an employee's company province matches no other rule. "
|
||||
"Only one active rule may be the default.",
|
||||
)
|
||||
break1_after_hours = fields.Float(
|
||||
string='First Break After (h)', default=5.0,
|
||||
help="Worked hours at or above this trigger the first unpaid break.",
|
||||
)
|
||||
break1_minutes = fields.Float(
|
||||
string='First Break (min)', default=30.0,
|
||||
help="Length of the first unpaid break. 0 disables it.",
|
||||
)
|
||||
break2_after_hours = fields.Float(
|
||||
string='Second Break After (h)', default=10.0,
|
||||
help="Worked hours at or above this add the second unpaid break.",
|
||||
)
|
||||
break2_minutes = fields.Float(
|
||||
string='Second Break (min)', default=30.0,
|
||||
help="Length of the second unpaid break. 0 disables it.",
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
def break_minutes_for(self, worked_hours):
|
||||
"""Total statutory unpaid break (minutes) for the given worked hours.
|
||||
|
||||
Tiers are inclusive (``>=``): a break applies when worked hours are
|
||||
equal to or greater than the threshold. The second tier adds on top of
|
||||
the first.
|
||||
"""
|
||||
self.ensure_one()
|
||||
worked = worked_hours or 0.0
|
||||
total = 0.0
|
||||
if self.break1_minutes and worked >= self.break1_after_hours:
|
||||
total += self.break1_minutes
|
||||
if self.break2_minutes and worked >= self.break2_after_hours:
|
||||
total += self.break2_minutes
|
||||
return total
|
||||
|
||||
@api.constrains('break1_after_hours', 'break1_minutes',
|
||||
'break2_after_hours', 'break2_minutes')
|
||||
def _check_tiers(self):
|
||||
for rule in self:
|
||||
if min(rule.break1_after_hours, rule.break1_minutes,
|
||||
rule.break2_after_hours, rule.break2_minutes) < 0:
|
||||
raise ValidationError(_("Break hours and minutes cannot be negative."))
|
||||
if rule.break2_minutes and rule.break2_after_hours <= rule.break1_after_hours:
|
||||
raise ValidationError(_(
|
||||
"The second break threshold (%(n2)s h) must be greater than "
|
||||
"the first (%(n1)s h).",
|
||||
n2=rule.break2_after_hours, n1=rule.break1_after_hours))
|
||||
|
||||
@api.constrains('is_default', 'active')
|
||||
def _check_single_default(self):
|
||||
for rule in self:
|
||||
if rule.is_default and rule.active:
|
||||
dupe = self.search([
|
||||
('is_default', '=', True), ('active', '=', True),
|
||||
('id', '!=', rule.id),
|
||||
], limit=1)
|
||||
if dupe:
|
||||
raise ValidationError(_(
|
||||
"Only one active break rule can be the default "
|
||||
"(currently: %s).", dupe.name))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register the model** — add to `fusion_clock/models/__init__.py` after the `clock_penalty` import:
|
||||
|
||||
```python
|
||||
from . import clock_break_rule
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Grant access** — append one row to `fusion_clock/security/ir.model.access.csv`:
|
||||
|
||||
```
|
||||
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
|
||||
```
|
||||
|
||||
(No user/portal grant needed — the resolver reads the table via `sudo()`.)
|
||||
|
||||
- [ ] **Step 5: Seed the Ontario rule** — create `fusion_clock/data/clock_break_rule_data.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="break_rule_ontario" model="fusion.clock.break.rule">
|
||||
<field name="name">Ontario</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="state_id" ref="base.state_ca_on"/>
|
||||
<field name="is_default" eval="True"/>
|
||||
<field name="break1_after_hours">5.0</field>
|
||||
<field name="break1_minutes">30.0</field>
|
||||
<field name="break2_after_hours">10.0</field>
|
||||
<field name="break2_minutes">30.0</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Views + action** — create `fusion_clock/views/clock_break_rule_views.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.break.rule.list</field>
|
||||
<field name="model">fusion.clock.break.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="state_id"/>
|
||||
<field name="country_id" optional="hide"/>
|
||||
<field name="break1_after_hours" widget="float_time"/>
|
||||
<field name="break1_minutes"/>
|
||||
<field name="break2_after_hours" widget="float_time"/>
|
||||
<field name="break2_minutes"/>
|
||||
<field name="is_default"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.break.rule.form</field>
|
||||
<field name="model">fusion.clock.break.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
|
||||
invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="e.g. Ontario"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Jurisdiction">
|
||||
<field name="country_id"/>
|
||||
<field name="state_id"
|
||||
domain="[('country_id', '=', country_id)]"/>
|
||||
<field name="is_default"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group string="Unpaid Break Tiers">
|
||||
<label for="break1_after_hours" string="First break after"/>
|
||||
<div class="o_row">
|
||||
<field name="break1_after_hours" widget="float_time"/>
|
||||
<span>h →</span>
|
||||
<field name="break1_minutes"/>
|
||||
<span>min</span>
|
||||
</div>
|
||||
<label for="break2_after_hours" string="Second break after"/>
|
||||
<div class="o_row">
|
||||
<field name="break2_after_hours" widget="float_time"/>
|
||||
<span>h →</span>
|
||||
<field name="break2_minutes"/>
|
||||
<span>min</span>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<p class="text-muted">
|
||||
Breaks are unpaid and deducted from actual worked hours. A tier with
|
||||
0 minutes is disabled. Triggers are inclusive — a break applies when
|
||||
worked hours are equal to or above the threshold.
|
||||
</p>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
|
||||
<field name="name">Break Rules</field>
|
||||
<field name="res_model">fusion.clock.break.rule</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'active_test': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
|
||||
<p>Define unpaid meal-break thresholds per province/country. Employees inherit
|
||||
the rule matching their company's province, or the default rule.</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add the menu** — in `fusion_clock/views/clock_menus.xml`, insert after the `menu_fusion_clock_locations_config` menuitem (the Locations config item) and before `menu_fusion_clock_nfc_enrollment`:
|
||||
|
||||
```xml
|
||||
<menuitem id="menu_fusion_clock_break_rules"
|
||||
name="Break Rules"
|
||||
parent="menu_fusion_clock_config"
|
||||
action="action_fusion_clock_break_rule"
|
||||
sequence="25"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Wire the manifest** — in `fusion_clock/__manifest__.py`:
|
||||
|
||||
**Do NOT bump the version yet** — it stays `19.0.4.0.3` until Task 4, so the
|
||||
`19.0.4.1.0` migration actually fires in dev (Odoo only runs a version's migration
|
||||
when the installed version is *lower* than the manifest version).
|
||||
|
||||
Add the seed data file after `'data/ir_config_parameter_data.xml',`:
|
||||
```python
|
||||
'data/clock_break_rule_data.xml',
|
||||
```
|
||||
Add the view file after `'views/clock_schedule_views.xml',`:
|
||||
```python
|
||||
'views/clock_break_rule_views.xml',
|
||||
```
|
||||
(Data and view files reload on every `-u` regardless of the version number, so the
|
||||
new model/menu install without a bump. No assets change in this plan, so the bump's
|
||||
only purpose is the migration trigger — deferred to Task 4.)
|
||||
|
||||
- [ ] **Step 9: Sync, upgrade, run tests**
|
||||
|
||||
Sync (see preamble), then:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
|
||||
```
|
||||
Expected: module upgrades cleanly; `test_break_minutes_for_tiers`, `test_second_tier_must_exceed_first`, `test_single_default_enforced` PASS. (Other tests in the class will error until Tasks 2–3 add their dependencies — that's expected if you scoped the run; otherwise the not-yet-added methods simply don't exist yet.)
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/clock_break_rule.py fusion_clock/models/__init__.py fusion_clock/data/clock_break_rule_data.xml fusion_clock/views/clock_break_rule_views.xml fusion_clock/views/clock_menus.xml fusion_clock/security/ir.model.access.csv fusion_clock/__manifest__.py fusion_clock/tests/test_break_rules.py fusion_clock/tests/__init__.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): add fusion.clock.break.rule per-province break table" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Jurisdiction resolver on `hr.employee`
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/hr_employee.py`
|
||||
- Modify: `fusion_clock/tests/test_break_rules.py`
|
||||
|
||||
- [ ] **Step 1: Add the resolver tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
|
||||
|
||||
```python
|
||||
# ---- Task 2: jurisdiction resolver ----
|
||||
def test_resolver_matches_company_province(self):
|
||||
bc = self.env.ref('base.state_ca_bc')
|
||||
bc_rule = self.Rule.create({
|
||||
'name': 'British Columbia', 'state_id': bc.id, 'is_default': False,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
self.employee.company_id.state_id = bc.id
|
||||
self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule)
|
||||
|
||||
def test_resolver_falls_back_to_default(self):
|
||||
self.assertTrue(self.default_rule, "seed default rule must exist")
|
||||
alberta = self.env.ref('base.state_ca_ab') # no rule for AB
|
||||
self.employee.company_id.state_id = alberta.id
|
||||
self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Sync, then:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
Expected: FAIL — `AttributeError: 'hr.employee' object has no attribute '_get_fclk_break_rule'`.
|
||||
|
||||
- [ ] **Step 3: Implement the resolver** — in `fusion_clock/models/hr_employee.py`, add this method immediately after the `_get_fclk_break_minutes` method (after its `return float(...)` block, before `_get_fclk_scheduled_times`):
|
||||
|
||||
```python
|
||||
def _get_fclk_break_rule(self):
|
||||
"""Return the statutory break rule for this employee.
|
||||
|
||||
Resolution: company's province → matching rule; else the global default
|
||||
rule; else an empty recordset (caller treats as zero break). Read via
|
||||
sudo so the portal net-hours compute can resolve it without a direct ACL.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Rule = self.env['fusion.clock.break.rule'].sudo()
|
||||
rule = Rule.browse()
|
||||
state = self.company_id.state_id
|
||||
if state:
|
||||
rule = Rule.search([('state_id', '=', state.id)], limit=1)
|
||||
if not rule:
|
||||
rule = Rule.search([('is_default', '=', True)], limit=1)
|
||||
return rule
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify they pass**
|
||||
|
||||
Sync, then re-run the Step 2 command. Expected: `test_resolver_matches_company_province` and `test_resolver_falls_back_to_default` PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_employee.py fusion_clock/tests/test_break_rules.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): resolve employee break rule from company province" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `x_fclk_break_minutes` → stored compute; remove all manual writes
|
||||
|
||||
This task is atomic: once the field is computed (no inverse), any remaining `write({'x_fclk_break_minutes': ...})` raises at runtime, so the field conversion and the removal of all four write sites must land together.
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/hr_attendance.py`
|
||||
- Modify: `fusion_clock/controllers/clock_api.py`
|
||||
- Modify: `fusion_clock/controllers/clock_kiosk.py`
|
||||
- Modify: `fusion_clock/controllers/clock_nfc_kiosk.py`
|
||||
- Modify: `fusion_clock/tests/test_break_rules.py`
|
||||
|
||||
- [ ] **Step 1: Add the attendance tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
|
||||
|
||||
```python
|
||||
# ---- Task 3: automatic deduction on every path ----
|
||||
def test_manual_attendance_applies_statutory_break(self):
|
||||
att = self._mk_att(6) # 6h >= 5 -> first break
|
||||
self.assertEqual(att.x_fclk_break_minutes, 30.0)
|
||||
self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2)
|
||||
|
||||
def test_manual_edit_extends_break(self):
|
||||
att = self._mk_att(6)
|
||||
self.assertEqual(att.x_fclk_break_minutes, 30.0)
|
||||
att.check_out = att.check_in + timedelta(hours=10) # now >= 10
|
||||
self.assertEqual(att.x_fclk_break_minutes, 60.0)
|
||||
self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2)
|
||||
|
||||
def test_under_first_threshold_no_break(self):
|
||||
att = self._mk_att(4) # 4h < 5 -> nothing
|
||||
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||
self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2)
|
||||
|
||||
def test_penalty_minutes_are_additive(self):
|
||||
att = self._mk_att(6) # statutory 30
|
||||
self.env['fusion.clock.penalty'].create({
|
||||
'attendance_id': att.id,
|
||||
'employee_id': self.employee.id,
|
||||
'penalty_type': 'early_out',
|
||||
'penalty_minutes': 15.0,
|
||||
'date': att.check_in.date(),
|
||||
})
|
||||
self.assertEqual(att.x_fclk_break_minutes, 45.0)
|
||||
|
||||
def test_master_toggle_off_zero_statutory(self):
|
||||
self.ICP.set_param('fusion_clock.auto_deduct_break', 'False')
|
||||
att = self._mk_att(6)
|
||||
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||
|
||||
def test_open_attendance_zero_break(self):
|
||||
att = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2026, 1, 5, 9, 0, 0),
|
||||
})
|
||||
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Sync, then run the module tests. Expected: the new tests FAIL — e.g. `test_manual_attendance_applies_statutory_break` asserts 30 but gets 0 (no write override exists yet).
|
||||
|
||||
- [ ] **Step 3: Convert the field to a stored compute** — in `fusion_clock/models/hr_attendance.py`, replace the field definition:
|
||||
|
||||
OLD:
|
||||
```python
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=0.0,
|
||||
tracking=True,
|
||||
help="Break duration in minutes to deduct from worked hours.",
|
||||
)
|
||||
```
|
||||
NEW:
|
||||
```python
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
compute='_compute_fclk_break_minutes',
|
||||
store=True,
|
||||
tracking=True,
|
||||
help="Unpaid break deducted from worked hours: statutory break (per the "
|
||||
"employee's province rule, from actual hours worked) plus any penalty "
|
||||
"minutes. Computed automatically on every save.",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the compute method** — in the same file, insert this method immediately before the `_compute_net_hours` method (just above its `@api.depends('worked_hours', 'x_fclk_break_minutes')` decorator):
|
||||
|
||||
```python
|
||||
@api.depends('worked_hours', 'check_out',
|
||||
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
|
||||
def _compute_fclk_break_minutes(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
|
||||
for att in self:
|
||||
statutory = 0.0
|
||||
if auto and att.check_out and att.employee_id:
|
||||
rule = att.employee_id._get_fclk_break_rule()
|
||||
if rule:
|
||||
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
|
||||
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
|
||||
att.x_fclk_break_minutes = statutory + penalties
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove the cron's break write** — in the same file, inside `_cron_fusion_auto_clock_out`:
|
||||
|
||||
Remove the now-unused threshold read (the line near the top of the method):
|
||||
```python
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
```
|
||||
Remove the two now-unused locals in the per-attendance loop:
|
||||
```python
|
||||
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
||||
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
||||
```
|
||||
Remove the break-write block (the compute now applies the break when `check_out` is set):
|
||||
```python
|
||||
if (att.worked_hours or 0) >= threshold:
|
||||
att.sudo().write(
|
||||
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
|
||||
)
|
||||
```
|
||||
(Leave the surrounding `employee = att.employee_id` and `clock_out_time = effective_deadline` lines intact.)
|
||||
|
||||
- [ ] **Step 6: Delete the controller helper and its call sites** — in `fusion_clock/controllers/clock_api.py`:
|
||||
|
||||
Delete the entire `_apply_break_deduction` method:
|
||||
```python
|
||||
def _apply_break_deduction(self, attendance, employee):
|
||||
"""Apply automatic break deduction if configured."""
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
|
||||
return
|
||||
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
worked = attendance.worked_hours or 0.0
|
||||
|
||||
if worked >= threshold:
|
||||
local_date = get_local_today(request.env, employee)
|
||||
if attendance.check_in:
|
||||
tz_name = (
|
||||
employee.resource_id.tz
|
||||
or (employee.user_id.partner_id.tz if employee.user_id else False)
|
||||
or employee.company_id.partner_id.tz
|
||||
or 'UTC'
|
||||
)
|
||||
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
|
||||
break_min = employee._get_fclk_break_minutes(local_date)
|
||||
current = attendance.x_fclk_break_minutes or 0.0
|
||||
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
||||
new_val = max(break_min, current)
|
||||
if new_val != current:
|
||||
attendance.sudo().write({'x_fclk_break_minutes': new_val})
|
||||
|
||||
```
|
||||
Delete its clock-out call (in the CLOCK OUT branch):
|
||||
```python
|
||||
# Apply break deduction
|
||||
self._apply_break_deduction(attendance, employee)
|
||||
|
||||
```
|
||||
Delete the penalty break-write in `_check_and_create_penalty` (keep the penalty-record `create` above it and the activity log below it):
|
||||
```python
|
||||
# Deduct penalty minutes from attendance (adds to break deduction)
|
||||
current_break = attendance.x_fclk_break_minutes or 0.0
|
||||
attendance.sudo().write({
|
||||
'x_fclk_break_minutes': current_break + deduction,
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Delete the kiosk call sites**
|
||||
|
||||
In `fusion_clock/controllers/clock_kiosk.py`, delete the line:
|
||||
```python
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
```
|
||||
In `fusion_clock/controllers/clock_nfc_kiosk.py`, delete the line:
|
||||
```python
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Pyflakes the touched controllers/models** (catches a missed `pytz`/var reference instantly)
|
||||
|
||||
```bash
|
||||
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/controllers/clock_api.py /mnt/extra-addons/fusion_clock/controllers/clock_kiosk.py /mnt/extra-addons/fusion_clock/controllers/clock_nfc_kiosk.py /mnt/extra-addons/fusion_clock/models/hr_attendance.py
|
||||
```
|
||||
Expected: no output (clean). If it flags `pytz` as unused in `hr_attendance.py`, that's fine only if no other code uses it — verify before removing the import (the absence/overtime crons still use `pytz`, so leave the import).
|
||||
|
||||
- [ ] **Step 9: Run to verify all Task 3 tests pass**
|
||||
|
||||
Sync, then run the module tests. Expected: all `test_manual_*`, `test_under_first_threshold_no_break`, `test_penalty_minutes_are_additive`, `test_master_toggle_off_zero_statutory`, `test_open_attendance_zero_break` PASS, and the existing NFC/kiosk/dashboard tests still PASS.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_attendance.py fusion_clock/controllers/clock_api.py fusion_clock/controllers/clock_kiosk.py fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_break_rules.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): auto-apply statutory break via one stored compute" -m "x_fclk_break_minutes is now statutory(worked_hours) + penalties, recomputed on every path including manual backend entry. Removes the four duplicated write sites (controller _apply_break_deduction + 3 call sites, auto-clock-out cron, penalty write)." -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Retire `break_threshold_hours`; clean settings & migrate
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/res_config_settings.py`
|
||||
- Modify: `fusion_clock/views/res_config_settings_views.xml`
|
||||
- Modify: `fusion_clock/data/ir_config_parameter_data.xml`
|
||||
- Create: `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`
|
||||
- Modify: `fusion_clock/tests/test_settings.py`
|
||||
|
||||
- [ ] **Step 1: Add the dead-setting assertion** — in `fusion_clock/tests/test_settings.py`, add one line to `test_dead_settings_removed`:
|
||||
|
||||
```python
|
||||
self.assertNotIn('fclk_break_threshold_hours', fields)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the settings field** — in `fusion_clock/models/res_config_settings.py`, delete:
|
||||
|
||||
```python
|
||||
fclk_break_threshold_hours = fields.Float(
|
||||
string='Break Threshold (hours)',
|
||||
config_parameter='fusion_clock.break_threshold_hours',
|
||||
default=4.0,
|
||||
help="Only deduct break if shift is longer than this many hours.",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Fix the settings view** — in `fusion_clock/views/res_config_settings_views.xml`, replace the whole `fclk_auto_break` setting block:
|
||||
|
||||
OLD:
|
||||
```xml
|
||||
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
||||
help="Automatically deduct unpaid break from worked hours on clock-out.">
|
||||
<field name="fclk_auto_deduct_break"/>
|
||||
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_default_break_minutes"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_break_threshold_hours" widget="float_time"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
```
|
||||
NEW:
|
||||
```xml
|
||||
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
||||
help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
|
||||
<field name="fclk_auto_deduct_break"/>
|
||||
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_default_break_minutes"/>
|
||||
</div>
|
||||
<div class="text-muted small mt4">
|
||||
Used as the default break when building shifts/schedules
|
||||
(planned hours). Actual deductions follow the province Break Rules.
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove the seed param** — in `fusion_clock/data/ir_config_parameter_data.xml`, delete:
|
||||
|
||||
```xml
|
||||
<record id="config_break_threshold_hours" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.break_threshold_hours</field>
|
||||
<field name="value">4.0</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Bump the version + create the migration**
|
||||
|
||||
First bump the manifest so the migration fires (installed `19.0.4.0.3` < manifest
|
||||
`19.0.4.1.0`). In `fusion_clock/__manifest__.py`:
|
||||
```python
|
||||
'version': '19.0.4.1.0',
|
||||
```
|
||||
Then create `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Retire the single-threshold break param (superseded by per-rule
|
||||
break1_after_hours), and force-recompute the now-computed break field so
|
||||
existing closed attendances reflect the province rule + their penalties."""
|
||||
cr.execute(
|
||||
"DELETE FROM ir_config_parameter WHERE key = %s",
|
||||
('fusion_clock.break_threshold_hours',),
|
||||
)
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
Attendance = env['hr.attendance']
|
||||
field = Attendance._fields['x_fclk_break_minutes']
|
||||
closed = Attendance.search([('check_out', '!=', False)])
|
||||
if closed:
|
||||
env.add_to_compute(field, closed)
|
||||
closed.flush_recordset(['x_fclk_break_minutes'])
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Sync, upgrade, run tests**
|
||||
|
||||
Sync, then run the module tests. Expected: module upgrades cleanly and the `19.0.4.1.0` migration executes (installed `19.0.4.0.3` < manifest `19.0.4.1.0`; modsdev shows the INFO line, nexa/entech run `log_level=warn`), `test_dead_settings_removed` PASS, full `fusion_clock` suite green.
|
||||
|
||||
- [ ] **Step 7: Verify the param is gone and historical rows recomputed** (sanity)
|
||||
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo shell -d modsdev --no-http 2>/dev/null <<'PY'
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
print('threshold param:', ICP.get_param('fusion_clock.break_threshold_hours', 'ABSENT'))
|
||||
print('default rule:', env['fusion.clock.break.rule'].search([('is_default','=',True)]).mapped('name'))
|
||||
PY
|
||||
```
|
||||
Expected: `threshold param: ABSENT`; `default rule: ['Ontario']`.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/migrations/19.0.4.1.0/post-migrate.py fusion_clock/tests/test_settings.py fusion_clock/__manifest__.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "refactor(fusion_clock): retire break_threshold_hours; breaks now driven by Break Rules" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Full verification, docs, manual smoke
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Full test run (whole module)**
|
||||
|
||||
Sync, then:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -120
|
||||
```
|
||||
Expected: all `fusion_clock` tests PASS, zero tracebacks. If anything fails, fix before continuing.
|
||||
|
||||
- [ ] **Step 2: Manual smoke (manager UI)** at http://localhost:8082
|
||||
|
||||
- Configuration → **Break Rules** exists; the **Ontario** row shows 5h→30 / 10h→30, Default ticked.
|
||||
- Attendances → create a manual attendance, check-in 09:00 check-out 15:00 (6h) → **Break = 30**, Net = 5.5h, with no clock action.
|
||||
- Edit that record's check-out to 19:00 (10h) → **Break = 60**, Net = 9.0h.
|
||||
- Create a 4h attendance → **Break = 0**.
|
||||
- Settings → the old "Min. Shift" threshold field is gone; the Auto-Deduct Break help points to Break Rules.
|
||||
|
||||
- [ ] **Step 3: Update the module CLAUDE.md** — in `fusion_clock/CLAUDE.md`:
|
||||
|
||||
- §4 Model Map: add a row — `fusion.clock.break.rule | models/clock_break_rule.py | Per-province statutory unpaid-break thresholds (2-tier).`
|
||||
- §5 Clocking Flow: note that the break deduction is no longer a controller step — `x_fclk_break_minutes` is a stored compute (`statutory(worked_hours) + Σ penalties`) that fires on every path including manual backend entry; resolved rule via `hr.employee._get_fclk_break_rule()` (company province → default).
|
||||
- §11 Settings Keys: remove `fusion_clock.break_threshold_hours`.
|
||||
- §13 Gotchas: add — "Unpaid break is computed, not written: never `write({'x_fclk_break_minutes': ...})`; change the province rule (`fusion.clock.break.rule`) or `auto_deduct_break` instead. Penalty minutes are now strictly additive (the old `max()` that swallowed late-in penalties is gone)."
|
||||
- Bump the version line in §1 to `19.0.4.1.0`.
|
||||
|
||||
- [ ] **Step 4: Commit the docs**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/CLAUDE.md
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "docs(fusion_clock): document province break rules + computed break field" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Report** — summarize what changed, the behaviour-change note (penalties now additive), and that live deployment to entech (`odoo-entech`) is a separate step pending user sign-off.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (performed against the spec)
|
||||
|
||||
**1. Spec coverage**
|
||||
- §4.1 model → Task 1. §4.2 resolver → Task 2. §4.3 stored compute → Task 3. §4.4 removals → Task 3 (writes) + Task 4 (setting/param/view). §4.5 UI/security/data → Task 1 (+ settings view in Task 4). §5 edge cases → tests in Tasks 1 & 3. §6 migration → Task 4. §7 tests → all six+ cases present across Tasks 1–3. §8 rollout → preamble + Task 5. ✓ No gaps.
|
||||
|
||||
**2. Placeholder scan** — every step has full code/commands; no TBD/TODO/"similar to". ✓
|
||||
|
||||
**3. Type/name consistency** — `break_minutes_for`, `_get_fclk_break_rule`, `_compute_fclk_break_minutes`, fields `break1_after_hours/break1_minutes/break2_after_hours/break2_minutes/is_default`, model `fusion.clock.break.rule`, access id `model_fusion_clock_break_rule`, action `action_fusion_clock_break_rule`, menu `menu_fusion_clock_break_rules` — all used identically across tasks. The compute folds `Σ penalty_minutes` (field `penalty_minutes` on `fusion.clock.penalty`, confirmed). ✓
|
||||
@@ -0,0 +1,43 @@
|
||||
# Accessibility Funding-Source Selector — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline) — this is a 3-file change. Steps use `- [ ]` checkboxes.
|
||||
|
||||
**Goal:** Let the rep mark an accessibility assessment's funding source (Private / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other) on the web form, so the generated sale order routes to the correct funding pipeline instead of always defaulting to private pay.
|
||||
|
||||
**Architecture:** The model (`fusion.accessibility.assessment.x_fc_funding_source`) and the SO routing (`_create_draft_sale_order` → `sale_type_map` → `x_fc_sale_type`) already exist (the "2026-04 portal audit fix"). The only gaps: (1) the form has no funding field, (2) the save controller never reads `funding_source` from the POST, (3) `hardship` is missing from the selectable funding sources. The submit JS already serialises every named form field via `FormData`, so no JS change is needed.
|
||||
|
||||
**Tech Stack:** Odoo 19, QWeb portal template, JSON-RPC controller. Module `fusion_portal` (worktree `K:\Github\Odoo-Modules-wt-portal`, branch `feat/assessment-visit`).
|
||||
|
||||
**Verification constraint:** `fusion_portal` depends on Enterprise `knowledge`, so it can NOT be installed on the local Community Docker. Syntax-check with host Python; functional verification is on westin (or a clone): pick "March of Dimes" on a form → the draft SO gets `x_fc_sale_type='march_of_dimes'` and lands in the MOD pipeline.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Hardship to the funding source + route it
|
||||
|
||||
**Files:** Modify `fusion_portal/models/accessibility_assessment.py` (selection ~:71-87, `sale_type_map` ~:771-779)
|
||||
|
||||
- [ ] **Step 1:** Add `('hardship', 'Hardship Funding')` to the `x_fc_funding_source` selection list (after `'wsib'`).
|
||||
- [ ] **Step 2:** Add `'hardship': 'hardship',` to `sale_type_map` in `_create_draft_sale_order` (the target `x_fc_sale_type='hardship'` already exists in `fusion_claims` `sale_order.py:332`).
|
||||
- [ ] **Step 3:** `python -m py_compile fusion_portal/models/accessibility_assessment.py` → no error.
|
||||
- [ ] **Step 4:** Commit.
|
||||
|
||||
### Task 2: Add the funding select to the shared client-info form
|
||||
|
||||
**Files:** Modify `fusion_portal/views/portal_accessibility_templates.xml` (`accessibility_client_info_section`, ~:366-375)
|
||||
|
||||
- [ ] **Step 1:** Add a new row with a `<select name="funding_source">` (options mirror the model selection; `direct_private` pre-selected so existing private behaviour is unchanged) right after the phone/email row, before the card closes.
|
||||
- [ ] **Step 2:** Validate XML well-formedness (`[xml]` parse).
|
||||
- [ ] **Step 3:** Commit.
|
||||
|
||||
### Task 3: Capture funding_source in the save controller
|
||||
|
||||
**Files:** Modify `fusion_portal/controllers/portal_main.py` (`accessibility_assessment_save` vals, ~:2498-2511)
|
||||
|
||||
- [ ] **Step 1:** Add `'x_fc_funding_source': post.get('funding_source') or 'direct_private',` to the `vals` dict.
|
||||
- [ ] **Step 2:** `python -m pyflakes fusion_portal/controllers/portal_main.py` → no new undefined-name errors.
|
||||
- [ ] **Step 3:** Commit.
|
||||
|
||||
### Task 4: Verify + ship
|
||||
|
||||
- [ ] **Step 1:** Grep confirms `funding_source` flows form → controller → `x_fc_funding_source` → `sale_type_map`.
|
||||
- [ ] **Step 2:** Deploy to westin (backup → scp the 3 files → `-u fusion_portal` → cache-bust → restart) and confirm: open `/my/accessibility/stairlift/straight`, pick "March of Dimes", complete → the new SO shows `x_fc_sale_type = march_of_dimes` and appears in the MOD pipeline.
|
||||
@@ -0,0 +1,506 @@
|
||||
# fusion_maintenance Foundation — Implementation Plan (Plan 1 of 5)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Confirming a sale of a maintainable product auto-creates a *priced* maintenance contract, and the due-reminder email shows the maintenance cost.
|
||||
|
||||
**Architecture:** Extend `fusion_repairs`. A maintenance **policy** (enabled / interval / flat fee) lives on `fusion.repair.product.category`, with a per-product fee/interval override on `product.template`. We fix the dead `_spawn_maintenance_contracts()` (anchor on delivery date, capture serial + fee + provenance, dedup) and call it from the **existing** `action_confirm()` override. The branded reminder email gains a fee line.
|
||||
|
||||
**Tech Stack:** Odoo 19 **Community**, Python, `TransactionCase`. Local dev: `docker odoo-modsdev-app`, DB `fusion-dev`.
|
||||
|
||||
**Spec:** [`2026-06-02-fusion-maintenance-design.md`](../specs/2026-06-02-fusion-maintenance-design.md). This is **Plan 1 of 5**; see the Roadmap at the bottom for Plans 2–5 (booking, visit log, backfill, office crons) — each is written when reached because it needs its own live-source reads (spec §15).
|
||||
|
||||
**Conventions (from CLAUDE.md):** new fields `x_fc_` prefix; Canadian English; Monetary = `$` + `currency_id`; declarative `models.Constraint` / `models.Index` (no `_sql_constraints`); `message_post` HTML wrapped in `Markup()`; `res.users` group field is `group_ids`.
|
||||
|
||||
**Run tests:**
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs \
|
||||
-u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
|
||||
**Grounding (verified source, 2026-06-02):**
|
||||
- [`maintenance_contract.py`](../../../fusion_repairs/models/maintenance_contract.py) — contract model (fields end at `company_id`, line 81; `_booking_token_unique` constraint line 83); dead `_spawn_maintenance_contracts()` (line 198, anchors on `today`, dedups by partner/product/SO, no fee/serial/source).
|
||||
- [`repair_product_category.py`](../../../fusion_repairs/models/repair_product_category.py) — category model; `safety_critical`, `equipment_class`; `_code_unique` constraint line 56.
|
||||
- [`product_template.py`](../../../fusion_repairs/models/product_template.py) — `x_fc_repair_category_id` (line 11), `x_fc_maintenance_interval_months` (line 23, default 0).
|
||||
- [`repair_service_plan.py`](../../../fusion_repairs/models/repair_service_plan.py) — **existing** `action_confirm()` override (line 229) ending `return res` (line 250); wire the maintenance spawn here.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `fusion_repairs/models/repair_product_category.py` — add maintenance-policy fields + `currency_id`.
|
||||
- **Modify** `fusion_repairs/models/product_template.py` — add `x_fc_maintenance_fee` override.
|
||||
- **Modify** `fusion_repairs/models/maintenance_contract.py` — add contract fields + indexes; add `_fc_maintenance_anchor_date`; rewrite `_spawn_maintenance_contracts`.
|
||||
- **Modify** `fusion_repairs/models/repair_service_plan.py` — call `self._spawn_maintenance_contracts()` inside `action_confirm`.
|
||||
- **Modify** `fusion_repairs/data/mail_template_data.xml` — add a fee row to the reminder template.
|
||||
- **Modify** `fusion_repairs/views/repair_product_category_views.xml` — expose the policy fields.
|
||||
- **Create** `fusion_repairs/tests/__init__.py`, `fusion_repairs/tests/test_maintenance_foundation.py`.
|
||||
- **Modify** `fusion_repairs/__manifest__.py` — bump `version` to `19.0.2.3.0`.
|
||||
|
||||
> **Scope note:** the technician-skill field (`x_fc_maintenance_skill_id`) is deferred to **Plan 2 (booking)** because skill matching is a booking concern and the exact skills representation is an open item (spec §15). Plan 1 is enrollment + pricing only.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Maintenance policy fields on the equipment category
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/models/repair_product_category.py` (insert after `intake_template_id`, before `_code_unique` at line 56)
|
||||
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||
|
||||
- [ ] **Step 1: Create the tests package + write the failing test**
|
||||
|
||||
Create `fusion_repairs/tests/__init__.py`:
|
||||
```python
|
||||
from . import test_maintenance_foundation
|
||||
```
|
||||
|
||||
Create `fusion_repairs/tests/test_maintenance_foundation.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestMaintenanceFoundation(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.partner = cls.env['res.partner'].create({'name': 'Mrs. Test Client'})
|
||||
cls.category = cls.env['fusion.repair.product.category'].create({
|
||||
'name': 'Stair Lift', 'code': 'stairlift',
|
||||
'equipment_class': 'lift_elevating', 'safety_critical': True,
|
||||
'x_fc_maintenance_enabled': True,
|
||||
'x_fc_maintenance_interval_months': 6,
|
||||
'x_fc_maintenance_fee': 149.0,
|
||||
})
|
||||
|
||||
def test_category_policy_fields_exist(self):
|
||||
self.assertTrue(self.category.x_fc_maintenance_enabled)
|
||||
self.assertEqual(self.category.x_fc_maintenance_interval_months, 6)
|
||||
self.assertEqual(self.category.x_fc_maintenance_fee, 149.0)
|
||||
self.assertTrue(self.category.currency_id)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
|
||||
```
|
||||
Expected: FAIL — `Invalid field 'x_fc_maintenance_enabled' on model 'fusion.repair.product.category'`.
|
||||
|
||||
- [ ] **Step 3: Add the policy fields**
|
||||
|
||||
In `repair_product_category.py`, insert before the `_code_unique = models.Constraint(...)` line:
|
||||
```python
|
||||
# ── Maintenance policy (per equipment type) ──────────────────────────
|
||||
x_fc_maintenance_enabled = fields.Boolean(
|
||||
string='Offer Maintenance',
|
||||
help='If set, units in this category are enrolled in recurring preventive '
|
||||
'maintenance on sale (and via the backfill wizard).',
|
||||
)
|
||||
x_fc_maintenance_interval_months = fields.Integer(
|
||||
string='Maintenance Interval (Months)', default=6,
|
||||
help='Default months between preventive maintenance visits for this category. '
|
||||
'Overridden by the product field of the same name when that is > 0.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee', currency_field='currency_id',
|
||||
help='Flat fee shown to the client for a maintenance visit of this equipment type.',
|
||||
)
|
||||
x_fc_maintenance_service_product_id = fields.Many2one(
|
||||
'product.product', string='Maintenance Service Product',
|
||||
help='Optional product used when drafting the priced visit line (Plan 2). '
|
||||
'Falls back to a generic visit product.',
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run the same command as Step 2. Expected: `test_category_policy_fields_exist` PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/models/repair_product_category.py fusion_repairs/tests/
|
||||
git commit -m "feat(fusion_repairs): maintenance policy fields on equipment category"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Per-product fee override
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/models/product_template.py` (after `x_fc_maintenance_interval_months`, line 28)
|
||||
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to the test class)
|
||||
```python
|
||||
def test_product_fee_override_field_exists(self):
|
||||
tmpl = self.env['product.template'].create({
|
||||
'name': 'Handicare Freecurve Stairlift',
|
||||
'x_fc_repair_category_id': self.category.id,
|
||||
'x_fc_maintenance_fee': 199.0,
|
||||
})
|
||||
self.assertEqual(tmpl.x_fc_maintenance_fee, 199.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run the test command. Expected: FAIL — `Invalid field 'x_fc_maintenance_fee' on model 'product.template'`.
|
||||
|
||||
- [ ] **Step 3: Add the field**
|
||||
|
||||
In `product_template.py`, after the `x_fc_maintenance_interval_months` field (line 28):
|
||||
```python
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee (override)', currency_field='currency_id',
|
||||
help='Per-product override of the category maintenance fee. 0 = use the category fee.',
|
||||
)
|
||||
```
|
||||
(`product.template` already provides `currency_id`.)
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes** — `test_product_fee_override_field_exists` PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/models/product_template.py fusion_repairs/tests/test_maintenance_foundation.py
|
||||
git commit -m "feat(fusion_repairs): per-product maintenance fee override"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Contract model extensions (fee, source, serial, policy)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/models/maintenance_contract.py` (add fields after `company_id`, line 81; add indexes near `_booking_token_unique`, line 83)
|
||||
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
```python
|
||||
def test_contract_extension_fields_exist(self):
|
||||
c = self.env['fusion.repair.maintenance.contract'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'product_id': self.env['product.product'].create({'name': 'Unit'}).id,
|
||||
'next_due_date': '2026-12-01',
|
||||
'x_fc_source': 'sale',
|
||||
'x_fc_device_serial': 'SN-123',
|
||||
'x_fc_maintenance_fee': 149.0,
|
||||
})
|
||||
self.assertEqual(c.x_fc_source, 'sale')
|
||||
self.assertEqual(c.x_fc_device_serial, 'SN-123')
|
||||
self.assertEqual(c.x_fc_maintenance_fee, 149.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails** — `Invalid field 'x_fc_source' ...`.
|
||||
|
||||
- [ ] **Step 3: Add the fields + indexes**
|
||||
|
||||
In `maintenance_contract.py`, after the `company_id` field (line 81), before `_booking_token_unique`:
|
||||
```python
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee', currency_field='currency_id',
|
||||
help='Flat fee shown to the client for this maintenance visit.',
|
||||
)
|
||||
x_fc_source = fields.Selection(
|
||||
[('sale', 'New Sale'), ('backfill', 'Backfill'),
|
||||
('claims', 'Claims Bridge'), ('manual', 'Manual')],
|
||||
string='Source', default='manual', index=True,
|
||||
)
|
||||
x_fc_source_sale_line_id = fields.Many2one(
|
||||
'sale.order.line', string='Source Sale Line', index=True, copy=False,
|
||||
)
|
||||
x_fc_device_serial = fields.Char(string='Serial (text)', index=True, copy=False)
|
||||
x_fc_policy_category_id = fields.Many2one(
|
||||
'fusion.repair.product.category', string='Maintenance Policy',
|
||||
)
|
||||
```
|
||||
(Idempotency is enforced in Python — Task 4 — to support the two-regime dedup in spec §6.2; the `index=True` above covers lookups.)
|
||||
|
||||
- [ ] **Step 4: Run to verify it passes** — `test_contract_extension_fields_exist` PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/models/maintenance_contract.py fusion_repairs/tests/test_maintenance_foundation.py
|
||||
git commit -m "feat(fusion_repairs): maintenance contract fee/source/serial/policy fields"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Spawn priced contracts on sale confirm (fix the dead trigger + wire it)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/models/maintenance_contract.py` (rewrite `_spawn_maintenance_contracts`, lines 198-227; add `_fc_maintenance_anchor_date` helper)
|
||||
- Modify: `fusion_repairs/models/repair_service_plan.py` (call it in `action_confirm`, before `return res` at line 250)
|
||||
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
```python
|
||||
def _make_product(self, **kw):
|
||||
vals = {'name': 'Stairlift Unit', 'type': 'consu',
|
||||
'x_fc_repair_category_id': self.category.id}
|
||||
vals.update(kw)
|
||||
return self.env['product.product'].create(vals)
|
||||
|
||||
def _confirm_so(self, product, commitment='2026-01-10'):
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'commitment_date': commitment,
|
||||
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})],
|
||||
})
|
||||
so.action_confirm()
|
||||
return so
|
||||
|
||||
def _contracts_for(self, so):
|
||||
return self.env['fusion.repair.maintenance.contract'].search(
|
||||
[('original_sale_order_id', '=', so.id)])
|
||||
|
||||
def test_no_contract_when_category_not_maintainable(self):
|
||||
cat = self.env['fusion.repair.product.category'].create(
|
||||
{'name': 'Cane', 'code': 'cane', 'x_fc_maintenance_enabled': False})
|
||||
so = self._confirm_so(self._make_product(x_fc_repair_category_id=cat.id))
|
||||
self.assertFalse(self._contracts_for(so))
|
||||
|
||||
def test_contract_created_via_category_policy(self):
|
||||
so = self._confirm_so(self._make_product())
|
||||
contracts = self._contracts_for(so)
|
||||
self.assertEqual(len(contracts), 1)
|
||||
c = contracts
|
||||
self.assertEqual(c.interval_months, 6)
|
||||
self.assertEqual(c.x_fc_maintenance_fee, 149.0)
|
||||
self.assertEqual(c.x_fc_source, 'sale')
|
||||
self.assertEqual(c.x_fc_policy_category_id, self.category)
|
||||
# anchor = commitment_date + 6 months
|
||||
self.assertEqual(str(c.next_due_date), '2026-07-10')
|
||||
|
||||
def test_product_override_beats_category(self):
|
||||
p = self._make_product()
|
||||
p.product_tmpl_id.x_fc_maintenance_interval_months = 3
|
||||
p.product_tmpl_id.x_fc_maintenance_fee = 199.0
|
||||
so = self._confirm_so(p)
|
||||
c = self._contracts_for(so)
|
||||
self.assertEqual(c.interval_months, 3)
|
||||
self.assertEqual(c.x_fc_maintenance_fee, 199.0)
|
||||
|
||||
def test_idempotent_on_reconfirm(self):
|
||||
p = self._make_product()
|
||||
so = self._confirm_so(p)
|
||||
so._spawn_maintenance_contracts() # call again
|
||||
self.assertEqual(len(self._contracts_for(so)), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail** — contracts not created (trigger not wired) → assertions fail.
|
||||
|
||||
- [ ] **Step 3: Rewrite `_spawn_maintenance_contracts` + add the anchor helper**
|
||||
|
||||
Replace the body of `_spawn_maintenance_contracts` (lines 198-227) and add the helper, in the `SaleOrder` class of `maintenance_contract.py`:
|
||||
```python
|
||||
def _fc_maintenance_anchor_date(self, line):
|
||||
"""Best-available delivery anchor: commitment_date -> date_order -> today.
|
||||
(Non-ADP/lift units lack a delivery date; this fallback chain handles them.)"""
|
||||
so = line.order_id
|
||||
anchor = so.commitment_date or so.date_order
|
||||
return fields.Date.to_date(anchor) if anchor else fields.Date.context_today(self)
|
||||
|
||||
def _spawn_maintenance_contracts(self):
|
||||
"""Create a priced maintenance contract per maintainable unit on a confirmed SO.
|
||||
Policy = product interval override, else the product's category policy.
|
||||
Idempotent: by serial when captured, else by source sale line."""
|
||||
Contract = self.env['fusion.repair.maintenance.contract'].sudo()
|
||||
for so in self:
|
||||
if so.state not in ('sale', 'done'):
|
||||
continue
|
||||
for line in so.order_line:
|
||||
product = line.product_id
|
||||
if not product:
|
||||
continue
|
||||
tmpl = product.product_tmpl_id
|
||||
category = tmpl.x_fc_repair_category_id
|
||||
product_interval = tmpl.x_fc_maintenance_interval_months or 0
|
||||
cat_enabled = bool(category) and category.x_fc_maintenance_enabled
|
||||
interval = product_interval or (
|
||||
category.x_fc_maintenance_interval_months if cat_enabled else 0)
|
||||
if interval <= 0 or not (product_interval > 0 or cat_enabled):
|
||||
continue
|
||||
fee = tmpl.x_fc_maintenance_fee or (
|
||||
category.x_fc_maintenance_fee if category else 0.0)
|
||||
# Capture serial only if fusion_claims' line field is present.
|
||||
serial = ''
|
||||
if 'x_fc_serial_number' in line._fields:
|
||||
serial = (line.x_fc_serial_number or '').strip()
|
||||
# Idempotency: serial regime vs source-line regime (spec §6.2).
|
||||
if serial:
|
||||
dedup = [('state', '=', 'active'), ('x_fc_device_serial', '=', serial)]
|
||||
else:
|
||||
dedup = [('state', '=', 'active'),
|
||||
('x_fc_source_sale_line_id', '=', line.id)]
|
||||
if Contract.search_count(dedup):
|
||||
continue
|
||||
anchor = so._fc_maintenance_anchor_date(line)
|
||||
# One contract per serialized unit; without a serial, per quantity.
|
||||
count = 1 if serial else max(int(line.product_uom_qty or 1), 1)
|
||||
for _i in range(count):
|
||||
Contract.create({
|
||||
'partner_id': so.partner_id.id,
|
||||
'product_id': product.id,
|
||||
'original_sale_order_id': so.id,
|
||||
'x_fc_source_sale_line_id': line.id,
|
||||
'x_fc_source': 'sale',
|
||||
'x_fc_device_serial': serial,
|
||||
'x_fc_policy_category_id': category.id if category else False,
|
||||
'interval_months': interval,
|
||||
'x_fc_maintenance_fee': fee,
|
||||
'next_due_date': anchor + relativedelta(months=interval),
|
||||
'state': 'active',
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire it into the existing `action_confirm`**
|
||||
|
||||
In `repair_service_plan.py`, in `action_confirm`, change line 249-250 from:
|
||||
```python
|
||||
self._fc_spawn_labor_warranties()
|
||||
return res
|
||||
```
|
||||
to:
|
||||
```python
|
||||
self._fc_spawn_labor_warranties()
|
||||
self._spawn_maintenance_contracts()
|
||||
return res
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to verify the Task-4 tests pass** — all four PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/models/maintenance_contract.py fusion_repairs/models/repair_service_plan.py fusion_repairs/tests/test_maintenance_foundation.py
|
||||
git commit -m "feat(fusion_repairs): spawn priced maintenance contracts on sale confirm"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Show the fee in the reminder email
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/data/mail_template_data.xml` (the `email_template_maintenance_due_reminder` record)
|
||||
|
||||
- [ ] **Step 1: Read the current template**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app sh -c "grep -n 'email_template_maintenance_due_reminder' /mnt/odoo-modules/fusion_repairs/data/mail_template_data.xml"
|
||||
```
|
||||
Then open that record's `<field name="body_html">` and find the equipment-name / due-date details table (the green-accent reminder).
|
||||
|
||||
- [ ] **Step 2: Add a fee row to the details table**
|
||||
|
||||
Inside the details table of the reminder body, after the "Next due" row, add (Canadian English, `$` + currency):
|
||||
```xml
|
||||
<tr t-if="object.x_fc_maintenance_fee">
|
||||
<td style="opacity:0.6;width:35%;">Maintenance fee</td>
|
||||
<td><span t-field="object.x_fc_maintenance_fee"
|
||||
t-options='{"widget": "monetary", "display_currency": object.currency_id}'/>
|
||||
<span style="opacity:0.6;"> + applicable tax</span></td>
|
||||
</tr>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Upgrade + manually verify the rendered email**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init
|
||||
```
|
||||
Then in odoo-shell render the template for a contract with a fee and confirm the fee line appears:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo shell -d fusion-dev --no-http <<'PY'
|
||||
c = env['fusion.repair.maintenance.contract'].search([('x_fc_maintenance_fee','>',0)], limit=1)
|
||||
tpl = env.ref('fusion_repairs.email_template_maintenance_due_reminder')
|
||||
print('FEE' if 'applicable tax' in tpl._render_field('body_html', c.ids)[c.id] else 'MISSING')
|
||||
PY
|
||||
```
|
||||
Expected: `FEE`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/data/mail_template_data.xml
|
||||
git commit -m "feat(fusion_repairs): show maintenance fee in due-reminder email"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Expose policy fields in the category form + bump version
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_repairs/views/repair_product_category_views.xml`
|
||||
- Modify: `fusion_repairs/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Read the category form view**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app sh -c "grep -n 'fusion.repair.product.category' /mnt/odoo-modules/fusion_repairs/views/repair_product_category_views.xml | head"
|
||||
```
|
||||
Locate the `<form>` for the category.
|
||||
|
||||
- [ ] **Step 2: Add a Maintenance group to the form**
|
||||
|
||||
Inside the category form sheet, add:
|
||||
```xml
|
||||
<group string="Maintenance Policy">
|
||||
<field name="x_fc_maintenance_enabled"/>
|
||||
<field name="x_fc_maintenance_interval_months"
|
||||
invisible="not x_fc_maintenance_enabled"/>
|
||||
<field name="x_fc_maintenance_fee"
|
||||
invisible="not x_fc_maintenance_enabled"/>
|
||||
<field name="x_fc_maintenance_service_product_id"
|
||||
invisible="not x_fc_maintenance_enabled"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Bump the version**
|
||||
|
||||
In `fusion_repairs/__manifest__.py`, change `'version': '19.0.2.2.6',` to `'version': '19.0.2.3.0',`.
|
||||
|
||||
- [ ] **Step 4: Upgrade + run the full test module green**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
|
||||
```
|
||||
Expected: all `TestMaintenanceFoundation` tests PASS, 0 failures, module loads.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add fusion_repairs/views/repair_product_category_views.xml fusion_repairs/__manifest__.py
|
||||
git commit -m "feat(fusion_repairs): category maintenance-policy UI + version 19.0.2.3.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (against the spec)
|
||||
|
||||
- **Spec §2 D2 (flat fee per type):** Tasks 1-2 (policy on category + product override), Task 4 (fee snapshot on contract), Task 5 (fee in email). ✓
|
||||
- **Spec §3.2 gap #1 (dead trigger):** Task 4 fixes + wires `_spawn_maintenance_contracts`. ✓
|
||||
- **Spec §3.2 gap #3 (no cost shown):** Task 5. ✓
|
||||
- **Spec §5.1 / §5.2 (policy + contract fields):** Tasks 1-3. ✓
|
||||
- **Spec §6.1 (new-sale path, delivery anchor, idempotent, serial when present):** Task 4 (`_fc_maintenance_anchor_date`, two-regime dedup, guarded serial capture). ✓
|
||||
- **Deferred to Plan 2:** `x_fc_maintenance_skill_id` (skills representation is §15 open item) — noted in File Structure.
|
||||
- **No placeholders:** every code step shows complete code; the two "read first" steps (Tasks 5-6) target XML whose exact surrounding markup must be read live before editing, and give the exact snippet to insert.
|
||||
- **Type consistency:** `x_fc_maintenance_fee` Monetary + `currency_id` used identically on category, product, contract; `_spawn_maintenance_contracts` / `_fc_maintenance_anchor_date` names consistent between maintenance_contract.py and the call site in repair_service_plan.py.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap — Plans 2–5 (write each when reached; each needs its own live-source reads per spec §15)
|
||||
|
||||
- **Plan 2 — Technician-aware booking** (the largest build): read `fusion_tasks/models/technician_task.py` `_find_next_available_slot` (line 544) / `_get_available_gaps` (line 664) signatures + working-hours source; add `x_fc_maintenance_skill_id` to the category and confirm the `res.users.x_fc_repair_skills` representation; replace the `<input type="date">` booking page with a real slot-picker controller; on confirm create a `fusion.technician.task` (`task_type='maintenance'`) + the maintenance `repair.order`; double-book guard; office "Book maintenance" action; per-cycle `booking_token` regen in `roll_next_due_date`. Delivers: real self-serve booking.
|
||||
- **Plan 3 — Maintenance visit log + checklist**: read the visit-report wizard + the inspection-certificate (M1) API; add `fusion.repair.maintenance.visit` + `fusion.repair.maintenance.checklist.line`; seed checklists per category; issue an inspection certificate for `safety_critical` categories. Delivers: queryable per-unit history + compliance proof.
|
||||
- **Plan 4 — Backfill wizard** (two-regime, spec §6.2): `fusion.repair.maintenance.backfill.wizard`; serial dedup for ADP wheelchairs (guarded `fusion_claims` read), partner+base-product+sale-line dedup for lifts with accessory-line exclusion; stagger; dry-run report → execute. Delivers: the existing install base enrolled.
|
||||
- **Plan 5 — Office follow-up crons**: `unbooked` + `overdue` crons gated on the existing `ir.config_parameter` toggles; per-row savepoint isolation. Delivers: staff nudges when clients don't self-serve.
|
||||
@@ -0,0 +1,298 @@
|
||||
# NexaCloud→Odoo Cutover — Plan 01: Odoo subscription-cancel endpoint
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add the one inbound endpoint NexaCloud's deprovision path needs — cancel (close) a subscription — to `fusion_centralize_billing`, with the same auth model the other endpoints already use.
|
||||
|
||||
**Architecture:** New `fusion.billing.service._api_cancel_subscription(external_ref)` resolves the subscription via the existing `_fc_resolve_subscription`, enforces the same "partner must be linked to this service" authorization as `_api_record_usage`, and closes it with Odoo 19's native `set_close()` (→ `subscription_state='6_churn'`). A `DELETE /api/billing/v1/subscriptions/<ref>` route wraps it.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise (`sale_subscription`), Python, Odoo `TransactionCase` tests.
|
||||
|
||||
**Spec:** [`2026-06-02-nexacloud-odoo-billing-cutover-design.md`](../specs/2026-06-02-nexacloud-odoo-billing-cutover-design.md) §4.1.3
|
||||
|
||||
---
|
||||
|
||||
## ⚠ Test harness (supersedes any `-d nexamain` command below)
|
||||
|
||||
**NEVER run `-u` / `--test-enable` against the live `nexamain` DB.** Tests run in an **isolated throwaway container** against a dedicated DB, reading a **separate** addons copy so the live module is never touched:
|
||||
|
||||
```
|
||||
# 1) edit files on branch feat/nexacloud-odoo-billing-cutover, then sync the changed
|
||||
# module files to the staging addons copy on odoo-nexa:
|
||||
# /opt/odoo/custom-addons-staging/fusion_centralize_billing/...
|
||||
# 2) run (ssh odoo-nexa):
|
||||
docker run --rm --network odoo_odoo-network \
|
||||
-v /opt/odoo/custom-addons-staging:/mnt/extra-addons:ro \
|
||||
-v /opt/odoo/enterprise-addons:/mnt/enterprise-addons:ro \
|
||||
-v /opt/odoo/odoo.conf:/etc/odoo/odoo.conf:ro \
|
||||
-v /opt/odoo/staging-data:/var/lib/odoo \
|
||||
odoo-nexa:19 -c /etc/odoo/odoo.conf -d fcb_test \
|
||||
--db_host=db --db_user=odoo \
|
||||
--addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons,/mnt/enterprise-addons \
|
||||
--test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel \
|
||||
-u fusion_centralize_billing --stop-after-init --no-http
|
||||
```
|
||||
- `fcb_test` is a **fresh** install DB (not a prod clone). `nexamain_staging` is a prod clone kept for later integration/importer plans.
|
||||
- **Scope each step's run to the relevant test class** (`:TestSubscriptionCancel`, `:TestSubscriptionCancelHttp`). The wider suite is **not hermetic yet** (see Plan 00) — `test_invoice_ledger` needs a configured Canadian CoA/active CAD/HST; `test_usage`/`test_webhook` collide with cloned prod data. Don't gate this plan on those.
|
||||
- The per-step `Run:` blocks below that mention `-d nexamain` are **illustrative only — use this harness instead.**
|
||||
|
||||
> **Prerequisite — Plan 00 (make the suite hermetic):** before green-baseline TDD, fix fixtures so the whole suite passes on `fcb_test`: `setUp` should get-or-create the `nexacloud`/`cpu_seconds` records (idempotent), and a test-setup helper must ensure an active CAD currency + a Canadian CoA + a 13% HST sale tax. Tracked as its own plan; recommended before Plan 01 execution.
|
||||
|
||||
---
|
||||
|
||||
## Increment plan sequence (this is Plan 01 of 6)
|
||||
|
||||
Each is its own plan doc + its own working, testable deliverable. Order reflects dependencies:
|
||||
|
||||
1. **Odoo: subscription-cancel endpoint** ← *this doc* (unblocked; no external decisions).
|
||||
2. **Odoo: NexaCloud charge catalog** — products + `sale.subscription.plan` (`NC-PLAN-*`) + `fusion.billing.charge` (cpu_seconds quota/overage). **Blocked on confirming real NexaCloud plan pricing/quotas** (open review Q#1) before it can be written placeholder-free.
|
||||
3. **Odoo: importer go-forward subscriptions** — extend `wizards/import_wizard.py` to create one shadow `sale.order` per active deployment with go-forward `next_invoice_date`; the safety test that asserts **no past-period invoice** is the centrepiece (guards against the 2026-05-27 Lago re-bill).
|
||||
4. **NexaCloud: adapter activation** — config (`odoo_billing_base_url`/`api_key`/staged enable), customer + subscription create/cancel calls, reconciliation-amount push.
|
||||
5. **NexaCloud: control-loop receiver** — activate `/billing/webhooks/central` HMAC verify → suspend/restore/deprovision via `network_isolation`/`throttle_checker`/`resource_manager`.
|
||||
6. **Dual-run + gated flip** — operational runbook: shadow ≥1 cycle, reconcile to cent, then the reversible flip flag.
|
||||
|
||||
---
|
||||
|
||||
## File structure (this plan)
|
||||
|
||||
- Modify: `fusion_centralize_billing/models/service.py` — add `_api_cancel_subscription`.
|
||||
- Modify: `fusion_centralize_billing/controllers/api.py` — add `DELETE /subscriptions/<ref>`.
|
||||
- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py` — service-method + authorization tests.
|
||||
- Modify: `fusion_centralize_billing/tests/__init__.py` — import the new test module.
|
||||
|
||||
Run tests (from `K:\Github\CLAUDE.md` workflow, adapted to odoo-nexa):
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `_api_cancel_subscription` service method
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/models/service.py` (add method after `_api_create_subscription`, ~line 250)
|
||||
- Create: `fusion_centralize_billing/tests/test_subscription_cancel.py`
|
||||
- Modify: `fusion_centralize_billing/tests/__init__.py`
|
||||
|
||||
- [ ] **Step 0: Verify the Odoo 19 close method (do NOT code from memory — per `K:\Github\CLAUDE.md`)**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app grep -nE 'def set_close|def set_open|6_churn' /mnt/enterprise-addons/sale_subscription/models/sale_order.py | head"
|
||||
```
|
||||
Expected: a `def set_close(self...)` exists and sets `subscription_state='6_churn'`. If the method name differs in this build, use the actual name in Step 3 and the assertion in Step 1.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `fusion_centralize_billing/tests/test_subscription_cancel.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSubscriptionCancel(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.product = self.env['product.product'].sudo().create(
|
||||
{'name': 'NexaCloud Plan', 'type': 'service',
|
||||
'recurring_invoice': True, 'list_price': 49.0})
|
||||
self.svc_a = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
self.svc_b = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'Other', 'code': 'other'})
|
||||
self.svc_a._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
|
||||
res = self.svc_a._api_create_subscription({
|
||||
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
|
||||
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
|
||||
self.sub = self.env['sale.order'].browse(res['subscription_id'])
|
||||
|
||||
def test_cancel_closes_subscription(self):
|
||||
self.assertEqual(self.sub.subscription_state, '3_progress')
|
||||
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(self.sub.subscription_state, '6_churn')
|
||||
|
||||
def test_cancel_is_idempotent(self):
|
||||
self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(self.sub.subscription_state, '6_churn')
|
||||
|
||||
def test_cancel_unknown_subscription_rejected(self):
|
||||
res = self.svc_a._api_cancel_subscription('999999999')
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
|
||||
def test_cancel_cross_service_rejected(self):
|
||||
# svc_b is not linked to the customer that owns self.sub
|
||||
res = self.svc_b._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
self.assertEqual(self.sub.subscription_state, '3_progress')
|
||||
|
||||
def test_cancel_missing_id_rejected(self):
|
||||
res = self.svc_a._api_cancel_subscription('')
|
||||
self.assertEqual(res['status'], 'error')
|
||||
```
|
||||
|
||||
Append to `fusion_centralize_billing/tests/__init__.py`:
|
||||
```python
|
||||
from . import test_subscription_cancel
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: FAIL — `AttributeError: 'fusion.billing.service' object has no attribute '_api_cancel_subscription'`.
|
||||
|
||||
- [ ] **Step 3: Implement the method**
|
||||
|
||||
In `fusion_centralize_billing/models/service.py`, add immediately after `_api_create_subscription`:
|
||||
```python
|
||||
def _api_cancel_subscription(self, external_ref):
|
||||
"""Cancel (close) the subscription identified by ``external_ref``.
|
||||
|
||||
Authorization mirrors ``_api_record_usage``: the resolved sale.order must
|
||||
exist, be a subscription, and belong to a customer THIS service is linked
|
||||
to. Idempotent — closing an already-churned subscription returns ok.
|
||||
Validation (C3): an empty ref returns a 4xx-shaped error, never raises.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if external_ref in (None, ''):
|
||||
return {'status': 'error', 'error': 'subscription id required'}
|
||||
sub = self._fc_resolve_subscription(external_ref)
|
||||
linked_partners = self.account_link_ids.mapped('partner_id')
|
||||
if not sub.exists() or not sub.is_subscription \
|
||||
or sub.partner_id not in linked_partners:
|
||||
return {'status': 'error', 'error': 'unknown subscription'}
|
||||
if sub.subscription_state != '6_churn':
|
||||
sub.set_close()
|
||||
return {'status': 'ok', 'subscription_id': sub.id,
|
||||
'subscription_state': sub.subscription_state}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancel -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: PASS — 5 tests, 0 failures. (If `set_close()` was a different name in Step 0, use that name here and re-run.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/models/service.py fusion_centralize_billing/tests/test_subscription_cancel.py fusion_centralize_billing/tests/__init__.py
|
||||
git commit -m "feat(billing): add _api_cancel_subscription (close sub, service-scoped authz)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `DELETE /subscriptions/<ref>` route
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/controllers/api.py` (add route after `post_subscription`, ~line 95)
|
||||
- Modify: `fusion_centralize_billing/tests/test_subscription_cancel.py` (add an HTTP-layer test)
|
||||
|
||||
- [ ] **Step 1: Write the failing test (HTTP layer)**
|
||||
|
||||
Append to `tests/test_subscription_cancel.py` a class that exercises the route through Odoo's test client. Add the import at the top of the file:
|
||||
```python
|
||||
from odoo.tests import HttpCase
|
||||
```
|
||||
Then append:
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSubscriptionCancelHttp(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.product = self.env['product.product'].sudo().create(
|
||||
{'name': 'NexaCloud Plan', 'type': 'service',
|
||||
'recurring_invoice': True, 'list_price': 49.0})
|
||||
self.svc = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
self.raw_key = self.svc.action_generate_api_key()
|
||||
self.svc._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
|
||||
res = self.svc._api_create_subscription({
|
||||
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
|
||||
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
|
||||
self.sub_id = res['subscription_id']
|
||||
self.env.cr.commit()
|
||||
self.addCleanup(self._cleanup)
|
||||
|
||||
def _cleanup(self):
|
||||
self.env['sale.order'].browse(self.sub_id).sudo().unlink()
|
||||
|
||||
def test_delete_requires_auth(self):
|
||||
resp = self.url_open(
|
||||
"/api/billing/v1/subscriptions/%s" % self.sub_id,
|
||||
method='DELETE')
|
||||
self.assertEqual(resp.status_code, 401)
|
||||
|
||||
def test_delete_cancels_with_valid_key(self):
|
||||
resp = self.url_open(
|
||||
"/api/billing/v1/subscriptions/%s" % self.sub_id,
|
||||
method='DELETE',
|
||||
headers={'Authorization': 'Bearer %s' % self.raw_key})
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertEqual(resp.json()['subscription_state'], '6_churn')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: FAIL — the DELETE route returns 404 (route not registered) so the assertions fail.
|
||||
|
||||
- [ ] **Step 3: Implement the route**
|
||||
|
||||
In `fusion_centralize_billing/controllers/api.py`, add after `post_subscription`:
|
||||
```python
|
||||
@http.route(f"{API_BASE}/subscriptions/<sub_ref>", type="http", auth="none",
|
||||
methods=["DELETE"], csrf=False)
|
||||
def delete_subscription(self, sub_ref, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
result = service._api_cancel_subscription(sub_ref)
|
||||
if result.get("status") == "error":
|
||||
status = 404 if result.get("error") == "unknown subscription" else 400
|
||||
return self._json(result, status=status)
|
||||
return self._json(result)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run:
|
||||
```
|
||||
ssh odoo-nexa "docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing:TestSubscriptionCancelHttp -u fusion_centralize_billing --stop-after-init"
|
||||
```
|
||||
Expected: PASS — 2 tests, 0 failures.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/controllers/api.py fusion_centralize_billing/tests/test_subscription_cancel.py
|
||||
git commit -m "feat(billing): DELETE /api/billing/v1/subscriptions/<ref> cancel route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
- **Spec coverage:** §4.1.3 "add subscription cancel (`DELETE /subscriptions/:id`)" → Tasks 1+2. ✔
|
||||
- **Placeholder scan:** none — all code is concrete; Step 0 verifies the one Odoo-internal name (`set_close`) against the live container instead of assuming.
|
||||
- **Type consistency:** `_api_cancel_subscription` returns the same `{'status','subscription_id','subscription_state'}` shape as `_api_create_subscription`; error shape matches `_api_record_usage` (`{'status':'error','error':...}`); resolver reused (`_fc_resolve_subscription`) so cross-service rejection is identical to `/usage`. ✔
|
||||
- **Authorization parity:** cancel uses the exact `not sub.exists() or not sub.is_subscription or sub.partner_id not in linked_partners` guard as `_api_record_usage`. ✔
|
||||
@@ -3,7 +3,7 @@
|
||||
**Date:** 2026-05-20
|
||||
**Module:** `fusion_repairs` (new)
|
||||
**Owner:** Gurpreet
|
||||
**Status:** Approved (ready for implementation plan)
|
||||
**Status:** Implemented in repo (bundles 1–11); see [`fusion_repairs/cloud.md`](../../../fusion_repairs/cloud.md) for shipped vs deferred
|
||||
**Scope:** Four-phase build (~8-12 weeks); three intake surfaces; 53 features
|
||||
**Sister modules:** `fusion_repair_compliance`, `fusion_repair_plans`, `fusion_repair_shop`, `fusion_repair_analytics` (Phase 4, optional split)
|
||||
|
||||
@@ -26,8 +26,8 @@ Built incrementally across 4 phases; each phase ships a usable slice.
|
||||
|
||||
## Current state
|
||||
|
||||
- [`fusion_repairs/`](fusion_repairs/) is an **empty folder** — no `__manifest__.py`, models, or views yet.
|
||||
- No existing code in the repo extends Odoo's `repair` app.
|
||||
- [`fusion_repairs/`](fusion_repairs/) is a **full Odoo 19 addon** (~100+ files, version `19.0.2.2.4`). Living status: [`fusion_repairs/cloud.md`](../../../fusion_repairs/cloud.md).
|
||||
- Extends Odoo `repair.order`, `sale.order`, `fusion.technician.task`, portals, and adds flowchart / pricing / parts models.
|
||||
- Closest precedents:
|
||||
- [`fusion_ltc_management/models/ltc_repair.py`](fusion_ltc_management/models/ltc_repair.py) — repair workflow + SO + technician task (LTC facilities only; **keep separate**)
|
||||
- [`fusion_tasks/models/technician_task.py`](fusion_tasks/models/technician_task.py) — field service scheduling with `task_type` including `repair` / `maintenance`
|
||||
@@ -109,7 +109,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
|
||||
| T1 | **Open in Maps button on task** | 2 | `geo:` / Apple Maps URL; one-tap |
|
||||
| T2 | **AI pre-visit brief on mobile form** | 2 | Surfaces `x_fc_ai_summary` prominently; "What to bring" + safety flags |
|
||||
| T3 | Labour timer via fusion_clock | 3 | Tap Start/Pause; final time pre-fills visit report |
|
||||
| T4 | **Client signature on completion** | 2 | OWL signature pad on visit report wizard; attached to repair (pattern from [`fusion_authorizer_portal`](fusion_authorizer_portal)) |
|
||||
| T4 | **Client signature on completion** | 2 | OWL signature pad on visit report wizard; attached to repair (pattern from [`fusion_portal`](fusion_portal)) |
|
||||
| T5 | "Found another issue" button | 2 | Spawn new repair from current visit, same partner, different equipment |
|
||||
| T6 | Parts replaced — serial capture | 3 | Scan/type replaced part serials; stores for OEM warranty + traceability |
|
||||
| T7 | No-show photo proof | 3 | "Client not home" → camera → photo attached → repair flagged + service-call fee added |
|
||||
@@ -164,11 +164,11 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
|
||||
| CL19 | **Voice input → AI transcription** | 4 | Client speaks the problem into mic, AI transcribes + classifies |
|
||||
| CL20 | **Resolution survey + Google review** | 2 | After "resolved" outcome, ask "save you time today?" + Google review CTA |
|
||||
|
||||
### Sales rep portal (mirrors fusion_authorizer_portal pattern)
|
||||
### Sales rep portal (mirrors fusion_portal pattern)
|
||||
|
||||
| ID | Feature | Phase | Notes |
|
||||
|----|---------|-------|-------|
|
||||
| S1 | **Sales rep web intake form** | 1 | `/my/repair/new` — same question flow as backend wizard, mobile-friendly. Reuses `is_sales_rep_portal` flag on `res.partner` from [`fusion_authorizer_portal/security/portal_security.xml`](fusion_authorizer_portal/security/portal_security.xml) line 11 |
|
||||
| S1 | **Sales rep web intake form** | 1 | `/my/repair/new` — same question flow as backend wizard, mobile-friendly. Reuses `is_sales_rep_portal` flag on `res.partner` from [`fusion_portal/security/portal_security.xml`](fusion_portal/security/portal_security.xml) line 11 |
|
||||
| S2 | Sales rep dashboard tile | 1 | Add "Service Calls" tile to `/my/sales-rep/dashboard` showing count of repairs they logged + recent 5 |
|
||||
| S3 | **My Service Calls** list page | 1 | `/my/repairs` — sales rep sees their submitted repairs, status, assigned tech, scheduled date |
|
||||
| S4 | View repair status from portal | 1 | `/my/repair/<id>` — read-only timeline, chatter for non-internal messages, ability to add a comment |
|
||||
@@ -183,7 +183,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
|
||||
|
||||
**Routing namespace:** `/my/repair/*` (intake + my list) and a `/my/sales-rep/repairs` summary route added to the existing sales rep dashboard.
|
||||
|
||||
**Record rule** (mirrors [`fusion_authorizer_portal/security/portal_security.xml`](fusion_authorizer_portal/security/portal_security.xml) line 129 pattern):
|
||||
**Record rule** (mirrors [`fusion_portal/security/portal_security.xml`](fusion_portal/security/portal_security.xml) line 129 pattern):
|
||||
|
||||
```xml
|
||||
<record id="rule_repair_order_sales_rep_portal" model="ir.rule">
|
||||
@@ -236,7 +236,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
|
||||
'website', # QWeb portal templates
|
||||
'fusion_tasks', # technician tasks + fusion.email.builder.mixin
|
||||
'fusion_poynt', # payment collection
|
||||
'fusion_authorizer_portal', # sales rep portal flag + group + dashboard scaffold
|
||||
'fusion_portal', # sales rep portal flag + group + dashboard scaffold
|
||||
]
|
||||
# Phase 3 soft-add: 'appointment', 'fusion_schedule' for client self-booking
|
||||
# Phase 3 soft-add: 'fusion_clock' for tech labour timer (T3)
|
||||
@@ -245,7 +245,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
|
||||
# Phase 3 soft-add: 'fusion_ringcentral' for SMS verify (CL12) + voicemail greeting (CL16) + caller-ID launch (Phase 4)
|
||||
# Phase 4 soft-add: 'fusion_shipping', 'fusion_canada_post' for mail-in repairs (M4)
|
||||
# Soft-call (no depend) at runtime: 'fusion.api.service' via try/except per fusion-api-integration rule
|
||||
# NOTE: fusion_authorizer_portal transitively pulls fusion_claims — accepted for portal reuse
|
||||
# NOTE: fusion_portal transitively pulls fusion_claims — accepted for portal reuse
|
||||
```
|
||||
|
||||
Before coding any Odoo 19 view/JS, read reference files from local OrbStack Docker per project rules.
|
||||
@@ -712,7 +712,7 @@ Themes adapt via project SCSS rules — no hardcoded colours per CLAUDE.md.
|
||||
|
||||
---
|
||||
|
||||
## Sales rep portal (Phase 1 — mirrors fusion_authorizer_portal)
|
||||
## Sales rep portal (Phase 1 — mirrors fusion_portal)
|
||||
|
||||
**Goal:** A sales rep on the road takes a client call and submits a repair request from their phone — same intake flow as backend CS wizard, no Odoo login screen.
|
||||
|
||||
@@ -720,10 +720,10 @@ Themes adapt via project SCSS rules — no hardcoded colours per CLAUDE.md.
|
||||
|
||||
| Option | Recommendation |
|
||||
|--------|----------------|
|
||||
| **Hard depend on `fusion_authorizer_portal`** | RECOMMENDED — reuses the existing `is_sales_rep_portal` flag, `group_sales_rep_portal`, sales rep dashboard scaffolding. Transitively pulls fusion_claims (already core in your stack). |
|
||||
| **Hard depend on `fusion_portal`** | RECOMMENDED — reuses the existing `is_sales_rep_portal` flag, `group_sales_rep_portal`, sales rep dashboard scaffolding. Transitively pulls fusion_claims (already core in your stack). |
|
||||
| Soft depend (try/except + own fallback flag) | Possible but doubles the code: own `is_sales_rep_portal` mirror + own group. Only worth it if you ever want fusion_repairs standalone. |
|
||||
|
||||
We go with hard depend. Add `fusion_authorizer_portal` to the manifest `depends` list.
|
||||
We go with hard depend. Add `fusion_portal` to the manifest `depends` list.
|
||||
|
||||
### Architecture
|
||||
|
||||
@@ -740,7 +740,7 @@ flowchart LR
|
||||
|
||||
### Controller layout ([`controllers/portal_sales_rep_repair.py`](fusion_repairs/controllers/portal_sales_rep_repair.py))
|
||||
|
||||
Routes scoped to `is_sales_rep_portal` users (gate at controller top, pattern from [`fusion_authorizer_portal/controllers/portal_assessment.py`](fusion_authorizer_portal/controllers/portal_assessment.py) line 25):
|
||||
Routes scoped to `is_sales_rep_portal` users (gate at controller top, pattern from [`fusion_portal/controllers/portal_assessment.py`](fusion_portal/controllers/portal_assessment.py) line 25):
|
||||
|
||||
| Route | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
@@ -767,14 +767,14 @@ Avoids the trap of two intake flows drifting out of sync.
|
||||
|
||||
### Templates ([`views/portal_sales_rep_templates.xml`](fusion_repairs/views/portal_sales_rep_templates.xml))
|
||||
|
||||
QWeb templates following [`fusion_authorizer_portal/views/portal_assessment_express.xml`](fusion_authorizer_portal/views/portal_assessment_express.xml) style:
|
||||
QWeb templates following [`fusion_portal/views/portal_assessment_express.xml`](fusion_portal/views/portal_assessment_express.xml) style:
|
||||
|
||||
- `portal_repair_intake_form` — multi-step (accordion or stepper) with same 5 sections as backend wizard
|
||||
- `portal_repair_list` — card list with status badge, scheduled date, tech name
|
||||
- `portal_repair_detail` — timeline + chatter
|
||||
- `portal_repair_intake_thanks` — confirmation page with "Submit Another" button (common on multi-call days)
|
||||
|
||||
Reuses portal gradient/header style via `portal_gradient` template variable already set by [`portal_main.home()`](fusion_authorizer_portal/controllers/portal_main.py) line 85.
|
||||
Reuses portal gradient/header style via `portal_gradient` template variable already set by [`portal_main.home()`](fusion_portal/controllers/portal_main.py) line 85.
|
||||
|
||||
### JS ([`static/src/js/portal_repair_intake.js`](fusion_repairs/static/src/js/portal_repair_intake.js))
|
||||
|
||||
@@ -860,7 +860,7 @@ Extend repair order form view with Intake tab (answers), Maintenance tab, and st
|
||||
|
||||
**Reused (do NOT recreate):**
|
||||
- [`fusion_tasks.group_field_technician`](fusion_tasks/security/security.xml) — for technician access to `repair.order` (parallel to existing tech task rules). Same domain `('technician_id', '=', user.id)` adapted as `('x_fc_technician_task_ids.technician_id', '=', user.id)` on repair orders
|
||||
- [`fusion_authorizer_portal.group_sales_rep_portal`](fusion_authorizer_portal/security/portal_security.xml) — for sales rep portal access (see Sales rep portal section)
|
||||
- [`fusion_portal.group_sales_rep_portal`](fusion_portal/security/portal_security.xml) — for sales rep portal access (see Sales rep portal section)
|
||||
|
||||
**New groups specific to fusion_repairs:**
|
||||
- `group_fusion_repairs_user` — CS intake, view repairs (implied by `base.group_user`)
|
||||
@@ -896,7 +896,7 @@ Extend repair order form view with Intake tab (answers), Maintenance tab, and st
|
||||
|
||||
**Sales rep portal (S1-S4, S6, S8):**
|
||||
- Portal controllers `/my/repair/new`, `/my/repairs`, `/my/repair/<id>`
|
||||
- Mobile-friendly QWeb templates following [`fusion_authorizer_portal/views/portal_assessment_express.xml`](fusion_authorizer_portal/views/portal_assessment_express.xml) style
|
||||
- Mobile-friendly QWeb templates following [`fusion_portal/views/portal_assessment_express.xml`](fusion_portal/views/portal_assessment_express.xml) style
|
||||
- Same intake question flow as backend (via shared service layer)
|
||||
- Mobile photo / camera capture
|
||||
- Client history sidebar exposed in portal form
|
||||
@@ -1137,7 +1137,7 @@ After implementation, test on local dev only:
|
||||
| Backend wizard and sales rep portal drift apart | Both call the same `fusion.repair.intake.service.create_repair_orders(payload)` AbstractModel method; no duplicate business logic |
|
||||
| Sales rep accidentally sees other reps' repairs | Record rule `('x_fc_intake_user_id', '=', user.id)` scoped to `base.group_portal`; integration test asserts cross-rep isolation |
|
||||
| Portal form abandoned mid-flow on call drop | Save partial state to `localStorage` keyed by partner + timestamp; "Resume" prompt on `/my/repair/new` if recent draft exists |
|
||||
| fusion_authorizer_portal install becomes mandatory | Documented in module description; if a deployment doesn't want fusion_authorizer_portal, fall back to a `fusion_repairs_portal_lite` companion module that recreates only the `is_sales_rep_portal` flag |
|
||||
| fusion_portal install becomes mandatory | Documented in module description; if a deployment doesn't want fusion_portal, fall back to a `fusion_repairs_portal_lite` companion module that recreates only the `is_sales_rep_portal` flag |
|
||||
| **Public form spam / abuse** | reCAPTCHA v3 + honeypot + per-IP rate limit + per-phone rate limit + SMS verify before submit (Phase 2). Block ASN ranges via Odoo's `ir.rule` if needed |
|
||||
| **AI giving unsafe medical advice** | Strict system prompt + JSON schema validation + keyword filter (rejects "diagnose", "you have", "stop using"); falls back to deterministic rules on any malformed/unsafe output; legal disclaimer "this is not medical advice" shown on every AI step |
|
||||
| **AI cost runaway from public traffic** | Hard daily/monthly budget cap via `fusion.api.service`; CAPTCHA gates AI calls; cache results for identical symptom-category pairs; deterministic fallback never costs anything |
|
||||
|
||||
444
docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md
Normal file
444
docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# Fusion Login Audit — Design Spec
|
||||
|
||||
**Status:** Approved, ready for implementation planning
|
||||
**Date:** 2026-05-26
|
||||
**Author:** Brainstormed with the user (Gurpreet) for the Westin Healthcare Odoo 19 deployment
|
||||
**Target module path:** `K:\Github\Odoo-Modules\fusion_login_audit\`
|
||||
**Production deploy target:** `/opt/odoo/custom-addons/fusion_login_audit/` on `odoo-westin` (VM 101, worker1, 192.168.1.40)
|
||||
**Production DB:** `westin-v19` (Odoo 19, PostgreSQL)
|
||||
|
||||
## Background and motivation
|
||||
|
||||
A spot audit of user `info@gsafinancialconsulting.com` ("GSA Accounting", uid 63) revealed Odoo's built-in login tracking is effectively unusable for compliance:
|
||||
|
||||
- `res.users.log` rows are pruned by the daily `_gc_user_logs` cron — only the most recent login per user survives. For GSA Accounting the entire history collapsed to a single row at `2026-04-22 20:24 EDT`.
|
||||
- `/var/log/odoo` on the production VM is empty because Odoo is configured at `log_level=warn` with stdout-only logging; INFO-level auth lines aren't captured anywhere.
|
||||
- The container's json log is 444 KB and rotates frequently — nothing about the user remains.
|
||||
- The existing `network_logger` module records outbound HTTP traffic from Odoo (uid=1 always), not user activity.
|
||||
|
||||
Result: today there is **no durable record** of who logged in, when, from where, or how often. A user with `base.group_system` + Technical Features and no 2FA — like GSA Accounting — could be active for months without any reconstructable trail.
|
||||
|
||||
This module closes that gap with a dedicated audit table that survives Odoo's GC, captures successful and failed authentications, surfaces results in the user form, and alerts admins on suspicious failure bursts.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Durable audit trail** of every password-authenticated login (success and failure) on `westin-v19`.
|
||||
2. **Per-user visibility** for Settings admins via a tab + smart button on `res.users`.
|
||||
3. **Failure-burst alerting** to admins on a configurable consecutive-failure threshold.
|
||||
4. **Geo-enrichment** of IPs out-of-band so authentication latency is unaffected.
|
||||
5. **Zero risk to the auth path** — an audit-write failure must never block a real login.
|
||||
|
||||
## Non-goals (v1)
|
||||
|
||||
- Logging every HTTP request / page view (explicitly de-scoped during brainstorming).
|
||||
- Logging session resume events from auth cookies.
|
||||
- API-key authentication (`credential['type'] == 'apikey'`) — bypasses `_check_credentials`. Documented as a known gap; addressable in a follow-up.
|
||||
- OAuth / SSO logins — no OAuth provider configured on westin-v19.
|
||||
- Self-service "view my own login activity" for end users — visibility is admin-only.
|
||||
- Auto-disabling users on failed logins — flagged as a self-service DoS vector during brainstorming.
|
||||
|
||||
## Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Odoo authentication path │
|
||||
│ │
|
||||
│ /web/login → res.users._login() → res.users._check_credentials() │
|
||||
│ ↓ │
|
||||
│ (on success) │
|
||||
│ ↓ │
|
||||
│ res.users._update_last_login() │
|
||||
│ ↓ │
|
||||
│ ┌────────────────────┴────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ fusion.login.audit (sudo create) Odoo's existing res_users_log │
|
||||
│ result='success' + IP + UA │
|
||||
│ │
|
||||
│ (on AccessDenied) │
|
||||
│ ↓ │
|
||||
│ fusion.login.audit (sudo create) │
|
||||
│ result='failure' + failure_reason + attempted_login │
|
||||
│ ↓ │
|
||||
│ _fc_recent_failure_count() >= threshold? │
|
||||
│ ↓ yes │
|
||||
│ _fc_send_failure_alert() → mail.mail to base.group_system │
|
||||
└──────────────────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────┼─────────────────────┐
|
||||
▼ ▼ ▼
|
||||
cron: cron_geo_enrich cron: cron_retention_gc UI surfaces:
|
||||
every 5 min daily 03:00 UTC - smart button on res.users
|
||||
- reverse DNS - delete rows older than - "Login Activity" tab
|
||||
- ip-api.com lookup x_fc_login_audit_ - Settings → Technical →
|
||||
- 30-day local cache retention_days Login Audit menus
|
||||
- Settings page section
|
||||
```
|
||||
|
||||
The auth-path hooks are synchronous (must run inside the request). Geolocation, alerting, and retention are out-of-band so they cannot affect login latency.
|
||||
|
||||
## Module skeleton
|
||||
|
||||
```
|
||||
fusion_login_audit/
|
||||
├── __manifest__.py
|
||||
├── __init__.py
|
||||
├── models/
|
||||
│ ├── __init__.py
|
||||
│ ├── res_users.py # extends res.users with capture hooks + computed fields + smart-button action
|
||||
│ ├── fusion_login_audit.py # the new audit record model
|
||||
│ └── res_config_settings.py # alert threshold + window + retention settings
|
||||
├── data/
|
||||
│ ├── ir_cron_data.xml # cron_geo_enrich + cron_retention_gc
|
||||
│ └── mail_template_data.xml # failed-login alert template
|
||||
├── security/
|
||||
│ ├── security.xml # record rule: read for base.group_system only
|
||||
│ └── ir.model.access.csv
|
||||
├── views/
|
||||
│ ├── fusion_login_audit_views.xml # list / form / kanban / search
|
||||
│ ├── res_users_views.xml # tab + smart button
|
||||
│ ├── res_config_settings_views.xml # Settings section
|
||||
│ └── menus.xml # Settings → Technical → Login Audit
|
||||
├── tests/
|
||||
│ ├── __init__.py
|
||||
│ ├── test_login_audit.py
|
||||
│ └── test_security.py
|
||||
└── static/
|
||||
└── description/
|
||||
└── icon.png # copied from C:\Users\gsing\Downloads\fusion logs.png
|
||||
```
|
||||
|
||||
**Manifest highlights**
|
||||
|
||||
- `version='19.0.1.0.0'` (project naming convention)
|
||||
- `license='OPL-1'` (matches `fusion_accounts`)
|
||||
- `depends=['base', 'mail']`
|
||||
- `category='Tools'`
|
||||
- `application=False` (it's a technical addon, not a top-level app)
|
||||
|
||||
**Dependencies (Python):** none new. Uses the `user_agents` library already shipped with Odoo. Geolocation calls `http://ip-api.com/json/<ip>` via the standard `requests` library (no API key required, 45 req/min free tier).
|
||||
|
||||
**Field naming:** new fields on existing models (`res.users`, `res.config.settings`) use the `x_fc_*` prefix per project CLAUDE.md. The new `fusion.login.audit` model uses unprefixed field names.
|
||||
|
||||
## Data model
|
||||
|
||||
### `fusion.login.audit` (new model, table `fusion_login_audit`)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `user_id` | Many2one(`res.users`, `ondelete='set null'`) | Null if attempted login didn't match any user |
|
||||
| `attempted_login` | Char(255), indexed | Always set — even on unknown-user failures |
|
||||
| `result` | Selection(`success`, `failure`) | Indexed |
|
||||
| `failure_reason` | Selection(`bad_password`, `unknown_user`, `disabled_user`, `2fa_failed`, `other`) | Null on success |
|
||||
| `event_time` | Datetime, indexed, default `fields.Datetime.now()` | UTC; displayed in user TZ via standard widget |
|
||||
| `ip_address` | Char(45) | IPv6-safe length |
|
||||
| `ip_hostname` | Char(255) | Reverse DNS, populated by geo cron |
|
||||
| `country_code` | Char(2), indexed | ISO-3166-1 alpha-2; null until cron runs |
|
||||
| `country_name` | Char(64) | |
|
||||
| `city` | Char(128) | |
|
||||
| `geo_state` | Char(64) | Region/state name |
|
||||
| `geo_lookup_state` | Selection(`pending`, `done`, `private_ip`, `internal`, `failed`) | Drives the geo cron worklist; `internal` = no HTTP request was attached |
|
||||
| `user_agent_raw` | Char(512) | The full UA header |
|
||||
| `browser` | Char(64) | e.g. "Chrome 140" — parsed |
|
||||
| `os` | Char(64) | e.g. "Windows 11" — parsed |
|
||||
| `device_type` | Selection(`desktop`, `mobile`, `tablet`, `bot`, `unknown`) | From `user_agents` |
|
||||
| `database` | Char(64) | Multi-DB safety — which DB was logged into |
|
||||
|
||||
**Indexes (in addition to the column-level `indexed=True`):**
|
||||
- `(user_id, event_time DESC)` — per-user history
|
||||
- `(attempted_login, event_time DESC)` — failure-burst detection by login string
|
||||
- `(geo_lookup_state, event_time)` — cron worklist
|
||||
|
||||
**No `_inherit = ['mail.thread']`** — audit rows are append-only and should not have chatter.
|
||||
|
||||
### `res.users` additions (per CLAUDE.md `x_fc_*` convention)
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `x_fc_login_audit_ids` | One2many(`fusion.login.audit`, `user_id`) | Backs the tab + smart-button count |
|
||||
| `x_fc_login_audit_count` | Integer, compute, store=False | Smart-button label |
|
||||
| `x_fc_last_successful_login` | Datetime, compute, store=True | Indexed; cheap "last seen" lookup |
|
||||
| `x_fc_last_login_ip` | Char(45), compute, store=True | Surfaces last source IP in the form header |
|
||||
|
||||
The `store=True` computes are triggered by the create on `fusion.login.audit` (via `@api.depends('x_fc_login_audit_ids.event_time', 'x_fc_login_audit_ids.result')`).
|
||||
|
||||
### `res.config.settings` additions
|
||||
|
||||
Booleans / integers only (per CLAUDE.md — no Date fields on settings):
|
||||
|
||||
| Field | Default | Notes |
|
||||
|---|---|---|
|
||||
| `x_fc_login_audit_retention_days` | 365 | Retention GC cron honors this; 0 = keep forever |
|
||||
| `x_fc_login_audit_alert_threshold` | 5 | Consecutive failures before alert |
|
||||
| `x_fc_login_audit_alert_window_min` | 15 | Time window in minutes for "consecutive" |
|
||||
| `x_fc_login_audit_alert_enabled` | True | Master kill-switch for alert emails |
|
||||
|
||||
Each is backed by an `ir.config_parameter` (`fusion_login_audit.retention_days`, etc.) so changes from the Settings page persist.
|
||||
|
||||
### Multi-company
|
||||
|
||||
`fusion.login.audit` is intentionally **company-agnostic**. Logins happen before any company context is established; synthesizing one would either break the unknown-user case or require a "system company" placeholder. Settings admins see all rows globally.
|
||||
|
||||
## Capture flow
|
||||
|
||||
### Successful login (`_update_last_login`)
|
||||
|
||||
```python
|
||||
def _update_last_login(self):
|
||||
result = super()._update_last_login()
|
||||
try:
|
||||
self._fc_record_login_event(result='success')
|
||||
except Exception:
|
||||
_logger.exception("fusion_login_audit: failed to record success row for %s", self.login)
|
||||
return result
|
||||
```
|
||||
|
||||
Called by Odoo only after the credential check has passed. Super() runs first so Odoo's own bookkeeping is unaffected.
|
||||
|
||||
### Failed login on known user (`_check_credentials`)
|
||||
|
||||
```python
|
||||
def _check_credentials(self, credential, env):
|
||||
try:
|
||||
return super()._check_credentials(credential, env)
|
||||
except AccessDenied:
|
||||
try:
|
||||
self._fc_record_login_failure(credential, reason='bad_password')
|
||||
if self._fc_recent_failure_count(credential) >= self._fc_alert_threshold():
|
||||
self._fc_send_failure_alert(credential)
|
||||
except Exception:
|
||||
_logger.exception("fusion_login_audit: failed to record/alert failure")
|
||||
raise
|
||||
```
|
||||
|
||||
TOTP failures (from `auth_totp`) also raise `AccessDenied` and are caught here. Distinguish via `credential.get('type') == 'totp'` to set `failure_reason='2fa_failed'`.
|
||||
|
||||
### Failed login on unknown user (`_login` classmethod)
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def _login(cls, db, credential, user_agent_env):
|
||||
try:
|
||||
return super()._login(db, credential, user_agent_env)
|
||||
except AccessDenied:
|
||||
try:
|
||||
cls._fc_record_unknown_user_failure(db, credential, user_agent_env)
|
||||
except Exception:
|
||||
_logger.exception("fusion_login_audit: failed to record unknown-user failure")
|
||||
raise
|
||||
```
|
||||
|
||||
Without this override, unknown-user attempts never reach `_check_credentials` and would silently disappear from the audit. The classmethod sets `user_id=None` and stores the attempted login string.
|
||||
|
||||
### Context extraction (`_fc_build_event_vals`)
|
||||
|
||||
Single helper shared by all three paths:
|
||||
|
||||
```python
|
||||
def _fc_build_event_vals(self, result, attempted_login, failure_reason=None):
|
||||
from odoo.http import request
|
||||
vals = {
|
||||
'attempted_login': attempted_login,
|
||||
'result': result,
|
||||
'failure_reason': failure_reason,
|
||||
'event_time': fields.Datetime.now(),
|
||||
'database': self.env.cr.dbname,
|
||||
'geo_lookup_state': 'pending',
|
||||
}
|
||||
if request and request.httprequest:
|
||||
vals['ip_address'] = request.httprequest.remote_addr # respects proxy_mode
|
||||
ua_str = request.httprequest.user_agent.string or ''
|
||||
vals['user_agent_raw'] = ua_str[:512]
|
||||
from user_agents import parse as ua_parse
|
||||
ua = ua_parse(ua_str)
|
||||
vals['browser'] = f"{ua.browser.family} {ua.browser.version_string}"[:64]
|
||||
vals['os'] = f"{ua.os.family} {ua.os.version_string}"[:64]
|
||||
vals['device_type'] = (
|
||||
'mobile' if ua.is_mobile else
|
||||
'tablet' if ua.is_tablet else
|
||||
'bot' if ua.is_bot else
|
||||
'desktop' if ua.is_pc else 'unknown'
|
||||
)
|
||||
else:
|
||||
vals['ip_address'] = 'internal'
|
||||
vals['user_agent_raw'] = '<no-request>'
|
||||
vals['geo_lookup_state'] = 'internal' # distinct from private_ip; cron skips both
|
||||
return vals
|
||||
```
|
||||
|
||||
### Write semantics
|
||||
|
||||
- All writes use `self.env['fusion.login.audit'].sudo().create(vals)` — low-privilege users can still generate their own audit rows despite the read-only record rule.
|
||||
- `mail_create_nolog=True` context to avoid chatter noise.
|
||||
- The password value is **never** present in `vals` and is hard-stripped from any `credential` dict before logging. A regression test asserts this.
|
||||
|
||||
## Async geolocation cron (`cron_geo_enrich`)
|
||||
|
||||
**Schedule:** every 5 minutes, `numbercall=-1`, `priority=10`.
|
||||
|
||||
**Worker logic:**
|
||||
|
||||
1. Select 100 oldest rows where `geo_lookup_state='pending'`.
|
||||
2. For each row:
|
||||
- **Private-IP shortcut:** if `ip_address` is in `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `::1`, or `fe80::/10` → set `geo_lookup_state='private_ip'`, `country_code='--'`, `city='Private network'`.
|
||||
- **Cache check:** look for any prior row with the same `ip_address` and `country_code IS NOT NULL` and `event_time > now() - interval '30 days'`. If found, copy `country_code` / `country_name` / `city` / `geo_state` / `ip_hostname` locally; set state `done`. No external call.
|
||||
- **Reverse DNS:** `socket.gethostbyaddr(ip)` with `socket.setdefaulttimeout(1.5)`.
|
||||
- **HTTP lookup:** `requests.get('http://ip-api.com/json/' + ip, params={'fields': 'status,country,countryCode,regionName,city'}, timeout=3, headers={'User-Agent': 'Odoo-FusionLoginAudit/19.0'})`. The call passes through `network_logger` automatically.
|
||||
- On `status='success'` → fill fields, set state `done`.
|
||||
- On HTTP error, timeout, or `status='fail'` → set state `failed` (no retry).
|
||||
3. `self.env.cr.commit()` after each row so one bad IP cannot roll back the batch.
|
||||
4. **Rate limit defense:** if the response header `X-Rl` is `'0'`, break early and leave remaining rows as `pending` for the next run.
|
||||
|
||||
**Privacy:** the only outbound data is the IP itself. No user identifiers, no Odoo URL, no headers beyond `User-Agent: Odoo-FusionLoginAudit/19.0`. All outbound calls are auditable in `network_logger`.
|
||||
|
||||
## UI surfaces
|
||||
|
||||
### `res.users` form view
|
||||
|
||||
- **Smart button** in the button box, gated `groups="base.group_system"`:
|
||||
```
|
||||
┌──────────────┐
|
||||
│ 🔑 N Logins │
|
||||
└──────────────┘
|
||||
```
|
||||
Click → opens `fusion.login.audit` list view filtered to this user (`domain=[('user_id', '=', active_id)]`).
|
||||
- **New tab "Login Activity"** appended after existing tabs, gated `groups="base.group_system"`:
|
||||
- Header summary: `x_fc_last_successful_login`, `x_fc_last_login_ip` (readonly).
|
||||
- Embedded one2many tree on `x_fc_login_audit_ids`, `limit="30"`, columns: `event_time`, `result` (colored badge), `ip_address`, `country_code` (with flag emoji display), `browser`, `os`, `failure_reason`.
|
||||
- Tree is `create="false" edit="false" delete="false"`.
|
||||
- "View full history →" button below the tree, same action as the smart button.
|
||||
|
||||
### Standalone views for `fusion.login.audit`
|
||||
|
||||
- **List view:** `event_time`, `user_id` (clickable), `attempted_login` (only when `user_id IS NULL`), `result` badge, `ip_address`, `country_code`, `city`, `browser`, `device_type`. Default sort `event_time DESC`.
|
||||
- **Search view:** filters for "Successes", "Failures", "Last 24h", "Last 7d", "Last 30d", "Unknown users (no user_id)"; group-by IP / country / user.
|
||||
- **Form view:** readonly; collapsible "Raw" section for `user_agent_raw`, `ip_hostname`, `database`, `geo_lookup_state`.
|
||||
- **Kanban view:** grouped by `result`, color-coded green/red.
|
||||
|
||||
### Menus
|
||||
|
||||
Under **Settings → Technical → Login Audit**:
|
||||
- "Login Events" → default list view
|
||||
- "Failed Logins (24h)" → list view with default `[('result', '=', 'failure'), ('event_time', '>=', context_today() - 1)]`
|
||||
|
||||
### Settings page
|
||||
|
||||
New "Login Audit" section in **Settings → General Settings** (gated `groups="base.group_system"`):
|
||||
- "Retention period (days)" — integer, help: "0 = keep forever"
|
||||
- "Alert threshold" — integer
|
||||
- "Alert window (minutes)" — integer
|
||||
- "Send failed-login alerts" — boolean
|
||||
|
||||
## Security
|
||||
|
||||
### Group
|
||||
|
||||
No new group created. Read is bound to existing `base.group_system`. Rationale: brainstorming decision was "Settings admins only" — reusing the existing group avoids an extra checkbox to manage.
|
||||
|
||||
### Model access (`ir.model.access.csv`)
|
||||
|
||||
| Group | Read | Write | Create | Unlink |
|
||||
|---|---|---|---|---|
|
||||
| `base.group_system` | ✓ | ✗ | ✗ | ✗ |
|
||||
|
||||
**No write/create/unlink for any group via the UI.** Audit rows are only written via `sudo()` from inside the auth hooks. An audit log admins can mutate is not an audit log.
|
||||
|
||||
### Record rule
|
||||
|
||||
Single global rule on `fusion.login.audit`: read for `base.group_system` only. The user-form one2many is additionally gated at the view level via `groups="base.group_system"` (not via a more permissive record rule) so non-admins have no read path even if they craft a custom view.
|
||||
|
||||
### Field-level
|
||||
|
||||
- `failure_reason` stores a category, never the attempted password.
|
||||
- `_fc_build_event_vals` strips `credential['password']` before any logging or row construction.
|
||||
- The `credential` dict is never persisted.
|
||||
- Regression test: no field on `fusion.login.audit` ever contains a known-test-password string.
|
||||
|
||||
## Retention
|
||||
|
||||
**Cron `cron_retention_gc`** — daily at 03:00 UTC, `numbercall=-1`:
|
||||
|
||||
```python
|
||||
days = int(self.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_login_audit.retention_days', 365))
|
||||
if days > 0:
|
||||
cutoff = fields.Datetime.now() - timedelta(days=days)
|
||||
self.env['fusion.login.audit'].sudo().search([
|
||||
('event_time', '<', cutoff)
|
||||
]).unlink()
|
||||
```
|
||||
|
||||
Uses `unlink()` rather than raw `DELETE` so any ORM side effects fire. Expected DB load on `westin-v19`: 27 users × ~2 logins/day × 365 days ≈ 20k rows steady state — trivial for Postgres.
|
||||
|
||||
## Failed-login alert
|
||||
|
||||
**Mail template** in `data/mail_template_data.xml`:
|
||||
|
||||
- **Subject:** `[Login Audit] {threshold} failed login attempts for {attempted_login}`
|
||||
- **Body:** simple HTML table of the last N failure rows for that `attempted_login` — timestamp, IP, country, user-agent summary.
|
||||
- **Recipients:** all users in `base.group_system` with a non-empty `email`.
|
||||
- **Send path:** `mail.mail` queue with `auto_delete=True` so the auth response isn't blocked.
|
||||
|
||||
**Cooldown:** 60 min per `attempted_login`, enforced via an `ir.config_parameter` keyed by `fusion_login_audit.last_alert:{attempted_login}` storing the last-send timestamp. Prevents a sustained attack from flooding admin inboxes.
|
||||
|
||||
**Kill-switch:** if `x_fc_login_audit_alert_enabled = False`, no alerts are sent regardless of threshold.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| `request` is None (XML-RPC, internal auth from cron) | Row written with `ip_address='internal'`, `user_agent_raw='<no-request>'`, `geo_lookup_state='internal'` (cron skips) |
|
||||
| Audit insert errors on a hot DB | Login still succeeds — every auth-path hook is wrapped in `try/except Exception: _logger.exception(...)` |
|
||||
| User deleted while audit rows remain | `ondelete='set null'` preserves history; `attempted_login` keeps the readable identifier |
|
||||
| Password reset / `auth_signup` | The reset itself generates no login event; the subsequent login does — matches expectation |
|
||||
| API key authentication | **Out of scope v1** (bypasses `_check_credentials`); documented |
|
||||
| OAuth / SSO | Out of scope v1; no provider configured on westin-v19 |
|
||||
| Portal user (`share=True`) | Logged the same way; smart button remains admin-visible |
|
||||
| Two requests racing on the same private IP | Each writes its own row; geo cache is best-effort, not transactional |
|
||||
| `proxy_mode = False` in `odoo.conf` | `remote_addr` will be the reverse-proxy IP — known limitation, fixable by setting `proxy_mode = True` (out of scope) |
|
||||
|
||||
## Testing
|
||||
|
||||
### `tests/test_login_audit.py` (TransactionCase)
|
||||
|
||||
1. Successful login writes a row with `result='success'` and resolved `user_id`.
|
||||
2. Bad password writes `result='failure'` with `failure_reason='bad_password'` and re-raises `AccessDenied`.
|
||||
3. Unknown user writes `result='failure'` with `failure_reason='unknown_user'`, `user_id=None`, non-null `attempted_login`.
|
||||
4. No field on the written row contains the attempted password (regression).
|
||||
5. Geo cron: pending row gets enriched from local cache when same IP exists within 30 days (no HTTP call made).
|
||||
6. Retention cron: rows older than `retention_days` are deleted; newer survive.
|
||||
7. Alert email: 5 failures in 15 min queues exactly one `mail.mail`; a 6th failure within cooldown queues zero.
|
||||
8. `database` field is populated from `self.env.cr.dbname`.
|
||||
9. Audit-write exception inside `_update_last_login` does not block the login.
|
||||
|
||||
### `tests/test_security.py` (HttpCase)
|
||||
|
||||
1. Non-admin user gets `AccessError` on direct `search(fusion.login.audit)`.
|
||||
2. Non-admin sees the user form view without the smart button or "Login Activity" tab (XML node hidden by `groups`).
|
||||
3. Settings admin sees both.
|
||||
|
||||
## Deployment notes
|
||||
|
||||
- **Local install:** copy module to `K:\Github\Odoo-Modules\fusion_login_audit\` (bind-mounted into `odoo-modsdev-app` container). Update via:
|
||||
```
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev -i fusion_login_audit --stop-after-init
|
||||
```
|
||||
- **Production install:** sync to `/opt/odoo/custom-addons/fusion_login_audit/` on odoo-westin (via `auto_sync.sh` or git pull on the VM). Update via:
|
||||
```
|
||||
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -i fusion_login_audit --stop-after-init"
|
||||
```
|
||||
- **Icon:** copy `C:\Users\gsing\Downloads\fusion logs.png` to `K:\Github\Odoo-Modules\fusion_login_audit\static\description\icon.png`.
|
||||
- **Verify `proxy_mode = True`** in `/opt/odoo/odoo.conf` on odoo-westin before relying on `ip_address` accuracy — otherwise `remote_addr` will be the reverse-proxy IP rather than the real client. Confirmed out of scope for this module, but flag for the operator.
|
||||
- **Verify outbound to `ip-api.com:80`** is reachable from the odoo-westin VM (Tailscale/firewall) — if blocked, `geo_lookup_state` will simply be `failed` and the rest of the module is unaffected.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- Logging in as any user creates exactly one `fusion.login.audit` row with `result='success'` and the correct IP/UA.
|
||||
- Failed login attempts create exactly one row with `result='failure'` and the correct `failure_reason`.
|
||||
- Unknown-user attempts create a row with `user_id=None` and the typed login string in `attempted_login`.
|
||||
- The smart button on `res.users` shows the lifetime count and opens the filtered list.
|
||||
- The "Login Activity" tab shows the last 30 events with correct color coding.
|
||||
- After 5 failures from the same login string within 15 minutes, exactly one alert email arrives in the inbox of every Settings admin with an `email` set.
|
||||
- The geo cron populates `country_code`, `city`, `ip_hostname` for public IPs within 10 minutes of the login.
|
||||
- The retention cron, set to 1 day for a test, deletes rows older than 24 hours and leaves newer ones.
|
||||
- All tests pass: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable -i fusion_login_audit --stop-after-init`.
|
||||
@@ -0,0 +1,336 @@
|
||||
# Fusion Helpdesk — Customer Follow-up & Embedded Ticket Inbox
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Status:** Approved design (ready for implementation plan)
|
||||
- **Branch:** `feat/helpdesk-customer-followup`
|
||||
- **Modules touched:** `fusion_helpdesk` (client deployments), `fusion_helpdesk_central` (central Odoo)
|
||||
- **Target system:** `odoo-nexa` / `erp.nexasystems.ca`, DB `nexamain`, Odoo 19 Enterprise
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Today, end users at client deployments (ENTECH, MOBILITY, …) file helpdesk tickets through an in-app
|
||||
"Report a Bug / Request a Feature" systray dialog. Those tickets land on the central Odoo Helpdesk but
|
||||
carry **no customer identity**, so:
|
||||
|
||||
- support replies email nobody,
|
||||
- the submitter can't see or follow up on their ticket,
|
||||
- the ticket never appears in any customer portal.
|
||||
|
||||
This design makes ticket follow-up work end to end. It rests on **one keystone fix** (attach the
|
||||
submitter's identity to every ticket) and then exposes **two follow-up surfaces** matched to two
|
||||
audiences:
|
||||
|
||||
1. **In-app embedded inbox** — the systray dialog becomes a small ticket inbox (New + My Tickets). Client
|
||||
staff read replies and follow up **without leaving their own Odoo or logging into the central system**.
|
||||
2. **Native Enterprise portal** — for external web/email customers, the existing Odoo portal + magic-link
|
||||
+ free sign-up does the job; they have no workspace to embed into.
|
||||
|
||||
Scope tier: **Polished** (light branding + acknowledgement email + in-app unread badge). Not a custom
|
||||
portal theme.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem & Diagnosis (grounded in the live system)
|
||||
|
||||
### 2.1 Current architecture
|
||||
|
||||
- **`fusion_helpdesk`** (installed on *client* deployments): OWL systray dialog → `POST
|
||||
/fusion_helpdesk/submit` → forwards to central over **XML-RPC as a shared bot account** (API key issued
|
||||
by `fusion_helpdesk_central`). Ticket payload today is only `{name, description, team_id}`. The
|
||||
reporter's name/login is embedded as **HTML text inside the description's "Diagnostic context" table** —
|
||||
not as structured fields.
|
||||
- **`fusion_helpdesk_central`** (installed on *central* Odoo): manages the per-client API keys on the
|
||||
shared bot user. Does **not** touch tickets, portal, notifications.
|
||||
|
||||
### 2.2 The actual bug (verified on `nexamain`, 2026-05-27)
|
||||
|
||||
All **51/51** tickets have `partner_id`, `partner_email`, `partner_name` = NULL (0 coverage). With no
|
||||
customer attached, Odoo has nobody to email, nobody to add as follower, no `/my/tickets` to populate, and
|
||||
no recipient for a magic link.
|
||||
|
||||
### 2.3 The platform already does the hard part
|
||||
|
||||
Installed & enabled on `odoo-nexa`:
|
||||
|
||||
- Modules: `helpdesk` 19.0.1.6, `website_helpdesk`, `website_helpdesk_knowledge`, `helpdesk_account`,
|
||||
`helpdesk_sale`, `portal`, `website`, `auth_signup`.
|
||||
- `auth_signup.invitation_scope = b2c` (free customer sign-up ON), `auth_signup.reset_password = True`.
|
||||
- `web.base.url = https://erp.nexasystems.ca`, `mail.catchall.domain = nexasystems.ca`, 4 working SMTP
|
||||
servers → outbound email works.
|
||||
- Team 1 **"Customer Care"** is already portal-ready: `privacy_visibility = portal`,
|
||||
`use_website_helpdesk_form = true`, `allow_portal_ticket_closing = true`, `use_alias = true`, alias
|
||||
`support` (→ `support@nexasystems.ca`).
|
||||
|
||||
`helpdesk.ticket` model (Enterprise source, verified):
|
||||
|
||||
- `_inherit = ['portal.mixin', 'mail.thread.cc', 'rating.mixin']`; `_mail_thread_customer = True`;
|
||||
`_primary_email = 'partner_email'`; `access_url = '/my/ticket/<id>'` (← that is the magic link).
|
||||
- **`create()` auto-resolves the partner**: when `partner_email` is given and `partner_id` is not, it calls
|
||||
`mail.thread._partner_find_from_emails_single([partner_email], {name, company_id})` to find-or-create the
|
||||
partner and set `partner_id` (`helpdesk_ticket.py` ≈ L564–572).
|
||||
- **`create()` subscribes the customer as a follower** (the "make customer follower" loop, ≈ L600–620),
|
||||
so they receive reply notifications by email.
|
||||
- Portal routes: `/my/tickets` (auth=`user`); `/my/ticket/<int:ticket_id>/<access_token>` (auth=`public`)
|
||||
→ validates token via `_document_check_access` → renders `helpdesk.tickets_followup` (reply composer
|
||||
included); `/my/ticket/close/<id>/<token>` posts a message with `author_id = partner_id`; public web
|
||||
form at `/helpdesk/<team>`.
|
||||
|
||||
**Consequence:** the keystone fix is small — pass `partner_email` + `partner_name` in the create payload and
|
||||
native helpdesk creates the partner, links it, and subscribes it. Replies then email the customer with a
|
||||
magic-link "View Ticket" button automatically.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals / Non-Goals
|
||||
|
||||
### Goals
|
||||
- Every new ticket carries the submitter's real identity (`partner_email`, `partner_name`,
|
||||
`x_fc_client_label`).
|
||||
- Agent replies reach the customer **by email** with a working **magic link**.
|
||||
- **In-app staff** can list, read, and reply to their tickets **inside their own Odoo** — no login, no
|
||||
context switch.
|
||||
- **External web/email customers** get the native portal + magic link + free sign-up.
|
||||
- Light branding (logo/colours) + an acknowledgement email on ticket creation.
|
||||
- Hybrid in-app visibility: regular users see their own tickets; a designated admin sees all of their
|
||||
deployment's tickets.
|
||||
|
||||
### Non-Goals
|
||||
- No custom portal theme, custom website submission form, KB-deflection, or SLA timeline UI (that was
|
||||
Tier C — deliberately out of scope).
|
||||
- No replication of tickets into the client database — the in-app inbox is a **live RPC view**.
|
||||
- No backfill of the 51 existing identity-less tickets (low value; their only identity is free text).
|
||||
- No changes to the billing module (`fusion_centralize_billing`) — separate work.
|
||||
|
||||
---
|
||||
|
||||
## 4. Audiences & channels (locked decisions)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Channels | **Both** — in-app reporter *and* external web/email |
|
||||
| In-app visibility | **Hybrid** — own by default; designated admin sees all of their deployment's tickets |
|
||||
| Scope tier | **Polished** — light branding + ack email + in-app unread badge |
|
||||
| Acknowledgement email on create | **Yes** (immediate magic link) |
|
||||
| Reporter email at submit | **Confirmed / editable** in the New form |
|
||||
| "See all" gating | **New group** on the client deployment |
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture
|
||||
|
||||
### 5.1 Keystone — identity layer
|
||||
|
||||
- **Client side (`fusion_helpdesk`)**: in `submit()`, add to the create payload:
|
||||
- `partner_name` = `request.env.user.name`
|
||||
- `partner_email` = confirmed value from the form (default `request.env.user.email or .login`, editable)
|
||||
- `x_fc_client_label` = `cfg['client_label']`
|
||||
- **Central side (`fusion_helpdesk_central`)**: add `x_fc_client_label` (Char, indexed) to `helpdesk.ticket`
|
||||
and surface it in the agent backend (list column + search filter) so support can filter by client. Native
|
||||
helpdesk does the partner resolution + follower subscription.
|
||||
|
||||
`x_fc_client_label` is the structured tag that makes deployment-scoped queries (and the admin "see all"
|
||||
view) reliable — far better than parsing the `[ENTECH]` subject prefix.
|
||||
|
||||
### 5.2 Two surfaces
|
||||
|
||||
- **Surface A — in-app embedded inbox** (`fusion_helpdesk`, client deployments). New work.
|
||||
- **Surface B — native Enterprise portal** (`fusion_helpdesk_central` config + light branding). Mostly
|
||||
configuration; near-zero new code.
|
||||
|
||||
### 5.3 Module responsibilities
|
||||
|
||||
**`fusion_helpdesk` (client) — majority of new work**
|
||||
- Controller (`controllers/main.py`): keystone payload change + new endpoints (§6.1).
|
||||
- OWL dialog (`static/src/js/…`, `static/src/xml/…`): New + My Tickets tabs; thread view; reply box.
|
||||
- Systray (`fusion_helpdesk_systray.js`): unread badge.
|
||||
- `res.groups`: `group_reporter_admin` ("Helpdesk Reporter Admin").
|
||||
- Model `fusion.helpdesk.ticket.seen`: per-user read tracking for the badge.
|
||||
- `res.config.settings`: (existing) — no new config required beyond what exists.
|
||||
|
||||
**`fusion_helpdesk_central` (central) — small additions**
|
||||
- `helpdesk.ticket` inherit: `x_fc_client_label` field + backend list/search exposure.
|
||||
- `mail.template`: branded acknowledgement on ticket create (with the magic-link CTA).
|
||||
- Data/doc: confirm the "Customer Care" team portal config (already correct on live — assert via comment or
|
||||
light data, don't fight existing config).
|
||||
|
||||
---
|
||||
|
||||
## 6. Surface A — In-app embedded inbox (detail)
|
||||
|
||||
### 6.1 Controller endpoints
|
||||
|
||||
All `type='jsonrpc'`, `auth='user'`. **Identity is always derived server-side from `request.env.user`** —
|
||||
never from request parameters. All remote calls go through the existing bot XML-RPC layer.
|
||||
|
||||
| Route | Returns | Notes |
|
||||
|---|---|---|
|
||||
| `POST /fusion_helpdesk/submit` *(modified)* | `{ok, ticket_id, ticket_url}` | Adds `x_fc_client_label` + `partner_name`; the confirmed form email is sent as `partner_email` (param may be named `reply_email`, but it maps straight to `partner_email`). |
|
||||
| `/fusion_helpdesk/my_tickets` | `[{id, ref, subject, stage, last_update, has_unread}]` | Scoped (§8). Reuses one remote `search_read`. |
|
||||
| `/fusion_helpdesk/ticket/<int:ticket_id>` | `{id, subject, stage, messages:[…], can_reply}` | **Public comments only** — internal notes excluded (§8). Re-checks scope. |
|
||||
| `/fusion_helpdesk/ticket/<int:ticket_id>/reply` | `{ok}` | Re-checks scope; posts `message_post` with `author_id` = replier's partner. |
|
||||
| `/fusion_helpdesk/unread_count` | `{count}` | For the systray badge (§7). |
|
||||
|
||||
### 6.2 Dialog UX
|
||||
|
||||
- The existing dialog gains two tabs:
|
||||
- **New** — today's form, plus a confirmed/editable **"Your email"** field (prefilled from the logged-in
|
||||
user; used as `reply_email`).
|
||||
- **My Tickets** — list of the user's tickets (ref, subject, stage chip, last-update, unread dot). Admins
|
||||
(in `group_reporter_admin`) see a **"Mine / All [LABEL]"** toggle.
|
||||
- Clicking a ticket opens a **thread view**: customer-visible messages (author, timestamp, body,
|
||||
attachments) + a **reply box** (text + attach) + a "Done"/back control. Opening a ticket marks it seen.
|
||||
|
||||
### 6.3 Reply attribution
|
||||
|
||||
- Replies post to central as `message_type='comment'`, `subtype_xmlid='mail.mt_comment'`, with `author_id`
|
||||
= the **replying user's** partner on central (resolved find-or-create by their email). For a user replying
|
||||
to their own ticket that's the ticket's customer; for an admin replying to a colleague's ticket it's the
|
||||
admin's own identity (correct attribution).
|
||||
- A customer reply notifies the assigned agent + followers (native), closing the two-way loop.
|
||||
|
||||
### 6.4 Read tracking & admin group
|
||||
|
||||
- Model `fusion.helpdesk.ticket.seen` (client DB): `user_id` (m2o `res.users`), `central_ticket_id`
|
||||
(Integer), `last_seen_message_id` (Integer) — unique `(user_id, central_ticket_id)`. This is
|
||||
read-tracking **metadata only** (no ticket content is stored) — it preserves the live-RPC-view principle
|
||||
while letting the badge work without re-fetching on every page load.
|
||||
- `group_reporter_admin` — an Odoo group on the client deployment. Membership unlocks the "All [LABEL]"
|
||||
query path **server-side** (the controller checks `has_group` before broadening scope).
|
||||
|
||||
---
|
||||
|
||||
## 7. Notifications & emails
|
||||
|
||||
- **Agent → customer:** customer is a follower → **native email** with a "View Ticket" magic link
|
||||
(portal.mixin `access_url` + token). Satisfies "they get replies in their email." In-app users also see
|
||||
the reply in My Tickets and the badge increments.
|
||||
- **Acknowledgement on create:** branded `mail.template` sent to the customer with the magic-link CTA so they
|
||||
can track immediately. Fires for any ticket on the portal-enabled team that has a `partner_email`,
|
||||
regardless of channel (in-app, web, email). Per Odoo 19, the template renders the link from the record
|
||||
(`object.access_url` / portal URL); no need to pass it via `ctx` (CLAUDE rule 12). **Implementation note:**
|
||||
verify `website_helpdesk` does not already send its own "ticket received" confirmation for web-form
|
||||
submissions — if it does, gate ours so external customers don't get two acknowledgements.
|
||||
- **Unread badge:** `unread_count` = number of the user's in-scope tickets whose latest customer-visible
|
||||
**support** message id is greater than the local `last_seen_message_id`. Cleared per-ticket on open.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security & scoping (the sharp edge)
|
||||
|
||||
The shared bot can read **every** client's tickets on central, so the client-side controller is the
|
||||
security boundary.
|
||||
|
||||
- Endpoints are `auth='user'`; identity is taken from `request.env.user`, never from the browser.
|
||||
- Scoped domain, built server-side:
|
||||
- regular user → `[('x_fc_client_label','=',label), ('partner_email','=ilike', me.email or me.login)]`
|
||||
- admin (`group_reporter_admin`) → `[('x_fc_client_label','=',label)]`
|
||||
- **`x_fc_client_label = <my deployment>` is ALWAYS ANDed in** (defense in depth) so no user — regular or
|
||||
admin — can ever read another deployment's tickets, even if two deployments share a reporter email.
|
||||
- `ticket/<id>` and `…/reply` **re-resolve the ticket through the same scoped domain** before reading or
|
||||
posting; a ticket outside scope returns not-found.
|
||||
- Thread fetch returns **only customer-visible messages** (exclude internal notes — `subtype_id.internal =
|
||||
True`), mirroring what the portal shows. Internal agent discussion never reaches a client.
|
||||
- Reuse the module's existing granular remote-error handling for auth/network failures.
|
||||
|
||||
---
|
||||
|
||||
## 9. Data flow
|
||||
|
||||
```
|
||||
SUBMIT (in-app)
|
||||
staff clicks icon → New tab → confirm email → submit
|
||||
client controller adds partner_email + partner_name + x_fc_client_label
|
||||
→ XML-RPC create on central (as bot)
|
||||
→ helpdesk find-or-creates partner_id + subscribes follower
|
||||
→ branded acknowledgement email w/ magic link
|
||||
|
||||
AGENT REPLY (Nexa support)
|
||||
reply as a comment in the ticket chatter on central
|
||||
→ native email to customer w/ "View Ticket" magic link
|
||||
→ in-app users also see it in My Tickets; badge increments
|
||||
|
||||
CUSTOMER FOLLOW-UP (any of three, same thread)
|
||||
in-app dialog reply → RPC message_post (author = replier's partner)
|
||||
portal magic link → native reply on /my/ticket/<id>/<token>
|
||||
email reply → native email-in via support@nexasystems.ca
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Edge cases
|
||||
|
||||
- **Missing/invalid reporter email** — New form prefills + lets the user confirm/edit. If still empty, the
|
||||
ticket is created without a customer (degrades to today's behaviour) and the dialog flags "no follow-up
|
||||
email captured."
|
||||
- **Same email across deployments** — partner is shared (their portal shows all their tickets), but the
|
||||
in-app inbox still scopes by `x_fc_client_label`, so each deployment shows only its own.
|
||||
- **Admin replies to a colleague's ticket** — author = the admin's own partner, not the ticket customer.
|
||||
- **Existing 51 orphan tickets** — left as-is (no reliable identity to backfill).
|
||||
- **Bot key revoked/rotated** (managed by `fusion_helpdesk_central`) — endpoints fail gracefully via the
|
||||
existing typed remote-error responses.
|
||||
- **Internal notes** — never returned to the client (subtype filter).
|
||||
|
||||
---
|
||||
|
||||
## 11. Testing strategy
|
||||
|
||||
- **`fusion_helpdesk_central`** (Enterprise; runs on an Enterprise env such as odoo-trial, like the billing
|
||||
module — local dev is Community and can't install `helpdesk`):
|
||||
- `x_fc_client_label` field exists + is searchable.
|
||||
- Integration: `helpdesk.ticket.create({partner_email, partner_name, x_fc_client_label})` resolves
|
||||
`partner_id` and adds the partner as a follower.
|
||||
- Acknowledgement template renders the magic link from the record.
|
||||
- **`fusion_helpdesk`** (client; XML-RPC layer **mocked** — no live central in unit tests):
|
||||
- Scoping: regular vs admin domain construction; `x_fc_client_label` always ANDed.
|
||||
- `…/reply` rejects a ticket outside the caller's scope.
|
||||
- Thread fetch excludes internal notes.
|
||||
- `unread_count` math against `fusion.helpdesk.ticket.seen`.
|
||||
- Refactor the remote proxy so it is injectable/mockable.
|
||||
- **Manual QA on `odoo-nexa`**: full round-trip — submit → agent reply → email + badge → in-app reply →
|
||||
portal magic link → external sign-up shows `/my/tickets`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Out of scope / future
|
||||
|
||||
- Custom portal theme, branded custom web form, KB deflection, SLA/status timeline (Tier C).
|
||||
- Backfilling identity on historical tickets.
|
||||
- Push/websocket live updates in the dialog (polling/refresh is sufficient for v1).
|
||||
|
||||
---
|
||||
|
||||
## 13. References
|
||||
|
||||
**Current code (this repo)**
|
||||
- `fusion_helpdesk/controllers/main.py` — `submit()`, `_read_config()`, `_authenticate()`,
|
||||
`_build_diag_block()` (XML-RPC forwarder; today sends only `{name, description, team_id}`).
|
||||
- `fusion_helpdesk/static/src/js/fusion_helpdesk_dialog.js` — OWL submission dialog.
|
||||
- `fusion_helpdesk/static/src/js/fusion_helpdesk_systray.js` — systray entry (badge target).
|
||||
- `fusion_helpdesk/models/res_config_settings.py` — remote endpoint config params.
|
||||
- `fusion_helpdesk_central/models/fusion_helpdesk_client_key.py` — bot user + API-key management.
|
||||
|
||||
**Live system facts (verified 2026-05-27 on `nexamain`)**
|
||||
- Modules installed: `helpdesk` 19.0.1.6, `website_helpdesk`, `website_helpdesk_knowledge`,
|
||||
`helpdesk_account`, `helpdesk_sale`, `portal`, `website`, `auth_signup`.
|
||||
- `auth_signup.invitation_scope=b2c`; `web.base.url=https://erp.nexasystems.ca`;
|
||||
`mail.catchall.domain=nexasystems.ca`; 4 SMTP servers.
|
||||
- Team 1 "Customer Care": `privacy_visibility=portal`, `use_website_helpdesk_form=t`,
|
||||
`allow_portal_ticket_closing=t`, `use_alias=t`, alias `support`.
|
||||
- 51/51 tickets have NULL `partner_id`/`partner_email`/`partner_name`.
|
||||
|
||||
**Enterprise source (read-only, on container)**
|
||||
- `helpdesk/models/helpdesk_ticket.py` — `_inherit` (portal.mixin, mail.thread.cc, rating.mixin);
|
||||
`access_url='/my/ticket/<id>'`; `create()` partner find-or-create (≈L564–572) + follower subscription
|
||||
(≈L600–620).
|
||||
- `helpdesk/controllers/portal.py` — `/my/tickets`, `/my/ticket/<id>/<access_token>`,
|
||||
`/my/ticket/close/<id>/<token>`.
|
||||
- `website_helpdesk/controllers/main.py` — `/helpdesk/<team>` public web form.
|
||||
|
||||
**Odoo 19 gotchas to respect (from repo CLAUDE.md)**
|
||||
- `res.users` group field is `group_ids` (not `groups_id`).
|
||||
- `message_post(body=…)` HTML must be wrapped in `Markup()`.
|
||||
- `mail.template` `ctx` is `env.context`; pass dynamic data via `with_context(**data)`.
|
||||
- `res.config.settings` Boolean via `config_parameter` doesn't persist `False`.
|
||||
- SQL constraints/indexes use declarative `models.Constraint` / `models.Index`.
|
||||
@@ -0,0 +1,271 @@
|
||||
# fusion_centralize_billing — Centralized Billing Engine on Odoo 19
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Status:** Design approved — pending written-spec review
|
||||
- **Author:** Design session (Claude + Gurpreet)
|
||||
- **Module:** `fusion_centralize_billing` (target: `K:\Github\Odoo-Modules\fusion_centralize_billing`)
|
||||
- **Host:** odoo-nexa (Proxmox VM 315, worker1), Odoo 19 **Enterprise**, live DB `nexamain`
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Make the Odoo Enterprise instance (`odoo-nexa`) the single billing brain for every
|
||||
NexaSystems service — hosting (NexaCloud), live chat (NexaDesk/Fusion-Chat), the
|
||||
metered maps API (NexaMaps), plus custom-app retainers, memberships, and one-off
|
||||
services. It replaces Lago in the role Lago currently plays, and absorbs NexaCloud's
|
||||
home-grown Stripe billing, so there is one customer ledger, one accounting system,
|
||||
one place revenue is recognized.
|
||||
|
||||
## 2. Current state (recon, 2026-05-27)
|
||||
|
||||
Billing is fragmented across **three+ independent engines**:
|
||||
|
||||
| System | Bills for | Engine today | Data home |
|
||||
|---|---|---|---|
|
||||
| **NexaCloud** (LXC 102, `10.200.0.250`) | VPS/LXC hosting, Coolify apps, CPU-seconds + throttle-removal fees, snapshots, domains | Own Postgres models + **direct Stripe** (`stripe_service.py`, `billing_service.py`, `usage_metering.py`, `invoice_generator.py`) | `nexacloud` DB (LXC 201) |
|
||||
| **NexaDesk / Fusion-Chat** (VM 314) | Chat plans (monthly/annual), feature + channel add-ons, message/token overage, token wallets | **Lago** v1.44.0 (VM 318) + Stripe (provider code `nexadesk`) | Lago (VM 318, `192.168.1.117`) |
|
||||
| **NexaMaps** (`fusionapps.maps_*`) | Metered geocoding/routing API: monthly quota + overage per 1k | Own tables; **~189k usage events / month** for 2 clients | Supabase `fusionapps` |
|
||||
| Services / memberships | Custom apps, consulting, retainers | ad-hoc / manual | — |
|
||||
|
||||
**Decisive fact:** `odoo-nexa` is **Odoo 19 Enterprise** and already runs the full
|
||||
Lago-equivalent stack: `sale_subscription` (+ `_stock`, `_timesheet`,
|
||||
`_external_tax`), `account_accountant`, `payment_stripe`, `website_sale` +
|
||||
`website_sale_subscription`, `crm/project/industry_fsm_sale_subscription`, plus
|
||||
custom `nexa_coa_setup`, `fusion_whitelabels`, `fusion_helpdesk_central`,
|
||||
`fusion_pdf_preview`. So Odoo already does subscriptions, recurring invoicing, full
|
||||
accounting/GL, Stripe, HST taxes, customer portal, credit notes, and self-serve
|
||||
checkout.
|
||||
|
||||
**The only capability Lago has that Odoo lacks natively is usage-based metered
|
||||
billing** (billable metrics → aggregation → quota/overage charges). That, plus the
|
||||
integration surface, is all we build.
|
||||
|
||||
Prior decision on record (Supabase `fusionapps.decisions`): Lago was deployed as the
|
||||
centralizer for NexaDesk + NexaCloud. This design **supersedes** that — the billing
|
||||
brain moves into the Odoo Enterprise already owned and operated.
|
||||
|
||||
## 3. Decisions locked in this session
|
||||
|
||||
1. **Odoo fully replaces Lago.** Build a metered-billing engine inside `fusion_centralize_billing`; decommission Lago VM 318 at the end.
|
||||
2. **One unified customer, separate invoice per service.** One `res.partner` per real client; each service bills on its own subscription/cycle. No cross-product invoice merging.
|
||||
3. **Apps drive; Odoo is the billing system of record.** Each app keeps its own signup, provisioning, and entitlement enforcement, and calls Odoo's billing API (the same way it calls Lago today). Odoo invoices, charges Stripe, and emits webhooks back.
|
||||
4. **Odoo owns the billing catalog; apps own entitlements.** Odoo is SoR for products, prices, recurrence, metric rate/quota/overage, taxes — keyed by a stable `plan_code`. Apps enforce feature limits (max_chatbots, CPU quota, API rate-limit) against the same code.
|
||||
5. **Pilot = NexaCloud, phased dual-run cutover** (one product at a time, parallel run + reconciliation before flip).
|
||||
6. **Aggregate-push usage ingestion.** Apps push periodic pre-aggregated counters; Odoo stores rollups and feeds native `sale.subscription` metered lines. No raw-event firehose into Odoo.
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
```
|
||||
NexaCloud NexaDesk NexaMaps (apps keep signup + provisioning + entitlements)
|
||||
│ │ │
|
||||
│ customers / subscriptions / usage counters (inbound REST, API-key bearer auth)
|
||||
▼ ▼ ▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ fusion_centralize_billing (custom Odoo 19 module) │
|
||||
│ • Service registry (one row per app) │
|
||||
│ • Identity links (ext acct → res.partner) │
|
||||
│ • Metric + Charge catalog (quota/overage) │
|
||||
│ • Usage engine (ingest → aggregate → bill) │
|
||||
│ • Outbound webhook queue (HMAC + retry) │
|
||||
└───────────────┬────────────────────────────────┘
|
||||
│ writes billable qty onto
|
||||
▼
|
||||
sale.order(is_subscription) → account.move → payment_stripe (NATIVE Odoo Enterprise)
|
||||
│ invoicing, HST tax, proration,
|
||||
│ invoice paid / failed / sub ended dunning, portal, credit notes
|
||||
▼
|
||||
outbound webhooks ──► apps suspend / restore / deprovision
|
||||
```
|
||||
|
||||
Principle: **build only the metering + integration layer; inherit all financial
|
||||
behaviour from native Odoo Enterprise.**
|
||||
|
||||
## 5. Data model
|
||||
|
||||
### 5.1 New models (`fusion.billing.*`)
|
||||
|
||||
| Model | Key fields | Purpose |
|
||||
|---|---|---|
|
||||
| `fusion.billing.service` | `name`, `code` (nexacloud/nexadesk/nexamaps), `api_key_hash`, `webhook_url`, `webhook_secret`, `active` | One row per source app — the auth + routing boundary. |
|
||||
| `fusion.billing.account.link` | `service_id`, `external_id`, `partner_id`, `external_email`; unique `(service_id, external_id)` | Identity resolution: folds each app's account into one `res.partner`. |
|
||||
| `fusion.billing.metric` | `code`, `name`, `aggregation` (sum/max/last/unique_count), `unit_label`, `rounding` | Billable metric definition. |
|
||||
| `fusion.billing.charge` | `plan_ref`/`product_id`, `metric_id`, `included_quota`, `price_per_unit`, `unit_batch` (e.g. per 1000), `charge_model` (standard/graduated/package/volume) | Maps a plan + metric → quota & overage pricing. Where "5M quota / $0.10 per 1k" lives. |
|
||||
| `fusion.billing.usage` | `subscription_id`, `metric_id`, `period_start`, `period_end`, `quantity`, `source`, `idempotency_key`; index `(subscription, metric, period)` | **Aggregated** usage rows (rollups, not raw events). |
|
||||
| `fusion.billing.webhook` | `service_id`, `event_type`, `payload` (JSON), `state` (pending/sent/failed/dead), `attempts`, `next_retry_at`, `signature` | Outbound event queue, processed by cron with backoff + HMAC. |
|
||||
| `fusion.billing.reconciliation` | `service_id`, `partner_id`, `period`, `odoo_amount`, `external_amount`, `delta`, `status` | Dual-run shadow-mode comparison (Odoo-computed vs app-actual). |
|
||||
|
||||
### 5.2 Native models reused as-is
|
||||
|
||||
`res.partner` (customer), **`sale.order` with `is_subscription=True`** (the subscription),
|
||||
`sale.subscription.plan` (recurrence/plan), `sale.order.line` (metered lines),
|
||||
`account.move` (invoice + credit note), `payment_stripe`/`payment.transaction` (Stripe),
|
||||
`account.tax` (HST per province), customer portal. Catalog = `product.template` +
|
||||
`sale.subscription.plan`, tagged with the shared `plan_code`.
|
||||
|
||||
New fields on native models use the `x_fc_*` prefix (e.g. `res.partner.x_fc_billing_external_ids`).
|
||||
|
||||
> **Odoo 19 modeling note (verified on live `nexamain`, 2026-05-27):** there is **no
|
||||
> `sale.subscription` model**. A subscription IS a `sale.order` with `is_subscription=True`,
|
||||
> `plan_id` → `sale.subscription.plan`, plus `subscription_state` / `next_invoice_date` /
|
||||
> `recurring_monthly`. Every "subscription" reference in this spec means that. The usage
|
||||
> engine links `fusion.billing.usage.subscription_id` → `sale.order`.
|
||||
|
||||
### 5.3 Relationship to `fusion_api` (reuse, don't duplicate)
|
||||
|
||||
The existing **`fusion_api`** module (`fusion.api.key` / `.consumer` / `.service` /
|
||||
`.usage` / `.usage.daily`) centralizes **outbound** provider keys (OpenAI, Anthropic,
|
||||
Google Maps, Twilio) with cost/usage tracking + rate limiting — i.e. what **Nexa pays
|
||||
providers** (COGS). It is **complementary**, not a substitute:
|
||||
`fusion_centralize_billing` tracks what **customers owe Nexa**. Two concrete ties:
|
||||
(a) feed `fusion.api.usage.daily` cost into margin reporting against billed revenue;
|
||||
(b) mirror its daily-rollup aggregation pattern for `fusion.billing.usage`. The
|
||||
customer-facing metered billing and the inbound API remain ours to build.
|
||||
|
||||
## 6. Usage engine (aggregate-push)
|
||||
|
||||
1. Apps `POST /usage` with periodic counters and an `idempotency_key`
|
||||
(e.g. `service:metric:subscription:window`). NexaCloud pushes CPU-seconds per
|
||||
deployment hourly; NexaMaps pushes api_calls per client daily; NexaDesk pushes
|
||||
messages/tokens. Upsert into `fusion.billing.usage` keyed by `idempotency_key` so
|
||||
retries never double-bill.
|
||||
2. A **pre-invoice cron** (runs ahead of each subscription's invoice date) sums the
|
||||
period's `fusion.billing.usage` per metric, applies the matching
|
||||
`fusion.billing.charge` (quota → free, overage → priced by `charge_model`), and
|
||||
writes the billable quantity/amount onto the subscription's draft invoice line
|
||||
(usage product).
|
||||
3. Native subscription invoicing issues the invoice, applies HST, and charges Stripe.
|
||||
Quota resets per period.
|
||||
|
||||
At ~189k Maps events/month pushed as daily counters, Odoo stores ≈30 rows per client
|
||||
per metric per month — trivial volume.
|
||||
|
||||
## 7. Inbound API (Lago-shaped, drop-in)
|
||||
|
||||
Base path `/api/billing/v1/*`. Odoo 19 routing: `type="http"`, `auth="none"`,
|
||||
`csrf=False`, manual **Bearer** API-key check against `fusion.billing.service`
|
||||
(hashed), JSON request/response via `request.make_json_response`, per-service rate
|
||||
limiting. (`type="jsonrpc"` is for Odoo session RPC — not used here, because external
|
||||
apps authenticate with bearer tokens, not Odoo sessions.)
|
||||
|
||||
Endpoints intentionally mirror `Fusion-Chat/src/lib/billing/lago-client.ts` so the
|
||||
NexaDesk swap is ≈ one file, and NexaCloud's integration is a thin client:
|
||||
|
||||
| Method · Path | Maps to |
|
||||
|---|---|
|
||||
| `POST /customers` | upsert `res.partner` + `account.link` (identity resolution) |
|
||||
| `POST /subscriptions` · `PUT /subscriptions/:id` · `DELETE /subscriptions/:id` | create / change-upgrade / cancel subscription `sale.order` |
|
||||
| `POST /usage` | batch aggregated counters (hot path → 202 Accepted) |
|
||||
| `POST /invoices` | one-off invoice (token packs, throttle-removal fee) |
|
||||
| `GET /invoices` · `GET /invoices/:id` · `POST /invoices/:id/download` | list / fetch / PDF |
|
||||
| `POST /invoices/:id/retry_payment` · `POST /invoices/:id/void` | payment retry / void |
|
||||
| `POST /credit_notes` | refund via `account.move` reversal |
|
||||
| `GET /plans` · `GET /catalog` | apps fetch pricing (as NexaDesk fetches from Lago) |
|
||||
| `GET /customers/:id/checkout_url` | Stripe payment-method setup |
|
||||
|
||||
## 8. Outbound webhooks (control loop)
|
||||
|
||||
Odoo → app, HMAC-SHA256 signed, retried with exponential backoff, dead-lettered after
|
||||
N attempts (reuse the proven pattern in `Fusion-Chat/src/lib/billing/lago-payment-retry-job.ts`):
|
||||
|
||||
| Event | App reaction |
|
||||
|---|---|
|
||||
| `invoice.payment_failed` (after dunning) | **suspend** — NexaCloud throttle/network-isolate; NexaDesk suspend tenant; NexaMaps disable API key |
|
||||
| `invoice.payment_succeeded` / `subscription.reactivated` | **restore** service |
|
||||
| `subscription.terminated` | **deprovision** |
|
||||
| `usage.threshold_reached` (80% / 100%, optional) | warn / cap |
|
||||
|
||||
## 9. NexaCloud pilot
|
||||
|
||||
- **Identity & catalog mapping:** `nexacloud.users` → `res.partner` via `account.link`;
|
||||
`nexacloud.products`/`plans` → `product.template` + subscription plans
|
||||
(`plan_code` = NexaCloud plan id/slug, prices from `price_monthly`/`price_yearly`);
|
||||
`nexacloud.deployments` + `subscriptions` → one subscription `sale.order` per deployment
|
||||
(NexaCloud bills per deployment).
|
||||
- **Metering:** CPU-seconds → `fusion.billing.metric` `cpu_seconds` (sum) + `charge`
|
||||
(included = plan quota, overage priced). Throttle-removal fee → one-off invoice
|
||||
(or add-on product). `nexacloud/.../usage_metering.py` pushes counters to `/usage`.
|
||||
- **Control loop:** `invoice.payment_failed` → NexaCloud suspends using its existing
|
||||
`network_isolation` / `throttle_checker` / `resource_manager`; `subscription.terminated`
|
||||
→ NexaCloud deprovisions.
|
||||
|
||||
## 10. Dual-run + migration (phased)
|
||||
|
||||
1. **Import** NexaCloud customers + active subscriptions into Odoo (script reads the
|
||||
`nexacloud` DB → creates partners / links / subscriptions / charges).
|
||||
2. **Shadow mode ≥ 1 billing cycle:** Odoo computes invoices while NexaCloud keeps
|
||||
charging via its own Stripe. `fusion.billing.reconciliation` diffs Odoo-computed vs
|
||||
NexaCloud-actual per customer/period; investigate every delta.
|
||||
3. **Flip** when deltas are within tolerance: NexaCloud calls Odoo's API as SoR and
|
||||
stops its internal Stripe billing. Past invoices stay archived (PDF / opening
|
||||
balances) — not re-issued.
|
||||
4. **Repeat** for NexaDesk (retire Lago for chat) → NexaMaps → then decommission
|
||||
Lago VM 318.
|
||||
|
||||
## 11. Risks & open items
|
||||
|
||||
- **🟢 Stripe account unification — RESOLVED (2026-05-27).** All systems share ONE Stripe
|
||||
account: **`acct_1ShlA9IkwUB1dVox`** (Nexa Systems Inc, CA, live). Verified live:
|
||||
NexaCloud's direct `sk_live` key resolves to that account, and Lago has three Stripe
|
||||
providers (`nexasystems`, `nexadesk`, `nexamaps`) that **all** resolve to the same
|
||||
account. Therefore **no Stripe account migration is needed** — Odoo's `payment_stripe`
|
||||
connects to that single account and **reuses existing Stripe customers + saved payment
|
||||
methods** (map each Stripe `provider_customer_id` → `res.partner`). This removes what
|
||||
was the biggest migration risk.
|
||||
- **Idempotency** on usage counters is mandatory (dedupe key) to prevent double billing on retries.
|
||||
- **Entitlement sync SLA:** on plan change, Odoo webhook informs the app; define how
|
||||
fast app-side limits must update (and the reconciliation if a webhook is missed).
|
||||
- **Odoo 19 correctness:** implementation MUST read live reference files from the
|
||||
container (`docker exec odoo-nexa-app cat …`) before coding subscription/API/account
|
||||
internals — never from memory (per `K:\Github\CLAUDE.md`).
|
||||
- **Tax:** HST/GST per Canadian province via `account.tax`; confirm tax codes align
|
||||
with current Lago `hst_on` usage.
|
||||
- **Auth hardening:** API keys hashed at rest, per-service scoping, rate limiting,
|
||||
request audit log; webhook secrets rotated.
|
||||
|
||||
## 12. Phasing — spec sequence
|
||||
|
||||
Each is its own spec → plan → build cycle:
|
||||
|
||||
1. **`fusion_centralize_billing` core** — service registry, identity links, metric/charge catalog,
|
||||
usage engine, inbound API, outbound webhook engine. *(detailed below — first deliverable)*
|
||||
2. **NexaCloud adapter + dual-run reconciliation** *(the pilot — coupled to #1)*
|
||||
3. NexaDesk adapter (swap the Lago client for the Odoo billing client)
|
||||
4. NexaMaps adapter
|
||||
5. Lago decommission + memberships/services onboarding + portal polish
|
||||
|
||||
## 13. First-deliverable scope (sub-projects #1 + #2)
|
||||
|
||||
**In scope**
|
||||
- `fusion_centralize_billing` module skeleton (manifest, security/ACLs + record rules, README) following the `nexa_coa_setup` layout.
|
||||
- Models in §5.1; new native fields use `x_fc_*`.
|
||||
- Aggregate-push usage engine (§6) incl. pre-invoice cron + idempotent upsert.
|
||||
- Inbound API (§7) with bearer auth, and outbound webhook engine (§8).
|
||||
- NexaCloud mapping + importer + shadow-mode reconciliation (§9, §10).
|
||||
- Manifest `depends`: `sale_subscription`, `account_accountant`, `payment_stripe`,
|
||||
`sale_management` (+ `nexa_coa_setup` if COA dependencies apply).
|
||||
|
||||
**Out of scope (YAGNI for now)**
|
||||
- NexaDesk / NexaMaps adapters (specs #3/#4).
|
||||
- Raw-event ingestion / per-event audit in Odoo (apps retain raw events).
|
||||
- Lago decommission (spec #5) — Lago stays running until NexaDesk is migrated.
|
||||
- Customer-portal redesign — use native portal as-is initially.
|
||||
|
||||
## 14. Success criteria (first deliverable)
|
||||
|
||||
- A NexaCloud deployment can be created as an Odoo subscription `sale.order` via the API,
|
||||
with one `res.partner` resolving the NexaCloud user.
|
||||
- CPU-seconds counters pushed to `/usage` aggregate correctly and produce a draft
|
||||
invoice with quota + overage applied, taxed (HST), and charged through `payment_stripe`.
|
||||
- A simulated `invoice.payment_failed` delivers a signed webhook NexaCloud can act on.
|
||||
- Shadow-mode reconciliation report shows Odoo-computed vs NexaCloud-actual within
|
||||
tolerance for ≥ 1 cycle before any flip.
|
||||
- No double billing under usage-counter retries (idempotency verified).
|
||||
|
||||
## 15. Open questions for review
|
||||
|
||||
1. ~~Stripe: one account across all products, or separate?~~ **ANSWERED (2026-05-27):** one
|
||||
account `acct_1ShlA9IkwUB1dVox` for everything (NexaCloud direct + Lago's
|
||||
`nexasystems`/`nexadesk`/`nexamaps` providers). No account migration; reuse existing
|
||||
Stripe customers + payment methods.
|
||||
2. NexaCloud billing granularity — confirm **one subscription per deployment** (vs one per customer with deployment line items).
|
||||
3. Membership model — Odoo native `membership` module, or model memberships as plain recurring subscriptions?
|
||||
4. Spec/module commit target — confirm branch strategy in `Odoo-Modules` (currently on `feat/fusion-login-audit`).
|
||||
@@ -0,0 +1,212 @@
|
||||
# Sub-project #2a — NexaCloud → Odoo Billing Importer (Design)
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Status:** Design approved (brainstorming session) — implementation in progress
|
||||
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise, host odoo-nexa / tested on odoo-trial)
|
||||
- **Parent:** Sub-project #2 (NexaCloud adapter + dual-run reconciliation). This spec covers **chunk 2a only** — the read-only importer/backfill. 2b (usage wiring), 2c (control loop), 2d (reconciliation) are separate specs.
|
||||
- **Depends on:** the core engine (sub-project #1, on `main` at `d770c0c3`): service registry, `_resolve_or_create_partner`, `fusion.billing.charge._compute_billable`, `fusion.billing.usage`, the inbound API, the webhook engine.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Backfill the **existing** NexaCloud customers, plans, and deployments into Odoo so the
|
||||
central billing engine has a complete shadow copy to run dual-run reconciliation (2d)
|
||||
against. The importer is a **one-time, re-runnable** migration — *not* a continuous sync.
|
||||
New NexaCloud signups after the cutover already flow through the live inbound API built in
|
||||
sub-project #1.
|
||||
|
||||
The importer must be **safe by construction**: while NexaCloud is still the live biller,
|
||||
nothing the importer creates in Odoo may charge, post, or email a customer.
|
||||
|
||||
## 2. Decisions locked in brainstorming (2026-05-27)
|
||||
|
||||
1. **Per-deployment granularity.** NexaCloud's own `subscriptions` table carries
|
||||
`deployment_id` + `plan_id`, so the natural mapping is **one Odoo subscription
|
||||
`sale.order` per deployment**. (Confirms spec §15 Q2.)
|
||||
2. **Billing model = flat plan price + metered overage.** Customers pay a fixed
|
||||
monthly/yearly plan price PLUS per-unit charges for usage above the plan's quota.
|
||||
(Confirms the original §6 quota+overage assumption.)
|
||||
3. **CPU metric standardized to `cpu_seconds`.** The NexaCloud plan quota
|
||||
(`plans.cpu_seconds_quota`) is already in seconds, so it maps to `charge.included_quota`
|
||||
with no conversion. NexaCloud's CPU rate ($0.0075/core-hour) maps to
|
||||
`price_per_unit = 0.0075`, `unit_batch = 3600` (one core-hour = 3600 cpu-seconds).
|
||||
4. **CPU is the only metered-overage metric in v1.** It is the only resource with a plan
|
||||
quota. RAM / disk / bandwidth are treated as bundled in the flat plan price for now,
|
||||
addable later as more metrics if NexaCloud actually bills them as overage. (YAGNI.)
|
||||
5. **Importer = Odoo-side read-only reader** (Approach A). An Odoo wizard connects
|
||||
read-only to the `nexacloud` Postgres, reads its tables, and writes only into Odoo via
|
||||
the existing model methods. No NexaCloud code is touched.
|
||||
6. **Idempotent / re-runnable.** Every created entity is upserted on a stable key, so the
|
||||
importer can run each cycle during the dual-run and update rather than duplicate.
|
||||
|
||||
## 3. Source data (NexaCloud, read-only)
|
||||
|
||||
Confirmed by reading `/Users/gurpreet/Github/Nexa-Cloud/backend/app/models`. FastAPI +
|
||||
async SQLAlchemy on Postgres. Relevant tables/columns:
|
||||
|
||||
- **`users`** — `id` (UUID), `email`, `full_name`, `company`, `billing_email`,
|
||||
`billing_address`/`_city`/`_state`/`_postal_code`/`_country`, `tax_id`,
|
||||
`stripe_customer_id`.
|
||||
- **`plans`** — `id` (UUID), `product_id`, `name`, `price_monthly`, `price_yearly`,
|
||||
`stripe_price_id`, `cpu_seconds_quota` (BigInteger), `is_active`.
|
||||
- **`deployments`** — `id` (UUID), `user_id`, `product_id`, `plan_id`, `name`, `status`,
|
||||
`billing_cycle`, `next_due_date`.
|
||||
- **`subscriptions`** — `id` (UUID), `user_id`, `deployment_id`, `plan_id`, `status`
|
||||
(active/cancelled/past_due/trialing/paused), `billing_cycle` (monthly/yearly),
|
||||
`current_period_start`, `current_period_end`, `stripe_subscription_id`.
|
||||
|
||||
(The `usage_records`, `invoices`, `addons` tables are out of scope for 2a — usage wiring
|
||||
is 2b; reconciliation against NexaCloud invoice/usage totals is 2d.)
|
||||
|
||||
## 4. Data mapping
|
||||
|
||||
| NexaCloud (read) | Odoo (upsert) | Idempotency key |
|
||||
|---|---|---|
|
||||
| `users` | `res.partner` + `fusion.billing.account.link` (service=`nexacloud`, external_id=`user.id`) | `account.link (service_id, external_id)` (existing unique constraint) |
|
||||
| `plans` | one subscription `product.template` (flat price) + one CPU-overage `product.product` + one `fusion.billing.charge` | `charge.plan_code = plan.id` (UUID string) |
|
||||
| `subscriptions`/`deployments` | one **draft** `sale.order(is_subscription)` per deployment | `sale.order.x_fc_nexacloud_subscription_id` |
|
||||
| (constant) | `fusion.billing.metric` `cpu_seconds` | `metric.code` (existing unique) |
|
||||
| (constant) | `sale.subscription.plan` Monthly + Yearly recurrences | `(billing_period_value, billing_period_unit)` |
|
||||
|
||||
### 4.1 Identity (`users` → partner + link)
|
||||
|
||||
Reuse `account_link._resolve_or_create_partner(service, external_id, name, email, extra)`.
|
||||
- `external_id` = `str(user.id)`, `email` = `user.billing_email or user.email`,
|
||||
`name` = `user.full_name or user.company or email`.
|
||||
- `extra` carries billing address fields → `res.partner` (`street`, `city`, `country_id`
|
||||
resolved from the ISO/name, `vat` from `tax_id`).
|
||||
- Stash `user.stripe_customer_id` on `res.partner.x_fc_stripe_customer_id` so the eventual
|
||||
flip (not 2a) can reuse the existing Stripe customer instead of creating a new one.
|
||||
|
||||
### 4.2 Catalog (`plans` → product + charge)
|
||||
|
||||
For each active NexaCloud plan:
|
||||
- **Subscription product** (`product.template`, `type='service'`, `recurring_invoice=True`)
|
||||
named after the plan. `recurring_invoice=True` is what makes Odoo treat an order using
|
||||
it as a subscription (verified pattern from the core engine's `_api_create_subscription`).
|
||||
- **CPU-overage product** (`product.product`, `type='service'`) — the product the rating
|
||||
math attaches the overage amount to (`charge.product_id`).
|
||||
- **`fusion.billing.charge`**: `plan_code=str(plan.id)`, `metric_id=cpu_seconds`,
|
||||
`product_id=`overage product, `included_quota=plan.cpu_seconds_quota`,
|
||||
`price_per_unit=0.0075`, `unit_batch=3600`, `charge_model='standard'`, CAD.
|
||||
**`plan_id` is left NULL on purpose** (see §6) — the hourly auto-rating cron skips
|
||||
charges with no `plan_id`, so importing charges never auto-mutates shadow subscriptions.
|
||||
|
||||
### 4.3 Subscription (`deployment` → draft shadow sale.order)
|
||||
|
||||
For each deployment that has a NexaCloud subscription:
|
||||
- `partner_id` = the mapped partner.
|
||||
- `plan_id` = the Monthly or Yearly `sale.subscription.plan` per `subscription.billing_cycle`.
|
||||
- `order_line` = one line: the plan's subscription product, qty 1, **`price_unit` set
|
||||
explicitly** to `plan.price_monthly` or `plan.price_yearly` (matching the cycle). Setting
|
||||
the price explicitly makes Odoo's computed amount match NexaCloud's by construction —
|
||||
it does not depend on Odoo subscription-pricing internals or a pricelist.
|
||||
- `x_fc_nexacloud_subscription_id` = `str(subscription.id)` (upsert key),
|
||||
`x_fc_nexacloud_deployment_id` = `str(deployment.id)`,
|
||||
`x_fc_billing_service_id` = the nexacloud service, `x_fc_shadow = True`.
|
||||
- **Left in draft** (`action_confirm()` is NOT called). No payment token is attached.
|
||||
|
||||
## 5. Architecture / mechanism
|
||||
|
||||
A new transient model **`fusion.billing.import.wizard`** with one button, but the logic
|
||||
lives in two model methods so it is unit-testable headless (the core-engine pattern —
|
||||
logic in model methods, thin UI):
|
||||
|
||||
- **`_read_nexacloud_rows()`** — opens a **read-only `psycopg2`** connection using a DSN
|
||||
from `ir.config_parameter` (`fusion_billing.nexacloud_dsn`), runs `SELECT`s, and returns
|
||||
a plain dict: `{'users': [...], 'plans': [...], 'subscriptions': [...]}` (rows as dicts).
|
||||
This is the *only* code that touches NexaCloud, and it only reads.
|
||||
- **`_import_rows(data, dry_run=False)`** — pure Odoo writes. Consumes the dict, upserts in
|
||||
FK order (metric+recurrences → partners → catalog → subscriptions), returns a summary
|
||||
`{'created': {...}, 'updated': {...}, 'skipped': [...], 'failed': [...]}`. With
|
||||
`dry_run=True` it computes the summary inside a rolled-back savepoint and writes nothing.
|
||||
|
||||
`action_run_import()` on the wizard wires them: `self._import_rows(self._read_nexacloud_rows(), dry_run=self.dry_run)`.
|
||||
|
||||
## 6. Shadow-mode safety (the critical property)
|
||||
|
||||
While NexaCloud is the live biller, the importer must not produce any customer-visible
|
||||
billing in Odoo. Three independent guarantees, any one of which is sufficient:
|
||||
|
||||
1. **Subscriptions are imported in `draft`.** Odoo's native recurring-invoice cron only
|
||||
invoices confirmed (`3_progress`) subscriptions, so draft imports are never auto-invoiced,
|
||||
posted, or emailed.
|
||||
2. **No payment token is imported.** Even a posted invoice could not be auto-charged,
|
||||
because Odoo has no saved Stripe payment method for the partner. Charging is physically
|
||||
impossible.
|
||||
3. **Charges are imported with `plan_id = NULL`.** The hourly `_cron_rate_open_periods`
|
||||
skips charges without a `plan_id`, so importing the catalog never mutates any order line.
|
||||
|
||||
`x_fc_shadow=True` marks every imported subscription for later identification. The flip
|
||||
(out of scope here) is: set `charge.plan_id`, attach payment tokens, `action_confirm()`.
|
||||
|
||||
## 7. Error handling
|
||||
|
||||
- **Per-row `savepoint`** (`with self.env.cr.savepoint():`) around each entity write
|
||||
(CLAUDE rule #14 — no `cr.commit()` in tests). One malformed row (missing email, unknown
|
||||
plan, bad country) is recorded in `failed` with its reason and skipped; the batch
|
||||
continues.
|
||||
- Rows that reference an unresolved parent (subscription whose user/plan failed) are
|
||||
`skipped` with a reason, not failed.
|
||||
- `_read_nexacloud_rows()` raises a clear `UserError` if the DSN config param is missing or
|
||||
the connection fails — the wizard surfaces it; nothing is half-written (read happens
|
||||
before any write).
|
||||
|
||||
## 8. Testing
|
||||
|
||||
Split mirrors §5 so the Odoo logic is fully testable without a foreign DB:
|
||||
- **`_import_rows(data)` unit tests** (`TransactionCase`, run on odoo-trial Enterprise via
|
||||
`bash scripts/fcb_test_on_trial.sh`) with hand-built fixture dicts:
|
||||
- partners + links created; re-run updates, does not duplicate (idempotency).
|
||||
- catalog: `cpu_seconds` metric, product, and a `charge` with `included_quota` = quota,
|
||||
`unit_batch=3600`, `price_per_unit=0.0075`, **`plan_id` NULL**.
|
||||
- subscription: one **draft** `sale.order` per deployment, `is_subscription=True`,
|
||||
`price_unit` = the cycle's NexaCloud price, `x_fc_shadow=True`, no confirm.
|
||||
- shadow safety: imported subscription is `draft`/not `3_progress`; no `account.move`
|
||||
is created; partner has no payment token.
|
||||
- malformed rows land in `failed`/`skipped` without aborting the batch.
|
||||
- `dry_run=True` writes nothing (counts only).
|
||||
- The `psycopg2` read path is verified manually against the real `nexacloud` DB once
|
||||
access is granted (cannot be unit-tested against a foreign DB).
|
||||
|
||||
## 9. Prerequisite (flagged, not blocking the build)
|
||||
|
||||
Odoo on nexa (VM 315) needs network reachability + a **read-only credential** to the
|
||||
`nexacloud` Postgres (LXC 201), stored as `ir.config_parameter` `fusion_billing.nexacloud_dsn`.
|
||||
The build and all unit tests proceed with fixtures; only the live import run is blocked
|
||||
until this is granted.
|
||||
|
||||
## 10. Out of scope (YAGNI / later chunks)
|
||||
|
||||
- RAM / disk / bandwidth overage metrics (only if NexaCloud bills them — add as metrics).
|
||||
- The **flip** to live billing (confirm subs, attach tokens, set `charge.plan_id`).
|
||||
- Usage metering wiring (2b), control-loop webhooks (2c), reconciliation compute (2d).
|
||||
- Importing historical NexaCloud invoices / `usage_records` (2d reads NexaCloud actuals).
|
||||
- Add-ons (`deployment_addons`) as recurring lines — revisit if material.
|
||||
|
||||
> **Flip-day note (carry into 2b):** the inbound `/usage` API resolves a subscription by
|
||||
> its **Odoo integer id** (`int(subscription_external_id)`), but imported shadow subs are
|
||||
> keyed by NexaCloud's UUID in `x_fc_nexacloud_subscription_id`. Before NexaCloud can push
|
||||
> usage (2b), decide how it learns the Odoo id (return the mapping from the importer, or
|
||||
> extend the usage API to also resolve by `x_fc_nexacloud_subscription_id`). Not a 2a bug
|
||||
> (2a is read-only), but it must be resolved before the flip.
|
||||
|
||||
## 11. Verify at implementation (do NOT code from memory — CLAUDE rule #1)
|
||||
|
||||
Confirm on odoo-trial Enterprise before relying on them:
|
||||
- A **draft** `sale.order` with `plan_id` + a `recurring_invoice=True` product line reports
|
||||
`is_subscription=True` (so `fusion.billing.usage.subscription_id`'s domain accepts it).
|
||||
- `product.template.recurring_invoice` is the correct field name in this build.
|
||||
- `sale.subscription.plan` fields `billing_period_value` / `billing_period_unit` (used by
|
||||
the core tests) are the right find-or-create keys.
|
||||
- `res.partner` country resolution field (`country_id`) and `vat` for `tax_id`.
|
||||
|
||||
## 12. Success criteria
|
||||
|
||||
- Running `_import_rows(fixture)` produces, per the mapping in §4, partners+links, a
|
||||
`cpu_seconds`-based charge catalog (`plan_id` NULL), and one **draft** shadow subscription
|
||||
per deployment with the correct flat `price_unit` — and re-running it changes nothing
|
||||
(pure idempotency).
|
||||
- No `account.move` and no payment token exist for any imported partner after an import
|
||||
(shadow safety, asserted in tests).
|
||||
- Full suite green on odoo-trial (`FCB_EXIT=0`); no `_sql_constraints`, no bare
|
||||
`sale.subscription` model references.
|
||||
@@ -0,0 +1,158 @@
|
||||
# NexaCloud → Odoo Invoice Ledger (Design)
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Status:** Design approved (brainstorming) — pending written-spec review
|
||||
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise; build/test on odoo-trial, run on `nexamain`)
|
||||
- **Supersedes (for NexaCloud):** the metered-billing direction (recompute charges from a CPU-seconds model). The dual-run proved that model captures ~6% of reality.
|
||||
|
||||
## 1. Why this exists (the pivot)
|
||||
|
||||
The dual-run reconciliation (2026-05-27) showed **94% of NexaCloud's revenue is billed
|
||||
outside** the per-deployment/CPU-metered model the engine was built for:
|
||||
|
||||
| NexaCloud invoices | count | total |
|
||||
|---|---|---|
|
||||
| NOT linked to a `subscriptions` row (Hosting services, add-ons) | 22 | **$2,881.08** |
|
||||
| Linked to a `subscriptions` row (what the metered importer reads) | 7 | **$180.79** |
|
||||
|
||||
NexaCloud bills via **Stripe** — service invoices (Odoo ERP Hosting / WordPress Hosting
|
||||
~$214.50/mo), **add-ons** (Daily Backup, WhatsApp, Forms Builder, White Label), and
|
||||
**Stripe proration** ("Remaining time on …"). That billing already works. **Re-implementing
|
||||
Stripe's proration + add-on logic in Odoo is the wrong move.** Instead, Odoo **ingests
|
||||
NexaCloud's actual invoices** and becomes the single **accounting system of record**
|
||||
(posted invoices + reconciled payments + HST), while NexaCloud/Stripe keep doing the billing.
|
||||
|
||||
## 2. Goal & scope (locked in brainstorming)
|
||||
|
||||
- **Full accounting SoR:** posted `account.move` customer invoices, **Stripe payments
|
||||
reconciled** (invoices show paid, AR accurate), **HST** modelled.
|
||||
- **All history + ongoing.** Backfill every NexaCloud invoice, then a daily cron for new ones.
|
||||
- **Revenue split by service family** into distinct income accounts (P&L breakdown).
|
||||
- **Draft-first rollout:** first nexamain run creates drafts for review, then bulk-post.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
A new ingestion component in `fusion_centralize_billing`, mirroring the importer's
|
||||
read/write split (reuses the read-only DSN + the `account.link` partner mapping already
|
||||
set up on nexamain):
|
||||
|
||||
- **`_read_nexacloud_invoices(since=None)`** — read-only `psycopg2`: `invoices` +
|
||||
`invoice_items` (+ `users` for partner resolution), optionally since a date. Returns
|
||||
plain row dicts. The only code touching NexaCloud.
|
||||
- **`_ingest_invoices(data, post=False)`** — pure Odoo: for each NexaCloud invoice,
|
||||
upsert one `account.move` (`move_type='out_invoice'`) with lines, tax, and (if paid) a
|
||||
reconciled payment. Idempotent on `x_fc_nexacloud_invoice_id`. Returns a summary. With
|
||||
`post=False` invoices are left **draft**; a separate `_post_ingested(...)` bulk-posts
|
||||
after review.
|
||||
- Trigger: an **`account.move`-creation wizard/action** + a daily `ir.cron` for ongoing.
|
||||
|
||||
## 4. Data mapping
|
||||
|
||||
### 4.1 Invoice → `account.move`
|
||||
- `move_type='out_invoice'`, `partner_id` = unified `res.partner` (resolve `invoice.user_id`
|
||||
→ `account.link` (service=nexacloud) → partner; create via the importer's resolver if missing),
|
||||
`invoice_date` = NexaCloud invoice date, `ref` = `invoice_number`, `currency_id` = CAD.
|
||||
- New fields (x_fc_*) on `account.move`: `x_fc_nexacloud_invoice_id` (idempotency key, unique),
|
||||
`x_fc_stripe_invoice_id`.
|
||||
|
||||
### 4.2 `invoice_item` → `account.move.line` (one per item)
|
||||
- `name` = item description, `quantity`, `price_unit`, `account_id` = the **service-family
|
||||
income account** (see 4.3).
|
||||
- **Tax:** derive the invoice's effective rate from `invoice.tax / invoice.subtotal`; map to
|
||||
the matching Odoo `account.tax` — **HST 13%** when ≈13%, **no tax** when 0, else the closest
|
||||
configured tax. Odoo's computed tax must equal NexaCloud's `invoice.tax` (assert in tests).
|
||||
|
||||
### 4.3 Service-family → income account (keyword mapping, with fallback)
|
||||
| Family | Matches (description keywords) |
|
||||
|---|---|
|
||||
| **Hosting** | "Odoo ERP Hosting", "WordPress Website Hosting" |
|
||||
| **Managed plans** | "Managed", "Managed Odoo - Standard", "… - Managed" |
|
||||
| **Add-ons** | "Daily Backup Protection", "WhatsApp Business Messaging", "Forms Builder", "White Label Branding" |
|
||||
| **Proration** | "Remaining time on …" → resolve to the family of the named item |
|
||||
| **Other** (fallback) | anything unmatched → a generic NexaCloud income account (flagged in the summary for review) |
|
||||
|
||||
Income-account codes come from the COA (`nexa_coa_setup`); confirm/create at implementation.
|
||||
|
||||
### 4.4 Payment reconciliation
|
||||
- For invoices with `status='paid'` (or `amount_paid >= amount_due`): register an
|
||||
`account.payment` via a **"NexaCloud Stripe" bank journal**, dated `paid_at`, amount
|
||||
`amount_paid`, ref = `stripe_invoice_id`; reconcile it against the posted invoice so the
|
||||
invoice shows **paid** and AR clears.
|
||||
- Open/unpaid invoices: post (or draft) without a payment → they sit in AR. Void invoices:
|
||||
ingest as cancelled (or skip) — decide from the data at implementation.
|
||||
|
||||
## 5. Idempotency & ongoing sync
|
||||
- Upsert on `x_fc_nexacloud_invoice_id` (a DB-unique field on `account.move`). Re-running
|
||||
updates a still-draft move or skips a posted one (never duplicates, never silently mutates
|
||||
a posted ledger entry — posted invoices that changed upstream are reported for manual review).
|
||||
- Daily `ir.cron` calls `_read_nexacloud_invoices(since=last_run)` → `_ingest_invoices(post=True)`
|
||||
for go-forward invoices (configurable auto-post once trusted).
|
||||
|
||||
## 6. Safety & rollout (touches the live ledger)
|
||||
1. Build + **TDD on odoo-trial** (fixture invoices → assert move totals, tax = source tax,
|
||||
payment reconciled, idempotency, family→account mapping).
|
||||
2. **Dry-run** mode (read + report, write nothing) — like the importer.
|
||||
3. First **nexamain** run: ingest **all history as DRAFT**, report a summary (counts per
|
||||
family, total $, unmatched-"Other" lines, tax mismatches). **You review a sample.**
|
||||
4. **Bulk-post** after approval. Then enable the daily cron.
|
||||
5. **Prune the obsolete metered shadow data** first: delete the 87 draft shadow
|
||||
`sale.order`s (`x_fc_shadow=True`), the ~464 `NC-*` products, the NexaCloud charges, and
|
||||
the reconciliation rows — they belong to the superseded recompute approach and would
|
||||
confuse the ledger.
|
||||
|
||||
## 7. Out of scope
|
||||
- The metered recompute engine's go-live (flip, control loop, usage push) — superseded for
|
||||
NexaCloud. The engine code stays in the module (potential future metered service, e.g.
|
||||
NexaMaps) but is inert.
|
||||
- NexaDesk / NexaMaps ledgers — separate (same ingestion pattern when needed).
|
||||
- Reproducing Stripe's billing logic — explicitly NOT done; we ingest its output.
|
||||
|
||||
## 8. Verify at implementation (Odoo 19; never from memory)
|
||||
- `account.move` / `account.move.line` / `account.payment` field names + the post flow
|
||||
(`action_post`) and payment register/reconcile API (read `account` + `account_accountant`
|
||||
reference on odoo-trial).
|
||||
- The HST `account.tax` record + income accounts + a usable bank journal on `nexamain`
|
||||
(from `nexa_coa_setup`); create the "NexaCloud Stripe" journal + family income accounts if absent.
|
||||
- Whether `invoice_items.amount` is pre-tax (expected: `invoice.subtotal = Σ items`; tax separate).
|
||||
|
||||
## 9. Success criteria
|
||||
- A fixture NexaCloud invoice ingests to a posted `account.move` whose untaxed total, tax
|
||||
(= source `invoice.tax`), and total match the source; a paid one is reconciled and shows paid.
|
||||
- Re-running ingests nothing new (idempotent).
|
||||
- Dry-run on nexamain reports the full backfill (counts per family, $ totals, unmatched lines)
|
||||
with zero writes; the real run creates drafts; bulk-post on approval.
|
||||
- Full suite green on odoo-trial (`FCB_EXIT=0`).
|
||||
|
||||
## 10. Backfill status + go-forward caveat (2026-05-27)
|
||||
|
||||
- **Backfill done + verified on nexamain.** 23 customer invoices posted + payment-reconciled
|
||||
($3,403.46), 1 void deleted. NexaCloud's `created_at`/`status`/`paid_at` proved
|
||||
**unreliable** (sync-stamped today; one void marked otherwise), so invoice + payment dates
|
||||
and paid status were verified against the **source systems**:
|
||||
- **Stripe** (14 invoices, `in_*` ids) — real `created` / `paid_at` via the Stripe API.
|
||||
- **Lago** (9 `NEX-*` invoices, `lago:*` ids, billed pre-Stripe) — `issuing_date` +
|
||||
`payment_status=succeeded` via the Lago API (`billing.nexasystems.ca/api/api/v1`, key in
|
||||
Fusion-Chat; Lago host 192.168.1.117, double-hop ssh via supabase-prod).
|
||||
Partner names came from the NexaCloud `company` field (not the user's full_name).
|
||||
- **GO-FORWARD: verified sync is LIVE (2026-05-27).** The verification used in the backfill
|
||||
is now folded into the ingest path, and the daily cron is enabled:
|
||||
- `_fc_verify(inv)` routes each invoice to its source by `stripe_invoice_id` prefix
|
||||
(`in_` → Stripe REST `GET /v1/invoices/{id}`; `lago:` → Lago REST) and returns
|
||||
`{invoice_date, void, draft, paid, paid_at, amount_paid}` taken from the SOURCE — or
|
||||
`None` if it can't be determined/reached. Credentials live in `ir.config_parameter`:
|
||||
`fusion_billing.stripe_api_key` (set, live), `fusion_billing.lago_api_url` /
|
||||
`fusion_billing.lago_api_key` (optional; unset — no new Lago invoices expected).
|
||||
- `_cron_sync_verified()` reads all NexaCloud invoices, skips ones already posted, then
|
||||
for the rest: skips **void** and **draft** (not finalized at source), logs **unverified**
|
||||
for retry next run, and ingests the rest with `_ingest_invoices(post=True, verified=…)`
|
||||
so the move uses the source invoice_date (accounting date too) and a payment is
|
||||
reconciled ONLY when the source confirms paid. Never acts on NexaCloud's raw fields.
|
||||
- Cron `cron_fc_invoice_ledger` on nexamain: **active**, daily at 06:00 UTC. (A stale
|
||||
pre-existing copy of this record still called the removed `_cron_ingest_recent`; because
|
||||
the data file is `noupdate="1"` the upgrade didn't rewrite it, so its server-action code
|
||||
+ name were corrected once via SQL. Fresh installs get the right definition from the XML.)
|
||||
- First live run (2026-05-27): 23 already-posted, 1 void + 2 Stripe drafts + 5 genuine
|
||||
$0 invoices all correctly skipped, **0 new posted**, ledger intact at $3,403.46.
|
||||
- Verification helpers are unit-tested without network (routing short-circuits when no
|
||||
credentials are set; the cron is exercised with `_read_nexacloud_invoices` / `_fc_verify`
|
||||
patched). Full suite green on odoo-trial (`FCB_EXIT=0`).
|
||||
@@ -0,0 +1,89 @@
|
||||
# Sub-project #2d — NexaCloud Dual-Run Reconciliation (Design)
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Status:** Design (proceeding straight to build — approach determined by parent spec §10)
|
||||
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise; tested on odoo-trial)
|
||||
- **Parent:** Sub-project #2. Depends on **2a** (the importer creates the shadow subscriptions + the `cpu_seconds` charge catalog this reconciles against).
|
||||
- **Model already exists:** `fusion.billing.reconciliation` (`service_id`, `partner_id`, `period`, `odoo_amount`, `external_amount`, `delta`, `status` ∈ match/delta/resolved, `note`).
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Prove, for ≥ 1 billing cycle, that Odoo's billing engine computes the **same charge** as
|
||||
NexaCloud already does — per subscription, per period — before any real billing is flipped.
|
||||
Read-only against NexaCloud; writes only `fusion.billing.reconciliation` rows in Odoo.
|
||||
|
||||
## 2. What gets compared
|
||||
|
||||
For each imported shadow subscription and period:
|
||||
|
||||
- **`external_amount`** = NexaCloud's **actual** pre-tax charge for that subscription+period
|
||||
(the NexaCloud invoice **subtotal**, i.e. flat plan + its own metered overage, before HST).
|
||||
- **`odoo_amount`** = what **Odoo would charge** for the same period:
|
||||
`flat + overage`, where
|
||||
- `flat` = the shadow subscription's plan-product line `price_unit` (the imported flat price), and
|
||||
- `overage` = `charge._compute_billable(cpu_seconds)[1]` for the period's CPU usage, with
|
||||
`cpu_seconds = Σ usage_records.cpu_hours × 3600` (the 2a unit convention).
|
||||
- **`delta`** = `odoo_amount − external_amount`.
|
||||
- **`status`** = `match` if `abs(delta) ≤ tolerance` (default $0.01, configurable), else `delta`.
|
||||
|
||||
Comparing **pre-tax subtotals** keeps it apples-to-apples — HST is native Odoo and not what
|
||||
we're validating; the metered math + catalog mapping is.
|
||||
|
||||
## 3. Architecture (mirrors 2a: pure compute split from the read)
|
||||
|
||||
- **`_compute_reconciliation(flat_amount, charge, cpu_seconds, external_amount, tolerance)`**
|
||||
→ `(odoo_amount, delta, status)`. Pure, deterministic, unit-tested with fixtures. This is
|
||||
the reconciliation core.
|
||||
- **`_reconcile_rows(rows, tolerance=0.01)`** — pure Odoo: for each input row
|
||||
`{subscription_external_id, period, cpu_seconds, external_amount}`, resolve the shadow
|
||||
`sale.order` (by `x_fc_nexacloud_subscription_id`), its `flat` (plan-line `price_unit`) and
|
||||
its `charge` (by `x_fc_nexacloud_plan_id` → `charge.plan_code`), call
|
||||
`_compute_reconciliation`, and **upsert** one `fusion.billing.reconciliation` row keyed by
|
||||
`(service_id, partner_id, period)`. Returns a summary `{match, delta, skipped, failed}`.
|
||||
- **`_read_reconciliation_rows(period=None)`** — read-only `psycopg2` (reuses the 2a DSN):
|
||||
per subscription+period, `Σ usage_records.cpu_hours` and the NexaCloud invoice **subtotal**.
|
||||
Integration glue (validated manually, like 2a's reader); not unit-tested against a foreign DB.
|
||||
- **Trigger:** a button on the existing import wizard (**“Run Reconciliation”**) and a model
|
||||
method suitable for an `ir.cron`. A non-zero `delta`/`failed` count is surfaced loudly
|
||||
(banner + ERROR log), same as the importer.
|
||||
|
||||
## 4. 2a amendment (small, required)
|
||||
|
||||
Add **`x_fc_nexacloud_plan_id`** (`Char`) to `sale.order` and set it in the importer's
|
||||
`_import_subscription` (from `subscription.plan_id`). Reconciliation needs sub → plan → charge,
|
||||
and parsing it out of the product `default_code` would be fragile.
|
||||
|
||||
## 5. Idempotency / re-runnability
|
||||
|
||||
Reconciliation rows upsert on `(service_id, partner_id, period)`, so re-running a period
|
||||
updates its row rather than duplicating — the dual-run is run every cycle.
|
||||
|
||||
## 6. Shadow-safety
|
||||
|
||||
Reconciliation is pure measurement: it reads NexaCloud and writes only
|
||||
`fusion.billing.reconciliation`. It never touches subscriptions, invoices, payments, or the
|
||||
charge catalog, so the 2a shadow guarantees are untouched.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
`TransactionCase` on odoo-trial with fixtures:
|
||||
- `_compute_reconciliation`: under-quota match; overage match; a real delta flips status to
|
||||
`delta`; tolerance boundary.
|
||||
- `_reconcile_rows`: creates one recon row per subscription; `match` vs `delta` set correctly;
|
||||
re-run upserts (no duplicate); a row for an unknown subscription/charge lands in
|
||||
`skipped`/`failed`, not a crash.
|
||||
- amendment: importer sets `x_fc_nexacloud_plan_id`.
|
||||
|
||||
## 8. Out of scope
|
||||
|
||||
- The **flip** (set `charge.plan_id`, attach tokens, confirm subs) — happens once deltas are
|
||||
within tolerance for ≥ 1 cycle; not automated here.
|
||||
- Reading NexaCloud RAM/disk/bandwidth (CPU is the only metered-overage metric in v1, per 2a).
|
||||
- A reconciliation dashboard/report view beyond the list of `fusion.billing.reconciliation`.
|
||||
|
||||
## 9. Success criteria
|
||||
|
||||
- For fixture data where Odoo's math equals NexaCloud's, every row is `match`; where it
|
||||
diverges beyond tolerance, the row is `delta` with the correct signed `delta`.
|
||||
- Re-running a period upserts (no duplicate rows).
|
||||
- Full suite green on odoo-trial (`FCB_EXIT=0`).
|
||||
350
docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md
Normal file
350
docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# Owner Approval Flow — Design Spec
|
||||
|
||||
**Date**: 2026-05-27
|
||||
**Author**: Gurpreet (with Claude)
|
||||
**Status**: Approved — ready for implementation plan
|
||||
**Touches**: `fusion_helpdesk` (client / entech), `fusion_helpdesk_central` (nexa)
|
||||
|
||||
## Problem
|
||||
|
||||
Some in-app feature requests and bug reports require sign-off from a real decision-maker at the client (the "owner" — the person paying the bill, not just an Odoo Manager-by-permission). Today this happens out-of-band via WhatsApp or phone, leaving no record on the ticket and forcing Gurpreet to remember who said what to whom.
|
||||
|
||||
We need a structured way to loop the client's owner in on tickets that need approval, on-demand from the central support side, with a low-friction approve/reject flow for the owner and a transcript of the decision living on the ticket itself.
|
||||
|
||||
## Goals
|
||||
|
||||
- Central support (Gurpreet on nexa) decides *which* tickets need approval — never automatic.
|
||||
- Owner approves or rejects with **one click** from their email, no login required.
|
||||
- The approval decision is **publicly visible** on the ticket (per existing chatter / inbox plumbing) — both the originating employee and central support see who approved or rejected and any optional comment.
|
||||
- Owner contact lives in **entech settings** (source of truth) and stays automatically fresh on nexa via piggyback on every ticket submission.
|
||||
- An **AI summary** of the ticket goes in the approval email so the owner can decide in 30 seconds without reading the whole thread.
|
||||
- **Single-shot reminder** if no response in N days.
|
||||
- **Bulk engagement** when multiple requests need the same owner's sign-off in one batch.
|
||||
- **Reporting dashboard** so Gurpreet can spot stuck approvals at a glance.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Manager-tier approvals (rejected during brainstorming — "manager" by Odoo permission ≠ business-authority owner; only owner needed).
|
||||
- SLAs / hard deadlines on owner response.
|
||||
- Multi-step approval chains (one owner, one decision).
|
||||
- Owner-facing mobile app or portal beyond the approve / reject confirmation page — email + magic link is the entire UX.
|
||||
- Auto-progressing the ticket stage on approval — Gurpreet still manually completes the work.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Module split
|
||||
|
||||
| Module | Role | Touches |
|
||||
|---|---|---|
|
||||
| `fusion_helpdesk` (entech, client) | Lets the client configure their owner contact; sends contacts upstream on every ticket | 2 ICP settings, settings view, `/fusion_helpdesk/submit` payload |
|
||||
| `fusion_helpdesk_central` (nexa) | Owns the engagement flow end-to-end: storage, wizard, email, public portal, reminder cron, dashboard | New wizard model, ticket fields, mail template, public controllers, OpenAI client, reporting views |
|
||||
|
||||
### Data model
|
||||
|
||||
#### Entech (`fusion_helpdesk`)
|
||||
|
||||
Two new `ir.config_parameter` keys exposed in **Settings → Fusion Helpdesk → Owner Approval**:
|
||||
|
||||
- `fusion_helpdesk.owner_email` — Char
|
||||
- `fusion_helpdesk.owner_name` — Char
|
||||
|
||||
`controllers/main.py::submit` piggybacks both keys on every ticket payload (alongside the existing identity keys). Both are optional — leaving them blank disables the Engage button on central for that client.
|
||||
|
||||
#### Central (`fusion_helpdesk_central`)
|
||||
|
||||
Extend existing `fusion.helpdesk.client.key` (one row per client deployment):
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `owner_email` | Char | Current owner contact for this client. Upserted on every incoming ticket from the submit payload. |
|
||||
| `owner_name` | Char | Display name for greeting / chatter attribution. |
|
||||
|
||||
Extend `helpdesk.ticket`:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `x_fc_engagement_state` | Selection (`none`/`pending`/`approved`/`rejected`) | Drives kanban badge + state pill on form. Default `none`. |
|
||||
| `x_fc_engagement_email` | Char | Snapshot of owner email reached for *this* engagement. Survives later edits to `client_key.owner_email`. |
|
||||
| `x_fc_engagement_name` | Char | Snapshot of owner name. |
|
||||
| `x_fc_engagement_token` | Char (UUID4) | Single-use token in the magic link. Cleared on confirm. |
|
||||
| `x_fc_engagement_sent_at` | Datetime | When the engagement email was first queued. |
|
||||
| `x_fc_engagement_reminded_at` | Datetime, nullable | When the single reminder went out. Set by cron. |
|
||||
| `x_fc_engagement_decided_at` | Datetime, nullable | When state transitioned to `approved`/`rejected`. Drives turnaround metric. |
|
||||
| `x_fc_ai_summary` | Text | The brief used in the email; editable in the wizard before send; read-only after. |
|
||||
| `x_fc_engagement_turnaround_hours` | Float, `store=True`, computed | `(decided_at - sent_at) / 3600`. Lets the pivot view aggregate. |
|
||||
|
||||
New transient model `fusion.helpdesk.engagement.wizard` — see Engagement Wizard below.
|
||||
|
||||
New `ir.config_parameter` keys (Helpdesk → Configuration):
|
||||
|
||||
- `fusion_helpdesk_central.openai_api_key` — Char, system-only readable
|
||||
- `fusion_helpdesk_central.openai_model` — Char, default `gpt-4o-mini`
|
||||
- `fusion_helpdesk_central.engagement_reminder_days` — Integer, default `3`; `0` disables reminders
|
||||
|
||||
## Engagement flow (single ticket)
|
||||
|
||||
1. Support opens the ticket → clicks **`Request Owner Approval`** (header button; only rendered when `x_fc_client_label` is set and `client_key.owner_email` is configured).
|
||||
2. Wizard `fusion.helpdesk.engagement.wizard` opens:
|
||||
- **AI Summary** textarea — auto-populated on `default_get` via one OpenAI call against `{ticket.name + html2plaintext(ticket.description) + each public chatter message}`. Editable.
|
||||
- **Personal note** textarea — Gurpreet's own one-liner that prepends the email body.
|
||||
- Read-only display of `owner_email` / `owner_name` resolved from `client_key`.
|
||||
- **[Send]** button.
|
||||
3. On send:
|
||||
- `token = uuid4().hex`
|
||||
- Ticket fields written: `engagement_state='pending'`, `engagement_email`, `engagement_name`, `engagement_token`, `engagement_sent_at=now`, `ai_summary`
|
||||
- Mail template `mail_template_engagement` rendered → queued (`mail.mail`, `auto_delete=True`)
|
||||
- Wizard closes
|
||||
4. Owner receives email → reads → clicks **`Approve`** or **`Reject`** (two big buttons, each a `https://erp.nexasystems.ca/fusion_helpdesk/engagement/<token>/<decision>` URL).
|
||||
5. Public controller resolves the token → renders a small standalone QWeb page (not the heavy portal layout):
|
||||
- Header strip with Nexa Systems branding
|
||||
- Ticket title + one-line AI summary
|
||||
- Optional comment textarea
|
||||
- **[Confirm Approval]** / **[Confirm Rejection]** button
|
||||
- If token invalid / used / wrong state → friendly "This link has already been used or is no longer valid" page
|
||||
6. On confirm:
|
||||
- Resolve owner partner: find-or-create `res.partner` by email (reusing the existing `_resolve_author`-style pattern from customer replies)
|
||||
- Post chatter message on ticket, attributed to that partner, subtype `mail.mt_comment` (public):
|
||||
```
|
||||
✓ Approved by {{ owner_name }}
|
||||
<i>{{ comment }}</i> ← only if comment provided
|
||||
```
|
||||
- Write `engagement_state='approved'|'rejected'`, `engagement_token=False`, `engagement_decided_at=now`
|
||||
- The chatter message propagates to the employee's My Tickets thread via the existing `_public_messages` filter, satisfying the "Fully visible" UX choice.
|
||||
- Gurpreet receives the standard Odoo follower notification.
|
||||
7. Support sees the state pill flip from amber `⏳ Awaiting approval from Kris` to green `✓ Approved by Kris`, then progresses the ticket as normal.
|
||||
|
||||
### Re-engagement
|
||||
|
||||
If Gurpreet clicks **`Request Owner Approval`** on a ticket that's already `pending` / `approved` / `rejected`, the wizard opens normally; on send it overwrites the token, snapshot fields, summary, `sent_at`, and clears `reminded_at` and `decided_at`. State resets to `pending`. Old chatter messages from prior engagements stay as audit history. Old tokens are immediately dead (the token field has changed).
|
||||
|
||||
### Token security
|
||||
|
||||
UUID4 is 122 bits of entropy — sufficient against guessing. Tokens are single-use (cleared on confirm). No date-based expiry in v1 — keep it simple; if abuse appears, add a 14-day `engagement_sent_at` cutoff in the controller.
|
||||
|
||||
## AI summary (OpenAI integration)
|
||||
|
||||
- Model: `gpt-4o-mini` (configurable via ICP). ~$0.15/1M input tokens; one call per Engage click. ~$0.01/month at 10 engagements/week.
|
||||
- Transport: `urllib.request` against `https://api.openai.com/v1/chat/completions` — no new pip dependency.
|
||||
- Timeout: 15 seconds. On failure → summary field renders empty + soft banner "AI summary unavailable — write a quick brief manually." Wizard remains usable.
|
||||
- HTML stripping: `odoo.tools.mail.html2plaintext()` (built-in).
|
||||
- Token cap: assembled prompt truncated to 8000 characters (well below context window, bounds cost on tickets with 50+ messages).
|
||||
- Prompt is a Python constant (`fusion_helpdesk_central/utils.py::SUMMARY_PROMPT`) so it's editable in one place without UI churn. See Engagement Wizard for prompt text.
|
||||
- **Privacy**: ticket description + chatter goes to OpenAI. Document in client onboarding. Empty API key disables the auto-fill but keeps the wizard working with a manual summary.
|
||||
|
||||
## Engagement Wizard (`fusion.helpdesk.engagement.wizard`)
|
||||
|
||||
`models.TransientModel` with:
|
||||
|
||||
- `ticket_id` Many2one (or `ticket_ids` for bulk — see below)
|
||||
- `personal_note` Char
|
||||
- `ai_summary` Text
|
||||
- `owner_email_display` Char (computed, readonly)
|
||||
- `owner_name_display` Char (computed, readonly)
|
||||
- `is_reminder` Boolean (set by cron, not by user)
|
||||
|
||||
`default_get` triggers `_compute_ai_summary()` which:
|
||||
|
||||
1. Reads ticket name, description (`html2plaintext`), and public messages
|
||||
2. Builds the prompt from `SUMMARY_PROMPT` template
|
||||
3. Truncates to 8000 chars
|
||||
4. POSTs to OpenAI, parses response, sets `ai_summary`
|
||||
5. Catches all exceptions → logs warning, sets `ai_summary=''`
|
||||
|
||||
`action_send` performs all writes + queues mail and returns `{'type': 'ir.actions.act_window_close'}`.
|
||||
|
||||
### Summary prompt (frozen Python constant)
|
||||
|
||||
```
|
||||
You are summarising a customer support ticket for a busy executive
|
||||
who needs to decide whether to approve the work.
|
||||
|
||||
Output rules:
|
||||
- 4–6 short bullet points, plain text (no markdown).
|
||||
- First bullet: the ask, in one sentence.
|
||||
- Second bullet: the business impact if approved.
|
||||
- Third bullet: the business impact if NOT approved (or "none material").
|
||||
- Optional bullets: cost / effort signals if any are mentioned.
|
||||
- Final bullet: open questions the approver should think about.
|
||||
- Do not invent facts. If the thread doesn't say, write "not stated".
|
||||
- No greetings, no sign-offs, no preamble.
|
||||
|
||||
Ticket title: {name}
|
||||
Original report:
|
||||
{description_plain}
|
||||
|
||||
Replies so far:
|
||||
{messages_plain}
|
||||
```
|
||||
|
||||
## Email + magic links
|
||||
|
||||
`mail.template` shipped in `fusion_helpdesk_central/data/mail_template_engagement.xml`.
|
||||
|
||||
- **From**: outgoing mail server default
|
||||
- **Reply-To**: Gurpreet's email (`gs@nexasystems.ca`) — replies don't fall into the bot inbox
|
||||
- **To**: `x_fc_engagement_email`
|
||||
- **Subject**: `Action needed: please review request "{{ ticket.name }}"`
|
||||
- **Reminder subject** (when wizard's `is_reminder=True`, set by cron): `Reminder: still waiting on your approval — "{{ ticket.name }}"`
|
||||
- **Body**: branded HTML matching the existing ack template style; greeting uses `engagement_name`; includes personal note, summary, full description + chatter in a `<details>` collapsible, two big approve/reject buttons.
|
||||
|
||||
### Public approval portal
|
||||
|
||||
Routes (both `auth='public'`, `csrf=False`):
|
||||
|
||||
- `GET /fusion_helpdesk/engagement/<token>/<string:decision>` — renders the confirmation page (or "no longer valid" page if token / state invalid). `decision` is validated against `('approve', 'reject')`.
|
||||
- `POST /fusion_helpdesk/engagement/<token>/<string:decision>` — accepts optional `comment` form field, performs the state transition + chatter post, renders a "Thanks — your decision is recorded" page.
|
||||
|
||||
Token resolution helper `_resolve_engagement(token, decision)` returns the ticket or raises a friendly error if anything's off. Used by both GET and POST.
|
||||
|
||||
## Bulk engagement
|
||||
|
||||
Server action on `helpdesk.ticket` list view: **`Request Owner Approval (bulk)`**.
|
||||
|
||||
### Validation (hard errors)
|
||||
|
||||
- All selected tickets share the same `x_fc_client_label` — otherwise: "Cannot bulk-engage tickets across different deployments."
|
||||
- All selected tickets have `engagement_state in ('none', 'rejected')` — otherwise: "{n} of the selected tickets already have a pending or approved engagement. Engage them individually."
|
||||
- `client_key.owner_email` is configured for the deployment — otherwise the standard tooltip error.
|
||||
|
||||
### Wizard
|
||||
|
||||
Same `fusion.helpdesk.engagement.wizard` model gains a `ticket_ids` Many2many to `helpdesk.ticket` (single-ticket mode keeps using `ticket_id`; the wizard checks which is set and branches). Per-ticket AI summaries generated **in parallel** via `concurrent.futures.ThreadPoolExecutor(max_workers=5)` with a 30-second overall timeout. Each per-ticket summary is editable in its own row in the wizard view via a child transient model `fusion.helpdesk.engagement.wizard.line` (fields: `wizard_id`, `ticket_id`, `ai_summary`).
|
||||
|
||||
### Email
|
||||
|
||||
A single combined email with one card per ticket. Each card has its own `[Approve][Reject]` buttons, each pointing at that ticket's unique token. Owner can decide per-ticket, ignore some, come back to the same email later (links stay live until clicked or re-engaged).
|
||||
|
||||
### Layout (rendered HTML)
|
||||
|
||||
```
|
||||
Hi Kris,
|
||||
|
||||
5 requests from ENTECH need your sign-off. Each can be approved or
|
||||
rejected independently — clicking a button on one card only acts on
|
||||
that card.
|
||||
|
||||
──── Request 1 of 5 ──────────────────────────────
|
||||
"Drag and drop steps"
|
||||
• <summary bullets>
|
||||
[✓ Approve] [✗ Reject]
|
||||
|
||||
──── Request 2 of 5 ──────────────────────────────
|
||||
...
|
||||
```
|
||||
|
||||
## Reminder cron
|
||||
|
||||
`ir.cron`, daily at 09:00, sudo:
|
||||
|
||||
```python
|
||||
N = int(ICP.get_param('fusion_helpdesk_central.engagement_reminder_days') or 3)
|
||||
if N <= 0:
|
||||
return # disabled
|
||||
cutoff = fields.Datetime.now() - timedelta(days=N)
|
||||
to_remind = self.env['helpdesk.ticket'].search([
|
||||
('x_fc_engagement_state', '=', 'pending'),
|
||||
('x_fc_engagement_sent_at', '<=', cutoff),
|
||||
('x_fc_engagement_reminded_at', '=', False),
|
||||
])
|
||||
for ticket in to_remind:
|
||||
template.with_context(is_reminder=True).send_mail(
|
||||
ticket.id, force_send=False)
|
||||
ticket.x_fc_engagement_reminded_at = fields.Datetime.now()
|
||||
```
|
||||
|
||||
**Single-shot by design** — no second reminder. If still no response after one nudge, the right action is human (call the owner), not another email.
|
||||
|
||||
Same token, same magic links — the owner can click either the original or the reminder email.
|
||||
|
||||
## Reporting dashboard
|
||||
|
||||
Menu: **Helpdesk → Reporting → Owner Engagements** (new entry, after Tickets Analysis).
|
||||
|
||||
Action opens four views over `helpdesk.ticket` filtered by `('x_fc_engagement_state', '!=', 'none')`:
|
||||
|
||||
1. **Pivot** (default): rows = `x_fc_client_label`, columns = `x_fc_engagement_state`, measures = count + avg `x_fc_engagement_turnaround_hours`
|
||||
2. **Graph (bar)**: engagement count over time grouped by `x_fc_client_label`
|
||||
3. **List**: ticket_ref, client, owner name/email, state, sent_at, reminded_at, decided_at, turnaround_hours
|
||||
4. **Kanban (default group by state)**: at-a-glance count per state
|
||||
|
||||
Filters: by client, by state, by date range. Canned filter "Pending > 7 days" highlights stuck approvals.
|
||||
|
||||
No new model; everything is derived from `helpdesk.ticket`. The stored computed field `x_fc_engagement_turnaround_hours` makes the pivot fast on large datasets.
|
||||
|
||||
## UI changes
|
||||
|
||||
### Helpdesk ticket form (nexa)
|
||||
|
||||
- New header button **`Request Owner Approval`** (visible iff `x_fc_client_label` set AND `client_key.owner_email` set; tooltip on disabled state explains why)
|
||||
- State pill right of the title:
|
||||
- `none` → no pill
|
||||
- `pending` → amber `⏳ Awaiting approval from {{ engagement_name }}`
|
||||
- `approved` → green `✓ Approved by {{ engagement_name }}`
|
||||
- `rejected` → red `✗ Rejected by {{ engagement_name }}`
|
||||
- New collapsible group **`Owner Engagement`** showing `ai_summary` (read-only after send), `engagement_email`, `engagement_name`, `engagement_sent_at`, `engagement_reminded_at`, `engagement_decided_at`, `engagement_turnaround_hours`
|
||||
|
||||
### Helpdesk ticket kanban (nexa)
|
||||
|
||||
Amber corner dot when `engagement_state == 'pending'` — surfaces blockers in the kanban view without opening each card.
|
||||
|
||||
### Entech settings UI
|
||||
|
||||
New section **Owner Approval** under existing Fusion Helpdesk group:
|
||||
|
||||
- `Owner email` text input
|
||||
- `Owner name` text input
|
||||
- Help text: "Used when Nexa Systems support requests approval for a feature or bug fix that needs sign-off. Leave blank if your deployment doesn't require approvals."
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behaviour |
|
||||
|---|---|
|
||||
| Owner contact not configured on entech | `Request Owner Approval` button disabled, tooltip: "Owner contact not configured for this client. Ask them to fill it in under Settings → Fusion Helpdesk." |
|
||||
| Token reused after first click | Friendly "This approval link has already been used or is no longer valid" page with a `mailto:support@nexasystems.ca` link. |
|
||||
| Owner gets re-engaged | New token replaces old; old immediately invalid. State resets to `pending`. Old chatter is preserved. `reminded_at` / `decided_at` cleared. |
|
||||
| OpenAI down / no API key | Wizard opens with empty summary + soft banner; you type your own brief, send normally. |
|
||||
| Owner replies to the email instead of clicking | Mail gateway treats it as a regular comment (existing flow). State stays `pending` until they click a magic link. |
|
||||
| Employee files a follow-up while owner is deciding | Reply lands in chatter normally; owner sees it next time they reload, but their engagement is tied to the snapshot AI summary (intentional — owner judges a stable artifact). |
|
||||
| Bulk action selects tickets across clients | Hard error before wizard opens. |
|
||||
| Bulk action selects tickets that already have pending engagements | Hard error specifying the count of disallowed tickets. |
|
||||
| Approved ticket needs to be "reversed" | No undo button. Re-engage with a fresh wizard → new summary → re-send. Audit chain stays in chatter. |
|
||||
|
||||
## Tests
|
||||
|
||||
Pure helpers in `fusion_helpdesk_central/utils.py` (new file):
|
||||
|
||||
- `build_summary_prompt(ticket_dict, messages)` → str
|
||||
- `truncate_for_openai(prompt, max_chars=8000)` → str
|
||||
- `format_engagement_chatter(decision, owner_name, comment)` → Markup
|
||||
|
||||
`fusion_helpdesk_central/tests/test_utils.py`:
|
||||
|
||||
- Prompt structure (correct ordering, all fields present, empty-thread fallback)
|
||||
- Truncation (preserves the prefix and ticket title)
|
||||
- Chatter formatting (approve / reject / with-comment / without-comment)
|
||||
|
||||
`fusion_helpdesk_central/tests/test_engagement.py`:
|
||||
|
||||
- Token generation is unique per call
|
||||
- Wizard `action_send` writes all expected fields, queues mail, returns close action
|
||||
- Re-engagement clears the old token + decided_at + reminded_at, resets state to `pending`
|
||||
- Public controller rejects invalid / used / wrong-decision tokens with friendly error
|
||||
- Public controller `POST` confirms decision, posts chatter, writes state
|
||||
- State transitions are correctly one-way (approved → approved is no-op, approved → re-engaged → pending works)
|
||||
- Bulk wizard rejects mixed-client selection
|
||||
- Bulk wizard rejects already-pending tickets in selection
|
||||
- Reminder cron only acts on rows past cutoff and not already reminded
|
||||
- Computed `turnaround_hours` matches expected delta after decision
|
||||
|
||||
OpenAI is mocked in tests — no live API calls in CI.
|
||||
|
||||
## Versions
|
||||
|
||||
- `fusion_helpdesk` → bump to `19.0.2.0.0` (minor feature, new settings)
|
||||
- `fusion_helpdesk_central` → bump to `19.0.2.0.0` (major feature, multiple new fields + wizard + controllers + cron + reporting)
|
||||
|
||||
## Deployment order
|
||||
|
||||
1. Deploy `fusion_helpdesk_central` first (it owns the storage, the wizard, the email template, the public routes, the cron, the reporting). It can sit dormant — no Engage button is reachable until `client_key.owner_email` is populated.
|
||||
2. Deploy `fusion_helpdesk` second (adds the entech settings + payload piggyback). First ticket filed after this deploy populates `client_key.owner_email` on central.
|
||||
3. Backfill: for any client that already has owner contact info known to Gurpreet (e.g., entech → kris@enplating.ca), edit the `client_key` row directly on nexa via the existing config UI. Or simply wait — the next ticket from that client will populate it.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Schedule-Driven Attendance Automation — Design
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved design → ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Drive every attendance automation (clock-in/out reminders, absence detection,
|
||||
late/early penalties, auto-clock-out) from each employee's **real schedule** —
|
||||
the team lead's **posted** planner entry first, then the employee's **recurring
|
||||
shift** — never the global 9–5 default. Employees who aren't scheduled get no
|
||||
reminders or absence flags. Overtime past the scheduled end is normal and is
|
||||
never cut off.
|
||||
|
||||
## Problem & root cause
|
||||
|
||||
The machinery already exists: `fusion.clock.shift` (recurring templates,
|
||||
assigned via `hr.employee.x_fclk_shift_id`), `fusion.clock.schedule` (dated
|
||||
per-employee entries built in the backend **shift planner** client action), and
|
||||
`hr.employee._get_fclk_day_plan(date)` which resolves per-day times. The crons
|
||||
already call these.
|
||||
|
||||
The bug: in `_get_fclk_day_plan()`, when an employee has **no dated entry and no
|
||||
assigned shift**, it silently falls back to the **global 9–5 default with
|
||||
`is_off = False`**. So everyone is treated as a 9–5 worker, and the reminder /
|
||||
absence crons fire off that global time. The crons also **hardcode-skip Sat/Sun**
|
||||
(`weekday() >= 5`), which is wrong for a production floor that runs weekends.
|
||||
Net effect: reminders are not actually schedule-driven for anyone who isn't on a
|
||||
fixed weekday 9–5 — exactly the spurious-email problem reported.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
1. **"Expected to work" source:** posted planner entry → else recurring shift
|
||||
(if it covers that weekday) → else **not scheduled** (silent). The global
|
||||
default never makes someone "expected."
|
||||
2. **Overtime:** time past the scheduled end is overtime and is never cut off.
|
||||
Auto-clock-out fires **only** at a generous safety cap (forgot-to-clock-out).
|
||||
3. **Posting:** draft → post gate. Team leads build the week in draft;
|
||||
automation ignores draft days. "Post" publishes the week and emails each
|
||||
employee their shifts. Only posted entries drive automation.
|
||||
4. **Employee schedule view:** reuse the **existing "Today's Shift" card** on
|
||||
`/my/clock` — no new portal view. (See Coordination.)
|
||||
|
||||
## Non-goals / constraints
|
||||
|
||||
- **No edits to the employee `/my` portal shell.** A concurrent session
|
||||
("Internal employee portal design", `fusion_plating`) owns `/my` + `/my/home`
|
||||
routing and the `/my/clock` bottom-nav tabs (it is adding a Payslips tab).
|
||||
This feature makes **zero** edits to `controllers/portal_clock.py` routing,
|
||||
`views/portal_clock_templates.xml`, or `/my` routing. The existing "Today's
|
||||
Shift" card already renders `today_schedule.get('label') or 'Not scheduled'`,
|
||||
so once the resolver is schedule-driven the card updates itself. Employees get
|
||||
their full posted week via the Post notification email. A dedicated "My
|
||||
Schedule" nav tab, if ever wanted, belongs to the portal-shell session.
|
||||
- The backend **shift planner** client action (manager/team-lead facing) is
|
||||
*not* the `/my` portal and **is** in scope to edit (Post button, draft/posted
|
||||
visuals).
|
||||
- No change to how attendance hours / overtime are computed.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Schedule resolver — `hr.employee._get_fclk_day_plan(date)`
|
||||
|
||||
Rewrite to return an explicit `scheduled` flag and a precise `source`, keeping
|
||||
all existing keys for backward compatibility (`is_off`, `label`, `hours`,
|
||||
`start_time`, `end_time`, `break_minutes`).
|
||||
|
||||
Return shape:
|
||||
```python
|
||||
{
|
||||
'scheduled': bool, # is the employee expected to work this day?
|
||||
'source': 'schedule' | 'shift' | 'none',
|
||||
'is_off': bool,
|
||||
'start_time': float, 'end_time': float, 'break_minutes': float,
|
||||
'hours': float,
|
||||
'label': str, # '' when not scheduled → card shows 'Not scheduled'
|
||||
'schedule_id': int | False,
|
||||
}
|
||||
```
|
||||
|
||||
Resolution order:
|
||||
1. **Posted planner entry** (`fusion.clock.schedule`, `state == 'posted'`) for
|
||||
(employee, date) — *draft entries are ignored, treated as absent*:
|
||||
- `is_off` → `scheduled=False`, `is_off=True`, `source='schedule'`, `hours=0`,
|
||||
`label='OFF'`.
|
||||
- else → `scheduled=True`, times from entry, `source='schedule'`.
|
||||
2. Else **recurring shift** `x_fclk_shift_id` **and** the shift covers
|
||||
`date`'s weekday → `scheduled=True`, times from shift, `source='shift'`.
|
||||
3. Else → `scheduled=False`, `source='none'`, `is_off=False`, `label=''`,
|
||||
`hours=0`. (Global default may fill `start_time`/`end_time` as a display
|
||||
hint only; it never sets `scheduled=True`.)
|
||||
|
||||
`_get_fclk_scheduled_times()` and `_get_fclk_break_minutes()` keep working off
|
||||
this structure unchanged.
|
||||
|
||||
### 2. Data model changes
|
||||
|
||||
- **`fusion.clock.schedule`**: add
|
||||
- `state = Selection([('draft','Draft'),('posted','Posted')], default='draft')`
|
||||
- `posted_date = Datetime`
|
||||
- Automation reads only `state == 'posted'`.
|
||||
- **`fusion.clock.shift`**: add a weekday pattern —
|
||||
`day_mon … day_sun = Boolean` (default Mon–Fri True, Sat–Sun False) plus a
|
||||
helper `covers_weekday(date) -> bool`. This replaces the hardcoded weekend
|
||||
skip and lets weekend shifts exist. (Judgment call: pattern lives on the
|
||||
shared shift template, e.g. "Mon–Fri Day", "Sat–Sun Weekend"; unique patterns
|
||||
→ own template or a posted planner override.)
|
||||
|
||||
### 3. Posting workflow
|
||||
|
||||
- New jsonrpc route `POST /fusion_clock/shift_planner/post_week` in
|
||||
`controllers/shift_planner.py`:
|
||||
- Gate: manager OR team lead.
|
||||
- Scope: managers → all in-scope employees for the viewed week; team leads →
|
||||
their direct reports (`parent_id` == the team lead's employee). Reuse the
|
||||
existing dashboard scoping helper.
|
||||
- Set `state='posted'`, `posted_date=now` on those week entries.
|
||||
- Queue **one email per affected employee** summarizing their posted shifts
|
||||
for the week (reuse `_fclk_email_wrap`). Failures logged, never block the
|
||||
post.
|
||||
- New planner entries default to `draft`. Re-posting after edits re-publishes
|
||||
(and re-notifies, flagged as an update).
|
||||
- Planner client action (`static/src/js/fusion_clock_shift_planner.js` + its
|
||||
template) gains a **Post** button and a draft-vs-posted visual cue. (Backend
|
||||
client action — not the `/my` portal.)
|
||||
|
||||
### 4. Reminder cron — `hr.attendance._cron_fusion_employee_reminders`
|
||||
|
||||
- Remove the `weekday() >= 5` hardcode.
|
||||
- Per enabled employee: `plan = emp._get_fclk_day_plan(today)`; **if not
|
||||
`plan['scheduled']` → skip** (silent).
|
||||
- Missed clock-in: if scheduled, not checked in, no attendance today, and
|
||||
`now > scheduled_in + reminder_before_shift_minutes` → remind. Uses the
|
||||
employee's real start, so a 14:00 shift is never pinged at 09:30.
|
||||
- Clock-out reminder: **reframed** (judgment call). Drop the "your shift ends at
|
||||
X" nudge (noise when OT is the norm). Instead, if still checked in and
|
||||
approaching the safety cap (`check_in + max_shift_hours -
|
||||
reminder_before_end_minutes`), send "you're still clocked in — remember to
|
||||
clock out."
|
||||
|
||||
### 5. Absence cron — `hr.attendance._cron_fusion_check_absences`
|
||||
|
||||
- Remove the `weekday() >= 5` hardcode.
|
||||
- Per enabled employee: `plan = emp._get_fclk_day_plan(yesterday)`; **only flag
|
||||
absent if `plan['scheduled']`** AND no attendance AND no leave request AND no
|
||||
global holiday. Off/unscheduled → never flagged.
|
||||
|
||||
### 6. Auto-clock-out — `hr.attendance._cron_fusion_auto_clock_out`
|
||||
|
||||
- Stop closing at `scheduled_out + grace`. Close **only** at the safety cap
|
||||
`check_in + max_shift_hours`. Everything between the scheduled end and the cap
|
||||
is captured as overtime by the existing fields.
|
||||
- Bump default `max_shift_hours` **12 → 16** (still configurable).
|
||||
- Keep `x_fclk_pending_reason=True`, break deduction, and office notify on
|
||||
auto-close.
|
||||
|
||||
### 7. Penalties — `controllers/clock_api.py::_check_and_create_penalty`
|
||||
|
||||
- Skip when the day is not scheduled (`not plan['scheduled']`), in addition to
|
||||
the existing posted-OFF skip. Late-in / early-out stay keyed off the resolved
|
||||
scheduled start/end. Overtime is never penalized.
|
||||
|
||||
### 8. Kiosk callers — `clock_kiosk.py`, `clock_nfc_kiosk.py`
|
||||
|
||||
- The existing `is_scheduled_off = source == 'schedule' and is_off` checks keep
|
||||
working for posted-OFF days. Extend the "unscheduled shift" log + penalty-skip
|
||||
to also cover `source == 'none'` (clocked in on a day with no schedule) so a
|
||||
not-scheduled clock-in is logged as `unscheduled_shift` and creates no penalty.
|
||||
|
||||
### 9. Settings
|
||||
|
||||
- `res_config_settings`: change `fclk_max_shift_hours` default 12 → 16 (and the
|
||||
resolver/cron `get_param` fallback). Optionally surface the shift weekday
|
||||
pattern on the shift form. No other new settings required.
|
||||
|
||||
### 10. Frontend
|
||||
|
||||
- **No file edits.** The existing "Today's Shift" card auto-reflects the new
|
||||
resolver: scheduled → times + hours; posted OFF → "OFF"; not scheduled →
|
||||
"Not scheduled" (already coded as `label or 'Not scheduled'`).
|
||||
|
||||
## Data flow
|
||||
|
||||
posted planner entry / recurring shift → `_get_fclk_day_plan(date)` →
|
||||
`scheduled` flag → consumed by: reminder cron, absence cron, penalty helper,
|
||||
kiosk unscheduled-log, and (read-only) the portal "Today's Shift" card. Posting
|
||||
flips `state` to `posted` (making entries visible to the resolver) and emails
|
||||
employees.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Crons: wrap each employee's body in `with self.env.cr.savepoint():` so one bad
|
||||
record can't abort the batch (savepoints, not `cr.commit()` — works in prod and
|
||||
tests).
|
||||
- Posting: state writes + email queueing in one transaction; email creation in
|
||||
try/except with logging so a bad address never blocks the post.
|
||||
- Notifications: `mail.mail` with `auto_delete=True`; send failures logged.
|
||||
|
||||
## Testing (`tests/test_schedule_driven.py`, post_install)
|
||||
|
||||
- **Resolver matrix:** posted-working / posted-off / draft-ignored /
|
||||
recurring-covers-weekday / recurring-skips-weekday / nothing → not-scheduled.
|
||||
Assert `scheduled`, times, and `label`.
|
||||
- **Reminder cron:** scheduled + late + no attendance → reminder; not scheduled →
|
||||
none; 14:00 shift not pinged at 09:30; already clocked in → no clock-in
|
||||
reminder.
|
||||
- **Absence cron:** scheduled no-show → absent logged; not scheduled → not
|
||||
flagged; leave/holiday → not flagged.
|
||||
- **Auto-clock-out:** open past scheduled end but under cap → stays open; past
|
||||
cap → closed + `x_fclk_pending_reason`.
|
||||
- **Posting:** draft entry → resolver `scheduled=False` (ignored by crons); post
|
||||
→ `state='posted'`, resolver picks it up, email queued; team lead can post only
|
||||
direct reports.
|
||||
- **Penalties:** not-scheduled clock-in → no penalty; scheduled late → `late_in`.
|
||||
|
||||
## Files expected to change (for the plan)
|
||||
|
||||
- `models/hr_employee.py` — resolver refactor.
|
||||
- `models/clock_shift.py` — weekday booleans + `covers_weekday`.
|
||||
- `models/clock_schedule.py` — `state` + `posted_date`.
|
||||
- `models/hr_attendance.py` — reminders, absences, auto-clock-out + savepoints.
|
||||
- `controllers/clock_api.py` — penalty skip when not scheduled.
|
||||
- `controllers/clock_kiosk.py`, `controllers/clock_nfc_kiosk.py` — unscheduled
|
||||
log/penalty for `source == 'none'`.
|
||||
- `controllers/shift_planner.py` — `post_week` route + scope + notifications;
|
||||
default new entries to draft.
|
||||
- `static/src/js/fusion_clock_shift_planner.js` + planner template — Post button,
|
||||
draft/posted visuals.
|
||||
- `models/res_config_settings.py` + `views/res_config_settings_views.xml` —
|
||||
`max_shift_hours` default 16; optional weekday-pattern surfacing.
|
||||
- `views/clock_shift_views.xml` — weekday checkboxes on the shift form.
|
||||
- `views/clock_schedule_views.xml` — show `state`.
|
||||
- `tests/test_schedule_driven.py` (+ `tests/__init__.py`).
|
||||
- **Not touched:** `controllers/portal_clock.py` routing,
|
||||
`views/portal_clock_templates.xml`, `/my` routing (owned by the concurrent
|
||||
portal-shell session).
|
||||
|
||||
## Coordination
|
||||
|
||||
Concurrent session "Internal employee portal design" (`fusion_plating`) owns the
|
||||
employee `/my` portal shell: `/my` + `/my/home` redirect to the clock page and
|
||||
new bottom-nav tabs (Payslips). This feature is **backend-only on the frontend
|
||||
side** — it edits no `/my` portal files — so the two land without conflict
|
||||
regardless of order. Shared touchpoint to watch: both evolve the employee
|
||||
experience; if a "My Schedule" nav tab is desired, it is the portal-shell
|
||||
session's responsibility, fed by this feature's resolver.
|
||||
@@ -0,0 +1,256 @@
|
||||
# Fusion Clock — Province-Aware Automatic Unpaid Break (2-tier)
|
||||
|
||||
- **Date:** 2026-05-31
|
||||
- **Module:** `fusion_clock`
|
||||
- **Version bump:** `19.0.4.0.3` → `19.0.4.1.0`
|
||||
- **Status:** Approved design, pending implementation plan
|
||||
- **Author:** Claude Code (brainstormed with user)
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Statutory unpaid meal breaks are jurisdiction-driven: a break is required after N1
|
||||
hours of work, and a second break after a higher N2 threshold. Ontario, for example:
|
||||
a 30-minute eating period after 5 hours of work, and (per the user's policy) another
|
||||
30 minutes after 10 hours. The deduction must be **automatic** and must apply on **every**
|
||||
way an attendance is recorded — including a manager manually adding or editing hours.
|
||||
|
||||
### Audit of current behaviour (what exists today)
|
||||
|
||||
The deduction field is `hr.attendance.x_fclk_break_minutes` (minutes). Net hours are
|
||||
`x_fclk_net_hours = worked_hours − x_fclk_break_minutes/60` (`models/hr_attendance.py:261`).
|
||||
|
||||
Break minutes are written from **four** places, all implementing variations of one rule:
|
||||
|
||||
1. `controllers/clock_api.py::_apply_break_deduction` (line 161) — on **clock-out**;
|
||||
reused by the PIN kiosk (`controllers/clock_kiosk.py:158`) and NFC kiosk
|
||||
(`controllers/clock_nfc_kiosk.py:381`). Logic: `if worked_hours >= break_threshold_hours`
|
||||
(default **4.0h**) → set break to `employee._get_fclk_break_minutes()` (default **30**),
|
||||
using `max(new, current)` so it doesn't wipe penalty minutes.
|
||||
2. Auto-clock-out cron (`models/hr_attendance.py:343`) — same single-threshold write.
|
||||
3. `controllers/clock_api.py::_check_and_create_penalty` (line 140) — **adds** penalty
|
||||
minutes into the same `x_fclk_break_minutes` field.
|
||||
|
||||
### Gaps vs. requirement
|
||||
|
||||
1. **Single tier only** — one threshold (4h), one break (30m). No second break.
|
||||
2. **Not applied on manual entry** — there is **no `create`/`write` override** on
|
||||
`hr.attendance`. A manager-created or manager-edited attendance gets break `= 0`.
|
||||
This is the central gap.
|
||||
3. **No province/country awareness** — no jurisdiction field exists anywhere (location
|
||||
has address/timezone but no province; company has none). Threshold + amount are flat
|
||||
global config params.
|
||||
4. **First-break default is 4h, not 5h** (Ontario is 5h).
|
||||
|
||||
## 2. Goals / Non-goals
|
||||
|
||||
**Goals**
|
||||
- Statutory unpaid break applies automatically based on **actual worked hours**, on every
|
||||
path (portal, systray, PIN kiosk, NFC kiosk, auto-clock-out cron, **and manual backend
|
||||
create/edit**).
|
||||
- Two tiers: first break after N1 hours, second break adds after N2 hours. Trigger is
|
||||
`worked_hours >= N` (inclusive; nothing under N1).
|
||||
- Rules are defined **per province/country** in a table; an employee resolves its rule
|
||||
from its **company's province**, with a single global default fallback.
|
||||
- **Eliminate the duplicated deduction logic** — one calculator, called everywhere.
|
||||
|
||||
**Non-goals (YAGNI)**
|
||||
- Per-employee break-rule override (resolver is structured so this is a cheap add later).
|
||||
- GPS/location-based jurisdiction detection.
|
||||
- More than two tiers (the table is 2-tier; a 3rd break would be a future schema change).
|
||||
- Changing the *planned* break concept used for scheduled-hours math.
|
||||
|
||||
## 3. Locked decisions
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|---|---|
|
||||
| 1 | Rule model | **Per-province table**, 2-tier (`fusion.clock.break.rule`) |
|
||||
| 2 | Jurisdiction source | **Company province** (`company_id.state_id`) + global default fallback |
|
||||
| 3 | Override behaviour | **Fully automatic** — idempotent stored compute, recomputes on every save |
|
||||
| 4 | Planned-vs-statute | **Statutory only** — the planned/scheduled break never affects the actual deduction |
|
||||
|
||||
## 4. Design
|
||||
|
||||
### 4.1 New model `fusion.clock.break.rule`
|
||||
|
||||
`models/clock_break_rule.py`, `_name = 'fusion.clock.break.rule'`,
|
||||
`_description = 'Statutory Break Rule'`, `_order = 'sequence, name'`.
|
||||
|
||||
| Field | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | Char (required) | — | e.g. "Ontario" |
|
||||
| `country_id` | Many2one `res.country` | — | scopes the province picker |
|
||||
| `state_id` | Many2one `res.country.state` | — | the province; `domain` on `country_id` |
|
||||
| `is_default` | Boolean | False | global fallback when no province matches |
|
||||
| `break1_after_hours` | Float | 5.0 | first break trigger N1 |
|
||||
| `break1_minutes` | Float | 30.0 | first break amount M1 (0 = disabled) |
|
||||
| `break2_after_hours` | Float | 10.0 | second break trigger N2 |
|
||||
| `break2_minutes` | Float | 30.0 | second break amount M2 (0 = disabled) |
|
||||
| `sequence` | Integer | 10 | |
|
||||
| `active` | Boolean | True | |
|
||||
|
||||
**Constraints** (`models.Constraint`, per repo Odoo-19 rule 9):
|
||||
- `break1_after_hours >= 0`, `break2_after_hours >= 0`, minutes `>= 0`.
|
||||
- When `break2_minutes > 0`: `break2_after_hours > break1_after_hours`
|
||||
(a misordered second tier is a config error).
|
||||
- (Soft) at most one `is_default = True` — enforced in a Python `@api.constrains`
|
||||
rather than a partial unique index, to give a friendly message.
|
||||
|
||||
**Method** — `break_minutes_for(self, worked_hours)`:
|
||||
```
|
||||
self.ensure_one()
|
||||
total = 0.0
|
||||
if self.break1_minutes and worked_hours >= self.break1_after_hours:
|
||||
total += self.break1_minutes
|
||||
if self.break2_minutes and worked_hours >= self.break2_after_hours:
|
||||
total += self.break2_minutes
|
||||
return total
|
||||
```
|
||||
`>=` is intentional and matches the requirement ("equal to or more than N1").
|
||||
|
||||
**Seed** (`data/clock_break_rule_data.xml`, `noupdate="1"`): one row —
|
||||
`name="Ontario"`, `state_id=base.state_ca_on`, `is_default=True`,
|
||||
`break1_after_hours=5.0`, `break1_minutes=30.0`,
|
||||
`break2_after_hours=10.0`, `break2_minutes=30.0`.
|
||||
(Acting as both the Ontario match and the global fallback for this deployment.
|
||||
Other provinces can be added as rows.)
|
||||
|
||||
### 4.2 Jurisdiction resolver — `hr.employee._get_fclk_break_rule()`
|
||||
|
||||
```
|
||||
self.ensure_one()
|
||||
Rule = self.env['fusion.clock.break.rule'].sudo()
|
||||
state = self.company_id.state_id
|
||||
rule = Rule.browse()
|
||||
if state:
|
||||
rule = Rule.search([('state_id', '=', state.id)], limit=1)
|
||||
if not rule:
|
||||
rule = Rule.search([('is_default', '=', True)], limit=1)
|
||||
return rule # may be empty recordset → caller treats as 0 break
|
||||
```
|
||||
`sudo()` so the portal net-hours compute (run as the employee) can read the rule table
|
||||
without a direct ACL grant. Resolver is a single method → adding a per-employee override
|
||||
(`x_fclk_break_rule_id`) later is a two-line change.
|
||||
|
||||
### 4.3 `hr.attendance` — `x_fclk_break_minutes` becomes a stored compute
|
||||
|
||||
The field changes from a plain editable Float to a **stored computed** field — this is the
|
||||
single calculator that replaces all four write sites.
|
||||
|
||||
```python
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
compute='_compute_fclk_break_minutes',
|
||||
store=True,
|
||||
tracking=True,
|
||||
help="Unpaid break deducted from worked hours: statutory break (by province "
|
||||
"rule, from actual hours worked) plus any penalty minutes.",
|
||||
)
|
||||
|
||||
@api.depends('worked_hours', 'check_out',
|
||||
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
|
||||
def _compute_fclk_break_minutes(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
|
||||
for att in self:
|
||||
statutory = 0.0
|
||||
if auto and att.check_out and att.employee_id:
|
||||
rule = att.employee_id._get_fclk_break_rule()
|
||||
if rule:
|
||||
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
|
||||
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
|
||||
att.x_fclk_break_minutes = statutory + penalties
|
||||
```
|
||||
|
||||
Properties:
|
||||
- **Idempotent** — same hours + same penalties always yield the same value; no drift,
|
||||
nothing to wipe.
|
||||
- **Fires on every path** — `worked_hours` recomputes whenever `check_in`/`check_out`
|
||||
change, so portal, kiosk, NFC, cron, **and manual backend create/edit** all recompute
|
||||
automatically. This is what fixes the manual-entry gap.
|
||||
- **Mid-shift = 0** — `check_out` empty → statutory 0 (penalties, if any, still counted).
|
||||
- **Master toggle preserved** — `auto_deduct_break` False → statutory 0 (penalties remain).
|
||||
- `_compute_net_hours` is unchanged (still `worked_hours − break/60`); it now depends on a
|
||||
computed-stored field, which Odoo chains correctly.
|
||||
|
||||
The attendance form's Break field becomes read-only (consistent with "fully automatic").
|
||||
`views/hr_attendance_views.xml` updated accordingly.
|
||||
|
||||
### 4.4 Removals (the de-duplication)
|
||||
|
||||
| Remove | File | Replaced by |
|
||||
|---|---|---|
|
||||
| `_apply_break_deduction` method + its 3 call sites | `controllers/clock_api.py:161`, `controllers/clock_kiosk.py:158`, `controllers/clock_nfc_kiosk.py:381` | the compute |
|
||||
| cron's `x_fclk_break_minutes` write | `models/hr_attendance.py:343-346` | the compute |
|
||||
| penalty's `current_break + deduction` write | `controllers/clock_api.py:140-144` | the compute's `Σ penalty_minutes` |
|
||||
| setting `fclk_break_threshold_hours` + `fusion_clock.break_threshold_hours` | `models/res_config_settings.py:39`, seed in `data/ir_config_parameter_data.xml` | per-rule `break1_after_hours` |
|
||||
|
||||
**Kept and untouched:** `hr.employee._get_fclk_break_minutes()`, `fusion_clock.default_break_minutes`,
|
||||
`fusion.clock.shift.break_minutes`, `fusion.clock.schedule.break_minutes` — these are the
|
||||
**planned** break (used to compute scheduled `planned_hours`), a separate concept from the
|
||||
actual worked-hours deduction. Decision #4 keeps them out of the deduction path.
|
||||
|
||||
**Kept:** the `auto_deduct_break` master toggle (now gates the statutory portion only).
|
||||
|
||||
### 4.5 UI / security / data
|
||||
|
||||
- **Menu:** *Fusion Clock → Configuration → Break Rules* (new `ir.actions.act_window` +
|
||||
list/form views in `views/clock_break_rule_views.xml`), gated to
|
||||
`group_fusion_clock_manager`. Add the menu item in `views/clock_menus.xml`.
|
||||
- **Security:** `security/ir.model.access.csv` — `fusion.clock.break.rule`: manager =
|
||||
full CRUD; team-lead/user = read (or none — the resolver uses sudo, so no direct grant
|
||||
is strictly required; grant manager full, no portal access).
|
||||
- **Manifest `data`:** add `data/clock_break_rule_data.xml` (after security, before crons)
|
||||
and `views/clock_break_rule_views.xml` (with the other config views, before
|
||||
`clock_menus.xml`). Bump `version` to `19.0.4.1.0`.
|
||||
|
||||
## 5. Edge cases
|
||||
|
||||
- **No rule resolvable** (no province match, no default) → statutory 0. The seeded default
|
||||
prevents this in practice.
|
||||
- **Company has no `state_id`** → falls to the default rule.
|
||||
- **`break2_after_hours <= break1_after_hours`** → blocked by constraint.
|
||||
- **Penalty created after clock-out** → `x_fclk_penalty_ids` change retriggers the compute;
|
||||
final break = statutory + penalty (preserves today's combined-field semantics, reported
|
||||
as one "Break" number).
|
||||
- **Open attendance** (no checkout) → break 0; recomputed when it's closed.
|
||||
- **Worked hours exactly at a boundary** (5.0h, 10.0h) → tier fires (`>=`).
|
||||
|
||||
## 6. Migration / upgrade
|
||||
|
||||
- On upgrade, flipping `x_fclk_break_minutes` to `store=True compute` makes Odoo recompute
|
||||
it for all existing rows. For closed attendances this re-derives break from
|
||||
`worked_hours` + linked penalties using the seeded Ontario rule — which is the intended
|
||||
corrected value. Any historical hand-edited break values are replaced (acceptable per
|
||||
Decision #3, "fully automatic"). Call this out in the change log.
|
||||
- No `pre`/`post` migration script is required; the recompute is automatic. (If we later
|
||||
want to *avoid* touching very old periods, a guarded post-migrate could pin them — out of
|
||||
scope for now.)
|
||||
|
||||
## 7. Testing (`tests/test_break_rules.py`, `@tagged('-at_install','post_install','fusion_clock')`)
|
||||
|
||||
1. `break_minutes_for`: 4.99h→0, 5.0h→30, 9.99h→30, 10.0h→60.
|
||||
2. Resolver: company in Ontario → Ontario rule; company with unset/other province → default.
|
||||
3. **Manual backend create** of a closed attendance (check_in/out spanning 6h) → break 30,
|
||||
net = worked − 0.5. **Manual edit** extending to 10h → break 60. (This is the headline
|
||||
gap; assert it directly via `env['hr.attendance'].create(...)`, not via a controller.)
|
||||
4. Penalty additivity: 6h + one 15-min penalty record → break 45.
|
||||
5. Master toggle off (`auto_deduct_break=False`) → statutory 0 (penalty-only).
|
||||
6. Constraint: `break2_after_hours <= break1_after_hours` raises.
|
||||
|
||||
Run (note ephemeral ports per repo CLAUDE.md):
|
||||
```
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock \
|
||||
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
|
||||
## 8. Rollout notes
|
||||
|
||||
- **Dual-path write** during dev: edit files in **both** `K:\Github\odoo-modsdev\addons\fusion_clock`
|
||||
(Docker-mounted, for tests) **and** `K:\Github\Odoo-Modules\fusion_clock` (git); commit
|
||||
from the git path only. (Per project memory.)
|
||||
- Live target is **entech** (`odoo-entech`); deploy after local tests pass and user review.
|
||||
- Asset/version bump already covered by the manifest `version` change.
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
None — all four design forks resolved (see §3).
|
||||
@@ -0,0 +1,164 @@
|
||||
# Assessment Visit — bundled, funding-routed assessments
|
||||
|
||||
**Date:** 2026-06-02
|
||||
**Module:** `fusion_portal` (depends on `fusion_claims`, `fusion_tasks`); live on `odoo-westin` (DB `westin-v19`)
|
||||
**Status:** Draft for review
|
||||
**Author:** Brainstormed with Gurpreet (Fusion / Westin Healthcare)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem & goals
|
||||
|
||||
A sales rep visits a client's home **with an occupational therapist (OT) and the client present for only 30–45 minutes**, and the OT's time is the scarcest resource. In that window the team often does more than one assessment — a wheelchair (ADP) plus, opportunistically, accessibility products the rep spots (a ramp at the front steps, a stair lift inside, a tub cutout, a patient lift for transfers). Today each assessment is a **separate, standalone web form** that re-collects the client's details and creates its own sale order, and the front-end forms give the rep **no way to mark a case's funding source** — so March-of-Dimes work silently defaults to private pay and never reaches the MOD pipeline.
|
||||
|
||||
**Goals**
|
||||
|
||||
1. **One visit, many assessments, entered once.** Bundle every assessment from one home visit; capture the client + funding details a single time.
|
||||
2. **Measurement-first.** Capture measurements while the OT is present; defer client/health-card data to after they leave; let the OT sign the ADP application on the spot.
|
||||
3. **Add as you go.** The rep adds an assessment/product the instant they spot it — repeatable, with a location tag (Front / Back / Inside).
|
||||
4. **Route by funding workflow.** On completion the visit emits **one sale order per funding workflow** (ADP, March of Dimes, ODSP, WSIB, private, …) — never one combined SO, and never a separate SO per item within the same funding.
|
||||
5. **Let the rep set funding at assessment time** (the real MOD "tracking" gap).
|
||||
6. **ADP multi-device** with valid-combination rules, including a new **mobility scooter** type and a **home-accessibility hard rule** for power mobility that feeds the accessibility upsell.
|
||||
|
||||
**Non-goals (v1):** voice/dictated entry; rebuilding the measurement math; a new MOD/ADP claim model (the pipelines already exist — we reuse them).
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state (verified against source)
|
||||
|
||||
- **Two assessment models, already two separate SO lineages.** `fusion.assessment` (ADP: rollator/wheelchair/powerchair) and `fusion.accessibility.assessment` (the 7 lift/mod types) each have their own `_create_draft_sale_order` (`assessment.py:587`, `accessibility_assessment.py:751`), their own `x_fc_sale_type`, and their own state machine — ADP's 24-state `x_fc_adp_application_status` vs MOD's 16-state `x_fc_mod_status`. Each guards against a second SO (`accessibility_assessment.py:503-511`). SO back-links are **scalar** Many2one: `assessment_id`, `accessibility_assessment_id` (`fusion_portal/models/sale_order.py:37,48`).
|
||||
- **SOs are born with no order lines.** Specs become a **chatter HTML note** (`_format_assessment_html_table`, `accessibility_assessment.py:815`); a human prices the draft afterward. **No per-type product mapping exists.**
|
||||
- **Funding is modelled but not on the measurement forms.** `x_fc_funding_source` (required, default `direct_private`) on the accessibility model — values `march_of_dimes`, `odsp`, `wsib`, `insurance`, `direct_private`, `other` (`accessibility_assessment.py:71-87`) — is present on the public booking form but **absent from all 7 measurement forms**, so they default to private. Canonical billing type `sale.order.x_fc_sale_type` (`fusion_claims/models/sale_order.py:320`) carries the full set incl. `adp`, `adp_odsp`, `march_of_dimes`, etc.
|
||||
- **MOD tracking already exists** as `x_fc_mod_status` (16 states) + ~60 `x_fc_mod_*` fields (HVMP reference #, vendor code, drawings, PCA, POD, approved/payment amounts, dated audit trail) + MOD views + ~7 wizards + ~40 MOD/ODSP stage emails (`fusion_claims/models/sale_order.py:438,877`). An accessibility assessment funded `march_of_dimes` already lands its SO in this pipeline at `need_to_schedule`. **The gap is purely that the rep can't choose `march_of_dimes` on the form.**
|
||||
- **Emails** are mostly Python-built via the shared `fusion.email.builder.mixin._email_build` (`fusion_tasks/models/email_builder_mixin.py:8`), gated by `ir.config_parameter` `fusion_claims.enable_email_notifications`. Completion email fires from inside `_create_draft_sale_order` (`assessment.py:847`; `accessibility_assessment.py:624`). Stage emails (`_adp_send_stage_email`, `_mod_email_build`, `_odsp_email_build`) are keyed off the SO's funding type + status, so **they keep working per-SO unchanged**.
|
||||
- **Known bug:** backend ADP `action_complete()` sends the authorizer **two** completion emails (template pair at `assessment.py:494` + inline report via `:847`). Must consolidate before fanning out across a visit.
|
||||
|
||||
---
|
||||
|
||||
## 3. The design
|
||||
|
||||
### 3.1 The Visit aggregate (only net-new model)
|
||||
|
||||
`fusion.assessment.visit` — the hub for one home visit.
|
||||
|
||||
- **Client/context, entered once:** `partner_id`, address fields, `visit_date`, `sales_rep_id`, `authorizer_id` (OT), `x_fc_funding_source`-style default, `state` (`measuring` → `client_pending` → `done`).
|
||||
- **Links to its assessments:** `adp_assessment_ids` (One2many → `fusion.assessment`) and `accessibility_assessment_ids` (One2many → `fusion.accessibility.assessment`). Each assessment gains `visit_id`.
|
||||
- **Links to its sale orders:** `sale_order_ids` (One2many → `sale.order`) — one per funding workflow it produced.
|
||||
- On the SO side, add `visit_id`. Each assessment already carries `sale_order_id` (Many2one — `accessibility_assessment.py:153`, `assessment.py:422`), so several same-funding assessments can already point at one SO; the redundant **scalar** `assessment_id` / `accessibility_assessment_id` on the SO (`fusion_portal/models/sale_order.py:37,48`) become **One2many** (or are dropped in favour of the `sale_order_id` reverse) so an SO no longer assumes a single source assessment.
|
||||
|
||||
Client info moves to the Visit as the single source of truth; the per-assessment `client_name`-required gate is relaxed (the model keeps the field for back-compat / standalone use but the Visit flow fills it from `partner_id`).
|
||||
|
||||
### 3.2 Add-as-you-go workspace (portal UX)
|
||||
|
||||
A portal "visit workspace" (reps are portal users, tablet-first):
|
||||
|
||||
- Always-present **"+ Add"** → pick a type + location tag (Front / Back / Inside / custom) → drop **straight into the existing measurement form** for that type. No client paperwork required to start.
|
||||
- Each added assessment is a **card** showing type, location, status (To measure / Measured / Signed), and — once priced — its amount.
|
||||
- **Measurement-first:** the forms render with client fields hidden/optional; a **deferred "Client + funding" step** is completed after the OT leaves and is shared by every item.
|
||||
- The **OT signs the ADP application (Page 11)** inline on the wheelchair/ADP item, on-site, independent of client demographics (reuse `portal_assessment_express` Page-11 section + signature pad).
|
||||
- Mockups (for reference, in repo `docs/mockups/` if committed): `fusion_portal_new_approach_mockup.html`.
|
||||
|
||||
### 3.3 Multi-instance + location tags
|
||||
|
||||
Any type can be added **more than once**, each its own assessment record with a **location label** ("Main stairs", "Basement", "Front porch"). Two stair lifts = two assessment records (→ two lines on the same funding SO; see §3.6). A **"Same as the previous"** action copies shared options so the rep only re-enters the differing measurements.
|
||||
|
||||
### 3.4 Per-item funding selector — the MOD gap fix
|
||||
|
||||
Expose `x_fc_funding_source` on **each accessibility assessment** in the flow: **Private Pay / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other**. This one field drives the existing `sale_type_map` → `x_fc_sale_type` → correct pipeline (MOD 16-state tracker, ODSP, hardship, …). Defaults to the previous item's funding so an all-MOD visit isn't re-picked each time. **ADP/wheelchair items are fixed to ADP** (no picker). This is the minimal change that closes the "can't mark a case as March of Dimes" gap — no new tracking model.
|
||||
|
||||
> **Patient lift** is an accessibility/equipment item that uses this same picker — funded by March of Dimes, **ODSP**, or **Hardship** (e.g. Toronto residents), so its funding is chosen per case, not fixed.
|
||||
> **`sale_type_map` gap:** `x_fc_funding_source` currently lacks `hardship` while `x_fc_sale_type` already has it (`sale_order.py:320`) — add `hardship` to the picker + a `sale_type_map` entry (`accessibility_assessment.py:771`), and review the map so every offered funding routes to a real `x_fc_sale_type`.
|
||||
> **MOD funding cap** applies to MOD items — see Resolved decision 1 (§4).
|
||||
|
||||
### 3.5 ADP multi-device + combinations + scooter + home-access rule
|
||||
|
||||
**Multi-device ADP order.** Today one ADP device per order; the visit allows a **valid combination** of ADP devices for one client, all landing on the **one ADP SO**. Each ADP device is an item; the combination check runs across the visit's ADP items.
|
||||
|
||||
**Device categories:** Walker/Rollator · Manual Wheelchair · Power Wheelchair · **Scooter (new)**.
|
||||
|
||||
**Combination rules (confirmed):**
|
||||
|
||||
| Combination | Allowed? |
|
||||
|---|---|
|
||||
| Any single device | ✓ |
|
||||
| Walker + Manual Wheelchair | ✓ |
|
||||
| Walker + Power Wheelchair | ✓ |
|
||||
| Walker + Scooter | ✓ |
|
||||
| Manual + Power Wheelchair | ✗ |
|
||||
| Power Wheelchair + Scooter | ✗ |
|
||||
| Manual Wheelchair + Scooter | ✗ |
|
||||
| Two walkers / any duplicate | ✗ |
|
||||
|
||||
Rule in words: **at most one "seated-mobility" device** {manual wheelchair, power wheelchair, scooter}, **optionally one walker/rollator alongside, no duplicates.** Enforced when adding/saving an ADP device.
|
||||
|
||||
**Scooter (new ADP type) fields:** `client_weight` (exists), scooter type, **maximum travel range**, and the home-accessibility check (below). Gets its own measurement section in the ADP form, mirroring the rollator/wheelchair/powerchair sections.
|
||||
|
||||
**Power-mobility home-accessibility hard rule.** For **scooter and power wheelchair**, a required check: *"Is the home accessible enough for the device to be used **inside and outside** the home independently — no lifting, not left outside/in the garage?"* ADP will not fund power mobility a home can't accommodate. If the answer is **No**, the visit **flags an accessibility need** and prompts the rep to add an accessibility item (ramp / porch lift, typically March of Dimes) to remediate. This is the explicit bridge between the ADP power-mobility item and the accessibility/MOD upsell.
|
||||
|
||||
> **The power-wheelchair form is already well-optimized — do NOT change its fields.** The *only* addition there is this home-accessibility warning. The new **scooter** type gets its own section (fields above); the manual-wheelchair and rollator sections are unchanged.
|
||||
|
||||
### 3.6 Funding-workflow grouping → one SO per workflow
|
||||
|
||||
On visit completion, group its assessments by **funding workflow** (`x_fc_sale_type`) and create **one SO per group**:
|
||||
|
||||
- All `march_of_dimes` items (stair lift + porch lift + tub cutout, or two stair lifts) → **one MOD SO, multiple lines** (funding permitting).
|
||||
- All ADP devices (the valid combination) → **one ADP SO**.
|
||||
- Private / ODSP / WSIB / insurance → their own SO each.
|
||||
- A separate SO appears **only when the case type changes**, never per-item within a funding.
|
||||
|
||||
Refactor the two per-model `_create_draft_sale_order` routines into a **shared, group-aware builder** that takes a set of same-funding assessments and produces one SO, branching on funding type to stamp the right starting status field (`x_fc_adp_application_status` for ADP, `x_fc_mod_status` for MOD, etc. — mirroring `assessment.py:600-622`) and the right links. **Reuse the existing MOD/ADP/ODSP pipelines unchanged.**
|
||||
|
||||
### 3.7 Emails
|
||||
|
||||
- Reuse `fusion.email.builder.mixin` and the existing per-funding stage emails (they're keyed off SO type + status, so per-SO they keep working).
|
||||
- **Move the completion send to per-SO** inside the new builder (not per-assessment), and **dedupe recipients**, so a 3-item visit doesn't emit 3–6 completion emails.
|
||||
- **Fix the existing duplicate** (authorizer gets two completion emails on backend ADP completion) as part of this.
|
||||
- Make `enable_email_notifications` gating consistent across the sends the visit touches.
|
||||
|
||||
### 3.8 Reused vs net-new
|
||||
|
||||
- **Reused, largely untouched:** the 7 accessibility measurement forms + their JS/Python calc; the ADP Express form + Page-11 signature; the MOD/ADP/ODSP pipelines, views, wizards, and stage emails; the email branding mixin.
|
||||
- **Net-new:** the `fusion.assessment.visit` model + workspace UI; per-item funding selector on the accessibility forms; the group-aware SO builder + link-cardinality change; ADP multi-device + combination validation; scooter type + fields; power-mobility home-access rule + cross-sell flag; completion-email consolidation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Resolved decisions
|
||||
|
||||
1. **MOD funding cap — documented rule, light-touch in v1.** March of Dimes covers **up to $15,000 per person, lifetime**, income-gated: if the client's income is **under** that year's threshold (the threshold changes annually), MOD funds the full $15k; if **over**, MOD may **deny or partially approve**. **v1:** surface this cap as a reminder on MOD items and capture an *"income under MOD threshold? (yes / no / unknown)"* flag so the rep can judge — **do not** auto-compute lifetime used-vs-remaining across the client's prior MOD orders (the SO's existing `x_fc_mod_*` approved/payment fields already record per-order amounts). **Future:** yearly-threshold config + automatic lifetime-remaining tracking + a hard warning.
|
||||
2. **No auto pricing / products in v1.** The visit creates a **draft** SO per funding workflow and appends each assessment's specs to that SO's chatter (today's pattern); **the sales rep builds the quotation lines manually.** One SO can hold many items. No per-assessment-type product mapping. (Auto-pricing is a future expansion.)
|
||||
3. **Patient-lift funding is chosen per case** via the funding picker — March of Dimes, **ODSP**, or **Hardship** (e.g. Toronto residents) all fund it; it is not fixed (see §3.4).
|
||||
4. **Power-wheelchair form unchanged** — already well-optimized; the only addition is the **home-accessibility warning** (device usable **inside and outside** the home). The home-access rule applies to **scooter (new type, new section) and power wheelchair (warning only)**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phasing
|
||||
|
||||
- **Phase 1 — Funding correctness + visit backbone:** `fusion.assessment.visit`, link-cardinality change, **funding selector on the accessibility forms** (incl. Hardship; patient-lift routing), **MOD $15k-cap reminder + income-threshold flag** (informational), group-and-route to per-workflow **draft** SOs (specs to chatter, manual pricing) reusing existing pipelines, completion-email consolidation + duplicate fix. *(Delivers the MOD-routing fix and the multi-SO split.)*
|
||||
- **Phase 2 — ADP expansion:** multi-device ADP order + combination validation, **scooter** type + fields, power-mobility **home-access hard rule** + accessibility cross-sell prompt.
|
||||
- **Phase 3 — Seamless field UX:** the full add-as-you-go workspace, measurement-first deferral, location tags, "same as previous", OT on-site sign-off polish.
|
||||
- **Later:** product-line auto-pricing, MOD funding-cap tracking, voice/quick entry.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks (from investigation)
|
||||
|
||||
- **Duplicate completion emails** already live on the ADP backend path — fix before fan-out (§3.7).
|
||||
- **Scalar back-links + double-SO guards** assume one SO per assessment; grouping breaks them — must move to `visit_id` / One2many and make the guard visit-aware.
|
||||
- **Inconsistent `enable_email_notifications`** — template sends ignore the kill-switch; don't route new traffic through templates without honoring it.
|
||||
- **Label drift** `x_fc_funding_source` vs `x_fc_sale_type` (`insurance`="Private Insurance" vs "Insurance"; `direct_private`="Private Pay (Direct)" vs "Direct/Private") — keys match so routing works; align labels in any shared UI.
|
||||
- **Unreachable funding types from accessibility:** `sale_type_map` (`accessibility_assessment.py:771`) covers 6 values; decide which funding types each assessment type may emit.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files in scope
|
||||
|
||||
- `fusion_portal/models/assessment.py` — ADP `_create_draft_sale_order` (:587), completion email (:847), multi-device + scooter + home-access.
|
||||
- `fusion_portal/models/accessibility_assessment.py` — accessibility `_create_draft_sale_order` (:751), `action_complete` (:493), completion email (:624), funding routing.
|
||||
- `fusion_portal/models/sale_order.py` — back-links (:37,:48) → `visit_id` / One2many.
|
||||
- `fusion_portal/models/visit.py` — **new** `fusion.assessment.visit`.
|
||||
- `fusion_portal/views/portal_accessibility_forms.xml` + `portal_assessment_express.xml` — funding selector, scooter section, home-access check; workspace shell.
|
||||
- `fusion_portal/controllers/portal_main.py` (`/my/accessibility/save` :2482) + `portal_assessment.py` — visit-aware save/group/route.
|
||||
- `fusion_claims/models/sale_order.py` — reuse `x_fc_sale_type` (:320), `x_fc_mod_status` (:438), stage emails (:6876,:9038,:10063); no pipeline rebuild.
|
||||
- `fusion_tasks/models/email_builder_mixin.py` — reuse for any new visit emails.
|
||||
|
||||
**Deployment note:** `fusion_portal` is live on `odoo-westin` (`westin-v19`, container `odoo-dev-app`). Ship per the rename/deploy procedure (backup → code sync → `-u fusion_portal` → cache-bust → restart → verify).
|
||||
298
docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md
Normal file
298
docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# fusion_maintenance — Design Spec
|
||||
|
||||
> Automated preventive‑maintenance follow‑ups + self‑serve real‑time booking for Westin
|
||||
> medical mobility equipment (stair lifts, porch lifts, lift chairs, wheelchairs, power
|
||||
> wheelchairs/scooters), to keep clients on schedule and turn service into recurring revenue.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Status** | Design **approved** (brainstorm dialogue 2026‑06‑02). Ready for implementation plan. |
|
||||
| **Implemented by** | **Extending `fusion_repairs`** (no new module). Version bump. |
|
||||
| **Target instance** | Westin production — host `odoo-westin` (192.168.1.40), container `odoo-dev-app`, DB `westin-v19`. One company / one DB running `fusion_claims` (live) + `fusion_repairs` (to be deployed). |
|
||||
| **Relates to** | [`docs/plans/fusion_maintenance_brainstorm.md`](../../plans/fusion_maintenance_brainstorm.md) (brief + Step 0 + sizing), [`2026-05-20-fusion-repairs-design.md`](2026-05-20-fusion-repairs-design.md) (base module). |
|
||||
| **Next step** | `writing-plans` → implementation plan. **No code until the plan is written and this spec is reviewed.** |
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Westin sells/services mobility equipment that needs preventive maintenance every **1–6 months
|
||||
depending on the product**. Today there is no system keeping clients on schedule. We want:
|
||||
|
||||
1. The system **automatically emails the client** when a unit is due for maintenance.
|
||||
2. The client can **book the visit themselves** (real‑time, self‑serve, no login) **or** call the
|
||||
office and staff book it for them.
|
||||
3. The booking **lands in our scheduling/calendar** as a real technician job.
|
||||
4. The **technician accesses and updates the maintenance log** on the visit; the system keeps the
|
||||
full history per unit.
|
||||
5. The **next maintenance is auto‑rescheduled** → recurring loop.
|
||||
6. The client is **told the cost** up front.
|
||||
7. Outcome: clients stay on track **and** Westin gains **recurring revenue**.
|
||||
8. Design/UX stays **consistent with `fusion_claims`** (branded emails, `x_fc_` naming, Canadian
|
||||
English, `$`+`currency_id`).
|
||||
|
||||
## 2. Locked decisions (from the brainstorm)
|
||||
|
||||
| # | Decision | Choice | Why |
|
||||
|---|----------|--------|-----|
|
||||
| D1 | Separate module vs. part of `fusion_repairs` | **Build into `fusion_repairs`** | The maintenance engine already lives there (~90% built); a separate module would duplicate it. fusion_repairs already owns the equipment categories, `repair.order`, technician tasks, service plans, and the Westin rate card. |
|
||||
| D2 | Pricing / revenue model | **Flat fee per equipment type** | Transparent cost to show the client; recurring per‑visit revenue. Configured per equipment **category** with per‑product override. |
|
||||
| D3 | Enrollment scope | **New sales + backfill existing install base** | The recurring revenue and "keep clients on track" value is in the *existing* base, not just future sales. |
|
||||
| D4 | Booking engine | **Technician‑aware picker on `fusion_tasks`** (NOT Enterprise `appointment`) | Clients see only slots a qualified tech is genuinely free for (route/skill‑aware); booking creates the technician task directly — one scheduling world, no appointment↔task bridge. Bonus: **no Enterprise dependency → Community‑testable locally.** |
|
||||
|
||||
## 3. Grounding (verified, not assumed)
|
||||
|
||||
### 3.1 What `fusion_repairs` ALREADY has (reuse — do not rebuild)
|
||||
Source: [`fusion_repairs/models/maintenance_contract.py`](../../../fusion_repairs/models/maintenance_contract.py), [`technician_task.py`](../../../fusion_repairs/models/technician_task.py), [`repair_service_plan.py`](../../../fusion_repairs/models/repair_service_plan.py), `cloud.md`.
|
||||
|
||||
- `fusion.repair.maintenance.contract` — partner/product/lot/original_SO, `interval_months`,
|
||||
`last_service_date`, `next_due_date`, state machine (`draft/active/paused/cancelled`),
|
||||
`booking_token` (unique), `last_reminder_band`, `booking_repair_id`. `roll_next_due_date()`
|
||||
advances the cycle correctly via `relativedelta`.
|
||||
- Reminder cron `cron_send_due_reminders` — daily, **30/7/1‑day** bands, per‑band dedup, queued
|
||||
branded email `email_template_maintenance_due_reminder` with the tokenized link.
|
||||
- Public booking controller `/repairs/maintenance/book/<token>` — `auth='public'`, token‑validated,
|
||||
already‑booked guard, thanks page.
|
||||
- `create_repair_from_booking()` — spawns a `repair.order` (`x_fc_intake_source='client_portal'`),
|
||||
links `x_fc_maintenance_contract_id`, dedups.
|
||||
- **Roll‑forward** on technician task completion ([`technician_task.py:88`](../../../fusion_repairs/models/technician_task.py:88)): when a `task_type='maintenance'` task → `status='completed'`, sets `last_service_date`, calls `roll_next_due_date()`, posts chatter. **This is the recurring loop.**
|
||||
- Pre‑paid **service‑plan subscriptions** (`fusion.repair.service.plan.subscription`) wired to
|
||||
`sale.order.action_confirm()` + visit burn engine (revenue primitive; optional here).
|
||||
- **Rate card** (`fusion.repair.callout.rate`, standard vs `lift_elevating`), `repair.order.x_fc_quote_total`.
|
||||
- **Equipment category taxonomy** (`fusion.repair.product.category`): stairlift / porch_lift /
|
||||
lift_chair flagged `equipment_class=lift_elevating`, `safety_critical=True`.
|
||||
- **Inspection certificate** (`fusion.repair.inspection.certificate`, M1 — Done): PDF + expiry cron.
|
||||
- Visit‑report wizard (signature, parts, labour timer).
|
||||
- `product.template.x_fc_maintenance_interval_months` (exists, [product_template.py:23](../../../fusion_repairs/models/product_template.py:23)).
|
||||
- `fusion_tasks` availability engine: [`_find_next_available_slot(tech_id, date, ...)`](../../../fusion_tasks/models/technician_task.py:544) and [`_get_available_gaps(tech_id, date, ...)`](../../../fusion_tasks/models/technician_task.py:664) — **route‑aware** (tech start address + geocoding + travel). Tech skills on `res.users.x_fc_repair_skills`.
|
||||
|
||||
### 3.2 The 4 gaps this spec closes
|
||||
1. **Contract auto‑creation trigger is dead code** — `_spawn_maintenance_contracts()` is defined on
|
||||
`sale.order` ([maintenance_contract.py:198](../../../fusion_repairs/models/maintenance_contract.py:198)) but **never called**. No `action_confirm` override invokes it → no contracts exist today.
|
||||
2. **No real booking** — the booking page is a bare `<input type="date">` ("a team member will call
|
||||
to confirm"); no availability, no slots, no calendar/task. **This is the main new build.**
|
||||
3. **No cost shown to the client** anywhere (email or booking page).
|
||||
4. **No auto tech‑task creation, no structured maintenance log, no office‑follow‑up crons**
|
||||
(`ir.config_parameter` toggles exist; no cron/Python).
|
||||
|
||||
### 3.3 Install‑base sizing (Westin live, 2026‑06‑02)
|
||||
- Serial numbers are captured **~only on real equipment** (parts have 0 serials) → `x_fc_serial_number`
|
||||
is a de‑facto "trackable unit" marker and the natural **idempotency key**.
|
||||
- ADP‑side base ≈ **138 serial‑tracked units / ~136 customers** (walkers 68, wheelchairs 45, power
|
||||
bases 7, scooters 4, +14 no‑device‑type). Funders: adp 109, direct_private 13, adp_odsp 10,
|
||||
march_of_dimes 7. Deliveries 2022‑10 → 2026‑05.
|
||||
- **Lifts (sized 2026‑06‑02; name‑based, approximate)** — a LARGE base in Westin's Odoo: stair lifts
|
||||
~254 customers (416 lines incl. accessories), porch/VPL ~30 customers (75 lines), lift chairs ~41
|
||||
customers (47 lines) — real products (Access BDD, Handicare, Serenity VPL, Pride VivaLift). **But lift
|
||||
serial coverage is ~0** (12/416 stairlift lines, 0 VPL, 2 lift‑chair). So the serial‑as‑unit‑key
|
||||
approach that works for ADP wheelchairs **does NOT work for lifts** — lifts must be keyed by
|
||||
(partner + base‑unit product + sale line), excluding accessory lines (curves, rails, remotes, charging
|
||||
stations, rentals). This splits the backfill into two regimes (§6.2).
|
||||
- Two backfill data gaps: 14 units have no device_type (need product/manual category); non‑ADP units
|
||||
lack `x_fc_adp_delivery_date` (need an invoice/order‑date fallback anchor).
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
Extend `fusion_repairs`. No new module, no new top‑level dependency for the core flow (booking uses
|
||||
`fusion_tasks`, already a hard dep; pricing/Poynt already deps). The optional `fusion_claims` read
|
||||
for the wheelchair backfill is a **soft** dependency (guarded `if 'fusion.claims' model present`),
|
||||
so `fusion_repairs` still installs/test‑runs without `fusion_claims` on local dev.
|
||||
|
||||
Reuse map: contract engine (extend), `fusion.technician.task` (booking target + availability +
|
||||
roll‑forward), `repair.order` (visit container/pricing/Poynt), inspection certificate (lift
|
||||
compliance), visit‑report wizard (extend with checklist), branded email pattern, rate card.
|
||||
|
||||
## 5. Data model
|
||||
|
||||
All new fields `x_fc_`, Canadian English labels, Monetary = `$` + `currency_id`.
|
||||
|
||||
### 5.1 Maintenance policy — on `fusion.repair.product.category` ("per equipment type")
|
||||
- `x_fc_maintenance_enabled` (Boolean) — is this category maintainable?
|
||||
- `x_fc_maintenance_interval_months` (Integer) — default cadence (1–6+).
|
||||
- `x_fc_maintenance_fee` (Monetary, `currency_id`) — the **flat fee** shown to the client.
|
||||
- `x_fc_maintenance_skill_id` — the technician skill the booking matches on (maps to
|
||||
`res.users.x_fc_repair_skills`). **If skills are already category‑based** (a tech's
|
||||
`x_fc_repair_skills` are equipment categories), drop this field and simply match technicians whose
|
||||
skills include *this* category — confirm the skills representation before modelling (§15).
|
||||
- `x_fc_maintenance_service_product_id` (M2O `product.product`, optional) — the service product used
|
||||
when drafting the priced invoice/SO line; falls back to a generic "Maintenance visit" product.
|
||||
|
||||
**Per‑product override:** `product.template.x_fc_maintenance_interval_months` (exists) +
|
||||
new `product.template.x_fc_maintenance_fee` (Monetary, optional). Resolution order at contract
|
||||
creation: product override → category policy.
|
||||
|
||||
### 5.2 Extend `fusion.repair.maintenance.contract`
|
||||
- `x_fc_maintenance_fee` (Monetary) — resolved price snapshot, shown to client.
|
||||
- `x_fc_source` (Selection: `sale` / `backfill` / `claims` / `manual`).
|
||||
- `x_fc_source_sale_line_id` (M2O `sale.order.line`) — provenance + idempotency.
|
||||
- `x_fc_device_serial` (Char, indexed) — idempotency key (esp. for claims/backfill where no lot).
|
||||
- `x_fc_policy_category_id` (M2O `fusion.repair.product.category`).
|
||||
- Constraint: at most one **active** contract per `(x_fc_device_serial)` (or per source sale line
|
||||
when serial absent) — declarative `models.Constraint` / partial `models.Index`.
|
||||
|
||||
### 5.3 New `fusion.repair.maintenance.visit` (the log)
|
||||
A structured, queryable per‑visit record — *not* buried in chatter.
|
||||
- `contract_id` (M2O, required), `technician_task_id` (M2O `fusion.technician.task`),
|
||||
`repair_order_id` (M2O `repair.order`, the container), `partner_id`, `product_id`, `lot_id`.
|
||||
- `visit_date`, `technician_id` (res.users), `state` (`scheduled/in_progress/done/no_show/cancelled`).
|
||||
- `checklist_line_ids` (O2M to `fusion.repair.maintenance.checklist.line`: label, result
|
||||
`pass/fail/na`, note) — items seeded **per equipment category** (lift checklist ≠ wheelchair
|
||||
checklist).
|
||||
- `findings` (Html, `Markup()`), `parts_note`, `x_fc_fee` (Monetary), `signature` (Binary),
|
||||
`inspection_certificate_id` (M2O — set for `safety_critical` categories).
|
||||
- "log/history" view = the list of visits per contract/unit (smart button on contract + partner).
|
||||
|
||||
## 6. Enrollment — two paths
|
||||
|
||||
### 6.1 Path A — new sales (fix the dead trigger)
|
||||
Override `sale.order.action_confirm()` to call `_spawn_maintenance_contracts()` (reuse the existing
|
||||
method; fix + wire it). For each confirmed line whose product/category has
|
||||
`x_fc_maintenance_enabled` and a serial/lot:
|
||||
- Create one `active` contract per unit (respect quantity), `x_fc_source='sale'`,
|
||||
`x_fc_source_sale_line_id` set, serial captured.
|
||||
- `next_due_date = (delivery/commitment date or date_order) + interval` (fallback chain handles
|
||||
non‑ADP units lacking a delivery date).
|
||||
- Resolve + snapshot `x_fc_maintenance_fee`.
|
||||
- **Idempotent**: skip if an active contract already exists for the serial / sale line.
|
||||
|
||||
### 6.2 Path B — backfill existing install base (one‑time wizard, idempotent)
|
||||
`fusion.repair.maintenance.backfill.wizard`:
|
||||
- **Scan** historical `sale.order.line` for products whose category/product is maintenance‑enabled and
|
||||
were delivered. **Two unit‑identity regimes**, because lifts carry no serials (§3.3):
|
||||
- **Serial‑tracked** (ADP wheelchairs/power chairs, via the `fusion_claims` serial/`device_type` data
|
||||
— soft dep, guarded; map ADP `device_type` → maintenance category): require a serial, **dedup by serial**.
|
||||
- **Non‑serial** (lifts — stair/porch/VPL/lift‑chair): do **NOT** require a serial. One contract per
|
||||
**base‑unit line**, **dedup by (partner + maintainable product + source sale line)**. The per‑product
|
||||
`x_fc_maintenance_enabled` flag is what includes base units and **excludes accessory lines** (curves,
|
||||
rails, remotes, charging stations, rentals) — only the lift itself gets a contract, not its add‑ons.
|
||||
- **Stagger** the first `next_due_date` across a configurable window (e.g. spread overdue units over
|
||||
N weeks) so years of equipment don't all email on day one.
|
||||
- **Dry‑run first**: produce a report (counts by category, # new vs already‑enrolled, # skipped for
|
||||
missing serial/date, the stagger schedule). Nothing is created or emailed until the operator
|
||||
approves and runs "Execute".
|
||||
- Anchor fallback for units with no delivery date: invoice date → order date → today.
|
||||
|
||||
## 7. Booking flow (the main build)
|
||||
|
||||
### 7.1 Client self‑serve (no login)
|
||||
1. Reminder email (existing branded template, **+ fee line added**) → tokenized link.
|
||||
2. Public slot‑picker page (extend the existing `/repairs/maintenance/book/<token>` route; replace
|
||||
the date input). The page:
|
||||
- Resolves the contract from the token; shows unit + **flat fee** ("$X + applicable tax").
|
||||
- Computes candidate technicians = users whose `x_fc_repair_skills` include the policy's
|
||||
`x_fc_maintenance_skill_id`.
|
||||
- Calls `fusion_tasks` `_get_available_gaps` / `_find_next_available_slot` per candidate tech over
|
||||
the next ~2–3 weeks, ranked by **proximity** to the client address → presents a short list of
|
||||
real open slots (date + window + implied tech).
|
||||
3. Client picks a slot → POST confirm:
|
||||
- **Re‑validate** the slot is still free (gap check) — if taken/expired, re‑render slots with a
|
||||
gentle notice (prevents double‑booking).
|
||||
- Create a `fusion.technician.task` (`task_type='maintenance'`) on that slot, **assigned to the
|
||||
qualified tech** (auto‑assignment by availability+skill), linked to the contract.
|
||||
- Spawn/link the maintenance‑type `repair.order` (container) + the `fusion.repair.maintenance.visit`
|
||||
(state `scheduled`, checklist seeded from the category).
|
||||
- Send the branded confirmation email (date/window/tech, fee, what to expect).
|
||||
- Set `booking_repair_id` (dedup).
|
||||
4. **No‑slot fallback:** if no qualified tech/slot in range → show "request a callback" → create an
|
||||
office activity. Never a dead end.
|
||||
|
||||
### 7.2 Office books on the client's behalf
|
||||
- A **"Book maintenance"** action on the `fusion.repair.maintenance.contract` form opens the same
|
||||
slot‑picker logic in the backend (office books while on the phone).
|
||||
- The existing dispatch board remains available for manual scheduling/override.
|
||||
|
||||
### 7.3 Token security fix
|
||||
On `roll_next_due_date()`, **regenerate `booking_token`** (currently it is not regenerated, so an
|
||||
old link stays valid across cycles). Old token → friendly "link expired" page.
|
||||
|
||||
## 8. Cost & revenue
|
||||
|
||||
- The **flat fee** (`x_fc_maintenance_fee`) is shown in **both** the reminder email and the
|
||||
slot‑picker page, Canadian English, `$` + tax note.
|
||||
- On booking, draft a priced line (SO/invoice) using `x_fc_maintenance_service_product_id` (or the
|
||||
generic visit product) at the contract's fee. Payment options: **pay‑at‑door via `fusion_poynt`**
|
||||
(existing `action_collect_payment` on the repair) or invoice after the visit.
|
||||
- Recurring revenue = one priced visit per cycle; the roll‑forward arms the next cycle automatically.
|
||||
(Pre‑paid annual plan upsell via the existing subscription engine is out of v1 — §11.)
|
||||
|
||||
## 9. Maintenance log & the recurring loop
|
||||
|
||||
- The technician fills the visit via the **extended visit‑report wizard** (existing tool) — checklist
|
||||
results, findings, parts, signature — which writes the `fusion.repair.maintenance.visit` record.
|
||||
- For `safety_critical` categories (lifts), completing the visit **issues an inspection certificate**
|
||||
(reuse M1) and links it on the visit — the log doubles as compliance proof.
|
||||
- On task `status='completed'` → existing **roll‑forward**: `last_service_date=today`,
|
||||
`next_due_date += interval`, reset `last_reminder_band`, **regenerate token**, visit → `done`.
|
||||
- Next cycle's reminder fires automatically when `next_due_date` re‑enters the 30‑day band.
|
||||
|
||||
## 10. Office follow‑up crons (toggle‑gated, exist as config only today)
|
||||
- **Unbooked**: reminder sent, no booking after N days → office call activity on the contract.
|
||||
- **Overdue**: `next_due_date` passed with no completed visit in the cycle → escalation activity.
|
||||
- Driven by the existing `ir.config_parameter` toggles in `data/ir_config_parameter_data.xml`.
|
||||
- Per‑row **savepoint** isolation inside the cron loop (no `cr.commit()` in tests — CLAUDE.md #14).
|
||||
|
||||
## 11. Out of scope (v1 — YAGNI)
|
||||
- SMS reminders / two‑way SMS booking (needs `fusion_ringcentral`).
|
||||
- Logged‑in `/my/equipment` client portal (X5).
|
||||
- Pre‑paid annual maintenance‑plan auto‑upsell at booking.
|
||||
- Full multi‑stop route optimization / batching (we use per‑tech availability + proximity ranking,
|
||||
not a global optimizer).
|
||||
- ADP funder re‑billing of maintenance (maintenance is private‑pay flat fee in v1).
|
||||
|
||||
## 12. Error handling & edge cases
|
||||
- **Double‑booking:** re‑validate the gap at confirm; lose the race → re‑show slots.
|
||||
- **Token:** per‑cycle regeneration; invalid/expired/already‑booked → friendly pages (exist, extend).
|
||||
- **No qualified tech / no slots:** callback fallback, not an error page.
|
||||
- **Backfill:** dry‑run + report; strict serial dedup; stagger; fallback anchor chain; never email on
|
||||
dry‑run.
|
||||
- **Missing data:** units with no device_type/category → excluded from auto‑backfill, listed in the
|
||||
report for manual enrollment.
|
||||
- **Audit on failure paths** (if any "booking failed" row is written in an `except`): use a separate
|
||||
`self.env.registry.cursor()` so it survives rollback (CLAUDE.md audit rule).
|
||||
- **`message_post` HTML** bodies wrapped in `Markup()` (CLAUDE.md).
|
||||
|
||||
## 13. Testing
|
||||
`fusion_repairs/tests/` (none exist today). Local dev is **Community** and — because we chose
|
||||
`fusion_tasks` over Enterprise `appointment` — the **entire feature is Community‑testable** on
|
||||
`odoo-modsdev`. `TransactionCase` coverage:
|
||||
- Contract spawn on `sale.order` confirm (enabled vs disabled category; quantity; idempotency).
|
||||
- Backfill wizard: **two‑regime dedup** (serial for wheelchairs; partner+product+line for lifts), accessory‑line exclusion, stagger, dry‑run produces no records, anchor fallback.
|
||||
- Booking: slot list comes from real gaps; confirm creates task+repair+visit; **double‑book guard**;
|
||||
no‑slot fallback.
|
||||
- Roll‑forward on completion: dates advance, band reset, **token regenerated**, visit → done.
|
||||
- Crons: reminder bands; unbooked/overdue follow‑ups (savepoint isolation).
|
||||
- Run: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0`.
|
||||
|
||||
## 14. Deployment & configuration
|
||||
1. Land on local dev, full E2E + tests green.
|
||||
2. **Deploy `fusion_repairs` to Westin** (`odoo-westin` / `westin-v19`) — the accepted bigger lift
|
||||
(first production deploy of fusion_repairs; verify rate‑card numbers, ACLs, asset bundles).
|
||||
3. **Configure** maintainable categories: `x_fc_maintenance_enabled`, interval, fee, skill, service
|
||||
product — for lifts (stairlift/porch/lift chair) + power & manual wheelchairs.
|
||||
4. Ensure technicians have `x_fc_repair_skills` + start addresses (for availability/routing).
|
||||
5. Run the **backfill wizard dry‑run → review report → execute** (staggered).
|
||||
6. Watch the first reminder/booking cycle; confirm emails, slots, task creation, completion → roll.
|
||||
|
||||
## 15. Open items to verify at implementation (rule #1 — read live source)
|
||||
- Exact representation of tech skills (`res.users.x_fc_repair_skills`) and how a category's required
|
||||
skill maps to it (Selection vs M2O vs tag) — read fusion_repairs/fusion_tasks before modelling
|
||||
`x_fc_maintenance_skill_id`.
|
||||
- Signatures of `_find_next_available_slot` / `_get_available_gaps` (params, return shape, working
|
||||
hours source) and whether they already account for travel windows.
|
||||
- The visit‑report wizard's current fields/flow before extending it with the checklist.
|
||||
- The inspection‑certificate issue API (how M1 creates a certificate) for the lift link.
|
||||
- **Lift base sized** (§3.3): ~254 stairlift + ~30 porch/VPL + ~41 lift‑chair customers, but ~0 serials.
|
||||
Still to verify: which exact products are **base units vs accessories** (so `x_fc_maintenance_enabled`
|
||||
lands on base units only), plus the lift interval/fee per category. Lift products aren't yet tagged
|
||||
with `fusion_repairs` categories on Westin (module not deployed there) — categorization is a deploy step.
|
||||
- `fusion_claims` device_type → maintenance‑category mapping table for the wheelchair backfill.
|
||||
|
||||
## 16. Build sequence (for the implementation plan)
|
||||
1. **Policy + fee data model** (category fields, product override, contract extensions, constraints).
|
||||
2. **Path A trigger** (wire `_spawn_maintenance_contracts` into `action_confirm`, fee resolution, anchor fallback) + tests.
|
||||
3. **Cost in email** (add fee to the reminder template).
|
||||
4. **Technician‑aware booking** (slot‑picker page + controller on `fusion_tasks` availability; task/repair/visit creation; double‑book guard; office action; token regen) + tests — the largest unit.
|
||||
5. **Maintenance visit log + checklist** (model, per‑category seed, visit‑report‑wizard extension, inspection‑cert link) + tests.
|
||||
6. **Backfill wizard** (scan/dedup/stagger/dry‑run; fusion_claims soft bridge) + tests.
|
||||
7. **Office follow‑up crons** (unbooked/overdue) + tests.
|
||||
8. **Deploy + configure + backfill** on Westin.
|
||||
@@ -0,0 +1,101 @@
|
||||
# NexaCloud → Odoo Centralized Billing — Cutover (build-out · dual-run · gated flip)
|
||||
|
||||
- **Date:** 2026-06-02
|
||||
- **Status:** Design approved — pending written-spec review
|
||||
- **Author:** Design session (Claude + Gurpreet)
|
||||
- **Parent spec:** [`2026-05-27-nexa-billing-centralized-design.md`](2026-05-27-nexa-billing-centralized-design.md) (architecture; this doc is its **phase #2** — the NexaCloud pilot)
|
||||
- **Repos:** `K:\Github\Odoo-Modules\fusion_centralize_billing` (engine) + `K:\Github\Nexa-Cloud` (the NexaCloud adapter)
|
||||
- **Hosts:** `odoo-nexa` (VM 315, Odoo 19 Enterprise, live DB `nexamain`); NexaCloud (LXC 102, app `192.168.1.250`, DB `192.168.1.50`)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Make Odoo (`fusion_centralize_billing` on `odoo-nexa`) the system of record for **NexaCloud** billing: build the engine pieces NexaCloud needs, import NexaCloud's active deployments as Odoo subscriptions, run Odoo in **shadow** alongside NexaCloud's existing Stripe billing for ≥1 cycle, reconcile to the cent, and then **flip** NexaCloud onto Odoo behind an explicit go/no-go gate. NexaCloud is the pilot; NexaDesk and NexaMaps follow in later increments. This does not touch Lago.
|
||||
|
||||
## 2. Decisions locked (this session, 2026-06-02)
|
||||
|
||||
1. **Sequence: NexaCloud first** (per parent spec), then NexaDesk, then NexaMaps.
|
||||
2. **Granularity: one Odoo subscription per NexaCloud deployment** (mirrors `nexacloud` `subscriptions.deployment_id`; the existing usage-push and `fusion.billing.reconciliation` code already key per deployment via `x_fc_nexacloud_subscription_id`).
|
||||
3. **Approach A: build → import → dual-run → gated flip**, all in this increment; the flip executes only after ≥1 green reconciliation cycle **and** explicit operator go-ahead.
|
||||
4. **Go-forward billing only.** The importer sets each subscription's `next_invoice_date` so Odoo bills only future periods. Past NexaCloud periods are **never re-issued** (this is the exact failure mode of the 2026-05-27 Lago incident — see `lago-doublecharge-incident-2026-06` memory).
|
||||
|
||||
## 3. Current state (recon, 2026-06-02)
|
||||
|
||||
Engine is **installed** on `nexamain` (`fusion_centralize_billing` v19.0.1.1.0; deps `sale_subscription`, `payment_stripe`, `account_accountant` installed). Runtime rows:
|
||||
|
||||
| Table | Rows | Read |
|
||||
|---|---|---|
|
||||
| `fusion_billing_service` | 1 | only `nexacloud`; **`webhook_url` empty** |
|
||||
| `fusion_billing_account_link` | 7 | identities imported |
|
||||
| `fusion_billing_metric` | 1 | (cpu_seconds) |
|
||||
| `fusion_billing_charge` | **0** | no quota/overage pricing yet |
|
||||
| `fusion_billing_usage` | **0** | nothing ingested |
|
||||
| `fusion_billing_reconciliation` | **0** | dual-run never run |
|
||||
| `fusion_billing_webhook` | **0** | control loop never fired |
|
||||
| `sale_order` (`is_subscription`) | **0** | no subscriptions exist |
|
||||
|
||||
Engine code status: `webhook.py` delivery engine (HMAC + backoff + dead-letter) is **complete** (its "TODO §8" header comment is stale); `usage.py` (idempotent upsert + pre-invoice rating cron + aggregation) and `reconciliation.py` (NexaCloud dual-run) are **complete**. `controllers/api.py` implements `/health`, `POST /customers`, `POST /usage`, `GET /plans`, `POST /subscriptions` only — the rest of parent-spec §7 is unimplemented (needed by NexaDesk, **not** NexaCloud).
|
||||
|
||||
NexaCloud adapter is present but **INERT**: `config.py` `odoo_billing_enabled=False`, `odoo_billing_base_url`/`odoo_billing_api_key` empty; `usage_metering.py` pushes `cpu_seconds` only when enabled; `routers/odoo_billing.py` `/billing/webhooks/central` returns 404 when disabled; `services/odoo_billing_integration.py` is the (inert) receiver. Lago is paused (worker+clock stopped) and out of scope here.
|
||||
|
||||
## 4. Scope
|
||||
|
||||
### 4.1 Odoo side (`fusion_centralize_billing` + catalog data on `nexamain`)
|
||||
|
||||
1. **Charge catalog (the main gap — currently 0).**
|
||||
- NexaCloud plans/products → `product.template` + `sale.subscription.plan` (monthly), each tagged `plan_code` and a `product.default_code` of `NC-PLAN-<slug>` (reconciliation already filters plan lines on `default_code LIKE 'NC-PLAN-%'`).
|
||||
- `cpu_seconds` metric (exists) → one `fusion.billing.charge` per plan: `included_quota` = the plan's bundled CPU-seconds, `price_per_unit`/`unit_batch` for overage derived from `usage_metering.HOURLY_RATES` (`cpu_per_core=$0.0075/core-hr` → per-cpu-second rate). Memory/disk are part of the flat plan today (not metered) — keep them flat unless a plan meters them.
|
||||
- Throttle-removal fee and the CPU/RAM/disk/daily-backup **add-ons** → one-off invoice products / optional recurring add-on products tagged `NC-ADDON-<slug>`.
|
||||
- HST: reuse native `account.tax` (13% ON); confirm the tax code matches what NexaCloud invoices apply today.
|
||||
2. **Run the importer** (`wizards/import_wizard.py`): read the `nexacloud` DB → ensure `res.partner` + `account.link` for each active customer (7 exist; backfill any missing), and create **one shadow `sale.order` (`is_subscription=True`) per active deployment**, setting `x_fc_nexacloud_subscription_id`, `x_fc_nexacloud_plan_id`, the `NC-PLAN-*` line, and **`next_invoice_date` = the deployment's next real billing date** (go-forward only). Subscriptions start in shadow (draft/not auto-charging).
|
||||
3. **Inbound API — add only what NexaCloud needs.** `POST /customers`, `POST /subscriptions`, `POST /usage`, `GET /plans` already exist. Add **subscription cancel** (`DELETE /subscriptions/:id` → terminate the `sale.order`) for NexaCloud's deprovision path. All other parent-spec §7 endpoints stay deferred to the NexaDesk increment.
|
||||
4. **Wire the control loop:** set the `nexacloud` `fusion.billing.service.webhook_url` → `https://api.vps.nexasystems.ca/api/v1/billing/webhooks/central`, and confirm `cron` schedules for `usage._cron_rate_open_periods` and `webhook._cron_dispatch` are enabled.
|
||||
|
||||
### 4.2 NexaCloud side (`Nexa-Cloud` repo)
|
||||
|
||||
4. **Configure + activate the adapter:** set `odoo_billing_base_url=https://erp.nexasystems.ca/api/billing/v1`, `odoo_billing_api_key=<nexacloud service key>`. Keep `odoo_billing_enabled` staged so usage push + the webhook receiver activate for shadow without yet disabling local Stripe.
|
||||
5. **Identity + subscription sync:** on deployment create / cancel, call Odoo `POST /customers` and `POST /subscriptions` / cancel (usage push already exists in `usage_metering.py`). Send a stable `external_id` (NexaCloud user id) and `subscription_external_id` (deployment/subscription id) — namespaced, to avoid the cross-app `external_id` collision noted in `nexa-billing-architecture`.
|
||||
6. **Reconciliation feed:** push NexaCloud's **actual** charged amount per (deployment, period) so `reconciliation._reconcile_rows` can diff Odoo-computed vs NexaCloud-actual. (Source: NexaCloud's own invoices/`usage_records`.)
|
||||
7. **Activate the control-loop receiver:** `routers/odoo_billing.py` `/billing/webhooks/central` → `services/odoo_billing_integration.py` maps `invoice.payment_failed`→suspend (existing `network_isolation`/`throttle_checker`/`resource_manager`), `invoice.payment_succeeded`/`subscription.reactivated`→restore, `subscription.terminated`→deprovision. Verify HMAC against the `nexacloud` service `webhook_secret`.
|
||||
|
||||
### 4.3 Dual-run (shadow, ≥1 billing cycle)
|
||||
|
||||
NexaCloud keeps charging via its own Stripe. Odoo computes **draft, uncharged** invoices from imported subscriptions + pushed `cpu_seconds`. `fusion.billing.reconciliation` upserts one row per `(service, deployment, period)` with `odoo_amount` vs `external_amount` and a cent-level `delta`. Operators investigate every `delta` row until a full cycle is `match` within tolerance (default $0.01).
|
||||
|
||||
### 4.4 Gated flip (after ≥1 green cycle + explicit go)
|
||||
|
||||
1. NexaCloud **stops its own Stripe charging** (disable the charge path in `billing_service.py` / scheduler `billing_payment` + invoice generation) and treats Odoo as SoR.
|
||||
2. Odoo subscriptions move from shadow → active; native subscription invoicing charges the **shared** Stripe account `acct_1ShlA9IkwUB1dVox` (saved cards carry over — no re-collection).
|
||||
3. Webhooks drive suspend/restore/deprovision. Past NexaCloud invoices remain archived (PDF/opening balance) — **not** re-issued.
|
||||
4. Rollback: re-enable NexaCloud local billing + set Odoo subs back to shadow (no data destroyed).
|
||||
|
||||
## 5. Out of scope (YAGNI for this increment)
|
||||
|
||||
- NexaDesk and NexaMaps adapters (later increments) and the inbound-API endpoints only they need (`/invoices` family, `/credit_notes`, `/catalog`, `/checkout_url`, `PUT /subscriptions` plan-change/upgrade).
|
||||
- Lago changes or decommission (Lago stays paused; its remediation is tracked separately).
|
||||
- Customer-portal redesign — use native Odoo portal as-is.
|
||||
- Metering memory/disk/bandwidth (stay flat unless a NexaCloud plan already meters them).
|
||||
|
||||
## 6. Success criteria
|
||||
|
||||
- A NexaCloud deployment is created as an Odoo subscription `sale.order` (`is_subscription=True`) via `POST /subscriptions`, resolving one `res.partner` through `account.link`.
|
||||
- `cpu_seconds` counters pushed to `/usage` aggregate (idempotent) into a **draft** invoice with quota → free, overage priced, HST applied — matching NexaCloud's own computed amount within $0.01.
|
||||
- A simulated `invoice.payment_failed` webhook reaches `/billing/webhooks/central` (valid HMAC) and triggers a NexaCloud suspend; `invoice.payment_succeeded` restores.
|
||||
- `fusion.billing.reconciliation` is `match` for **every** active deployment across ≥1 full cycle before any flip.
|
||||
- Re-sending the same usage counter (same `idempotency_key`) does **not** double-bill (constraint + upsert verified by test).
|
||||
- Post-flip: Odoo charges go-forward periods only; **zero** past-period re-issues.
|
||||
|
||||
## 7. Risks & open items
|
||||
|
||||
- **Re-billing regression (highest):** the importer MUST set `next_invoice_date` go-forward and must not finalize/charge historical periods. Add an explicit test asserting no invoice is generated for any period earlier than import time. (Direct mitigation of the 2026-05-27 Lago incident.)
|
||||
- **Odoo 19 correctness:** read live reference files from the container (`docker exec odoo-nexa-app cat …`) for `sale.order` subscription flow, `account.move`, `payment_stripe` before coding internals — never from memory (per `K:\Github\CLAUDE.md`).
|
||||
- **Idempotency:** `fusion.billing.usage` unique `(subscription, metric, idempotency_key)` already enforces it; the NexaCloud key is `nexacloud:cpu_seconds:<sub>:<period>` — keep it stable across retries.
|
||||
- **external_id namespacing:** NexaCloud must send namespaced ids so it can never collide with NexaDesk/NexaMaps in the shared Odoo identity space.
|
||||
- **Reconciliation source:** confirm where NexaCloud's "actual amount" comes from (its `invoices`/`usage_records`) and that it's net of the same HST basis Odoo uses.
|
||||
- **Flip switch safety:** disabling NexaCloud's local Stripe must be a single, reversible config flag, and the `billing_payment` scheduler job must be guarded so it can't charge once Odoo is SoR.
|
||||
- **Spec/branch target:** `Odoo-Modules` is on `feat/fusion-login-audit` with `-wt-portal`/`-wt-fm` worktrees; confirm the branch for engine changes; NexaCloud changes land on its own branch (note: pushing `Nexa-Cloud` `main` auto-deploys to prod).
|
||||
|
||||
## 8. Test plan
|
||||
|
||||
- Odoo unit tests (extend `fusion_centralize_billing/tests/`): catalog→charge mapping; usage aggregation + quota/overage; idempotent re-push; reconciliation match/delta; webhook HMAC sign/verify + backoff; **importer go-forward `next_invoice_date` assertion**.
|
||||
- NexaCloud tests: adapter customer/subscription calls; `/billing/webhooks/central` HMAC verify + suspend/restore/deprovision dispatch; reconciliation-amount push.
|
||||
- Dual-run acceptance: a full cycle of `match` reconciliation on real (or staged) deployments before the flip gate.
|
||||
7
fusion-plating/.claude/settings.local.json
Normal file
7
fusion-plating/.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ls /k/Github/Odoo-Modules/ | grep -i -E \"shopfloor|tablet|fusion_plating\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,883 +0,0 @@
|
||||
# Graph Report - /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal (2026-04-22)
|
||||
|
||||
## Corpus Check
|
||||
- 33 files · ~40,589 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 470 nodes · 550 edges · 123 communities detected
|
||||
- Extraction: 89% EXTRACTED · 11% INFERRED · 0% AMBIGUOUS · INFERRED: 60 edges (avg confidence: 0.76)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_Community 0|Community 0]]
|
||||
- [[_COMMUNITY_Community 1|Community 1]]
|
||||
- [[_COMMUNITY_Community 2|Community 2]]
|
||||
- [[_COMMUNITY_Community 3|Community 3]]
|
||||
- [[_COMMUNITY_Community 4|Community 4]]
|
||||
- [[_COMMUNITY_Community 5|Community 5]]
|
||||
- [[_COMMUNITY_Community 6|Community 6]]
|
||||
- [[_COMMUNITY_Community 7|Community 7]]
|
||||
- [[_COMMUNITY_Community 8|Community 8]]
|
||||
- [[_COMMUNITY_Community 9|Community 9]]
|
||||
- [[_COMMUNITY_Community 10|Community 10]]
|
||||
- [[_COMMUNITY_Community 11|Community 11]]
|
||||
- [[_COMMUNITY_Community 12|Community 12]]
|
||||
- [[_COMMUNITY_Community 13|Community 13]]
|
||||
- [[_COMMUNITY_Community 14|Community 14]]
|
||||
- [[_COMMUNITY_Community 15|Community 15]]
|
||||
- [[_COMMUNITY_Community 16|Community 16]]
|
||||
- [[_COMMUNITY_Community 17|Community 17]]
|
||||
- [[_COMMUNITY_Community 18|Community 18]]
|
||||
- [[_COMMUNITY_Community 19|Community 19]]
|
||||
- [[_COMMUNITY_Community 20|Community 20]]
|
||||
- [[_COMMUNITY_Community 21|Community 21]]
|
||||
- [[_COMMUNITY_Community 22|Community 22]]
|
||||
- [[_COMMUNITY_Community 23|Community 23]]
|
||||
- [[_COMMUNITY_Community 24|Community 24]]
|
||||
- [[_COMMUNITY_Community 25|Community 25]]
|
||||
- [[_COMMUNITY_Community 26|Community 26]]
|
||||
- [[_COMMUNITY_Community 27|Community 27]]
|
||||
- [[_COMMUNITY_Community 28|Community 28]]
|
||||
- [[_COMMUNITY_Community 29|Community 29]]
|
||||
- [[_COMMUNITY_Community 30|Community 30]]
|
||||
- [[_COMMUNITY_Community 31|Community 31]]
|
||||
- [[_COMMUNITY_Community 32|Community 32]]
|
||||
- [[_COMMUNITY_Community 33|Community 33]]
|
||||
- [[_COMMUNITY_Community 34|Community 34]]
|
||||
- [[_COMMUNITY_Community 35|Community 35]]
|
||||
- [[_COMMUNITY_Community 36|Community 36]]
|
||||
- [[_COMMUNITY_Community 37|Community 37]]
|
||||
- [[_COMMUNITY_Community 38|Community 38]]
|
||||
- [[_COMMUNITY_Community 39|Community 39]]
|
||||
- [[_COMMUNITY_Community 40|Community 40]]
|
||||
- [[_COMMUNITY_Community 41|Community 41]]
|
||||
- [[_COMMUNITY_Community 42|Community 42]]
|
||||
- [[_COMMUNITY_Community 43|Community 43]]
|
||||
- [[_COMMUNITY_Community 44|Community 44]]
|
||||
- [[_COMMUNITY_Community 45|Community 45]]
|
||||
- [[_COMMUNITY_Community 46|Community 46]]
|
||||
- [[_COMMUNITY_Community 47|Community 47]]
|
||||
- [[_COMMUNITY_Community 48|Community 48]]
|
||||
- [[_COMMUNITY_Community 49|Community 49]]
|
||||
- [[_COMMUNITY_Community 50|Community 50]]
|
||||
- [[_COMMUNITY_Community 51|Community 51]]
|
||||
- [[_COMMUNITY_Community 52|Community 52]]
|
||||
- [[_COMMUNITY_Community 53|Community 53]]
|
||||
- [[_COMMUNITY_Community 54|Community 54]]
|
||||
- [[_COMMUNITY_Community 55|Community 55]]
|
||||
- [[_COMMUNITY_Community 56|Community 56]]
|
||||
- [[_COMMUNITY_Community 57|Community 57]]
|
||||
- [[_COMMUNITY_Community 58|Community 58]]
|
||||
- [[_COMMUNITY_Community 59|Community 59]]
|
||||
- [[_COMMUNITY_Community 60|Community 60]]
|
||||
- [[_COMMUNITY_Community 61|Community 61]]
|
||||
- [[_COMMUNITY_Community 62|Community 62]]
|
||||
- [[_COMMUNITY_Community 63|Community 63]]
|
||||
- [[_COMMUNITY_Community 64|Community 64]]
|
||||
- [[_COMMUNITY_Community 65|Community 65]]
|
||||
- [[_COMMUNITY_Community 66|Community 66]]
|
||||
- [[_COMMUNITY_Community 67|Community 67]]
|
||||
- [[_COMMUNITY_Community 68|Community 68]]
|
||||
- [[_COMMUNITY_Community 69|Community 69]]
|
||||
- [[_COMMUNITY_Community 70|Community 70]]
|
||||
- [[_COMMUNITY_Community 71|Community 71]]
|
||||
- [[_COMMUNITY_Community 72|Community 72]]
|
||||
- [[_COMMUNITY_Community 73|Community 73]]
|
||||
- [[_COMMUNITY_Community 74|Community 74]]
|
||||
- [[_COMMUNITY_Community 75|Community 75]]
|
||||
- [[_COMMUNITY_Community 76|Community 76]]
|
||||
- [[_COMMUNITY_Community 77|Community 77]]
|
||||
- [[_COMMUNITY_Community 78|Community 78]]
|
||||
- [[_COMMUNITY_Community 79|Community 79]]
|
||||
- [[_COMMUNITY_Community 80|Community 80]]
|
||||
- [[_COMMUNITY_Community 81|Community 81]]
|
||||
- [[_COMMUNITY_Community 82|Community 82]]
|
||||
- [[_COMMUNITY_Community 83|Community 83]]
|
||||
- [[_COMMUNITY_Community 84|Community 84]]
|
||||
- [[_COMMUNITY_Community 85|Community 85]]
|
||||
- [[_COMMUNITY_Community 86|Community 86]]
|
||||
- [[_COMMUNITY_Community 87|Community 87]]
|
||||
- [[_COMMUNITY_Community 88|Community 88]]
|
||||
- [[_COMMUNITY_Community 89|Community 89]]
|
||||
- [[_COMMUNITY_Community 90|Community 90]]
|
||||
- [[_COMMUNITY_Community 91|Community 91]]
|
||||
- [[_COMMUNITY_Community 92|Community 92]]
|
||||
- [[_COMMUNITY_Community 93|Community 93]]
|
||||
- [[_COMMUNITY_Community 94|Community 94]]
|
||||
- [[_COMMUNITY_Community 95|Community 95]]
|
||||
- [[_COMMUNITY_Community 96|Community 96]]
|
||||
- [[_COMMUNITY_Community 97|Community 97]]
|
||||
- [[_COMMUNITY_Community 98|Community 98]]
|
||||
- [[_COMMUNITY_Community 99|Community 99]]
|
||||
- [[_COMMUNITY_Community 100|Community 100]]
|
||||
- [[_COMMUNITY_Community 101|Community 101]]
|
||||
- [[_COMMUNITY_Community 102|Community 102]]
|
||||
- [[_COMMUNITY_Community 103|Community 103]]
|
||||
- [[_COMMUNITY_Community 104|Community 104]]
|
||||
- [[_COMMUNITY_Community 105|Community 105]]
|
||||
- [[_COMMUNITY_Community 106|Community 106]]
|
||||
- [[_COMMUNITY_Community 107|Community 107]]
|
||||
- [[_COMMUNITY_Community 108|Community 108]]
|
||||
- [[_COMMUNITY_Community 109|Community 109]]
|
||||
- [[_COMMUNITY_Community 110|Community 110]]
|
||||
- [[_COMMUNITY_Community 111|Community 111]]
|
||||
- [[_COMMUNITY_Community 112|Community 112]]
|
||||
- [[_COMMUNITY_Community 113|Community 113]]
|
||||
- [[_COMMUNITY_Community 114|Community 114]]
|
||||
- [[_COMMUNITY_Community 115|Community 115]]
|
||||
- [[_COMMUNITY_Community 116|Community 116]]
|
||||
- [[_COMMUNITY_Community 117|Community 117]]
|
||||
- [[_COMMUNITY_Community 118|Community 118]]
|
||||
- [[_COMMUNITY_Community 119|Community 119]]
|
||||
- [[_COMMUNITY_Community 120|Community 120]]
|
||||
- [[_COMMUNITY_Community 121|Community 121]]
|
||||
- [[_COMMUNITY_Community 122|Community 122]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `create()` - 22 edges
|
||||
2. `FusionAssessment` - 20 edges
|
||||
3. `AuthorizerPortal` - 19 edges
|
||||
4. `ResPartner` - 16 edges
|
||||
5. `accessibility_assessment_save()` - 12 edges
|
||||
6. `FusionAccessibilityAssessment` - 11 edges
|
||||
7. `selectField()` - 11 edges
|
||||
8. `PDFTemplateFiller` - 10 edges
|
||||
9. `SaleOrder` - 10 edges
|
||||
10. `FusionPdfTemplate` - 9 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `create_field()` --calls--> `create()` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/pdf_editor.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/accessibility_assessment.py
|
||||
- `FusionPdfTemplatePreview` --uses--> `PDFTemplateFiller` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
|
||||
- `FusionPdfTemplateField` --uses--> `PDFTemplateFiller` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
|
||||
- `Generate PNG preview images from the PDF using poppler (pdftoppm). Falls` --uses--> `PDFTemplateFiller` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
|
||||
- `Set template to active.` --uses--> `PDFTemplateFiller` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
|
||||
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.05
|
||||
Nodes (29): accessibility_bathroom(), accessibility_ceiling_lift(), accessibility_ramp(), accessibility_stairlift_curved(), accessibility_stairlift_straight(), accessibility_tub_cutout(), accessibility_vpl(), home() (+21 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.06
|
||||
Nodes (20): Assign role-specific portal groups to a portal user based on contact checkboxes., Assign backend groups to an internal user based on contact checkboxes. A, Grant portal access to this partner, or update permissions for existing users., Create a role-specific welcome Knowledge article for the new portal user., Send a professional portal invitation email to the partner. Gen, Resend portal invitation email to an existing portal user., Open the list of assigned sale orders, Open the list of assessments for this partner (+12 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.07
|
||||
Nodes (19): create(), FusionAssessment, Format assessment data as HTML table for chatter, Format wheelchair specifications for the sale order notes (legacy), Generate document records for signed pages, Send email notifications when assessment is completed, View related documents, View the created sale order (+11 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.08
|
||||
Nodes (15): create(), FusionAccessibilityAssessment, Complete the assessment and create a Sale Order. 2026-04 portal audit f, Add a tag to the sale order based on assessment type, Copy assessment photos to sale order chatter, Send email notification to office about assessment completion, Schedule a follow-up activity for the sales rep, Find or create a partner for the client (+7 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.08
|
||||
Nodes (20): Complete express assessment and create draft sale order (no signatures required), CustomerPortal, Ensure all module views are active after install/update. Odoo silently deac, _reactivate_views(), AssessmentPortal, portal_assessment_express_edit(), portal_assessment_express_new(), portal_assessment_express_save() (+12 more)
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.09
|
||||
Nodes (14): authorizer_cases_search(), sales_rep_cases_search(), get_authorizer_portal_cases(), get_sales_rep_portal_cases(), Open composer to send message to authorizer only, Send email when an authorizer is assigned to the order, View portal documents, Get data for portal display, excluding sensitive information (+6 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.12
|
||||
Nodes (14): preview_pdf(), _draw_field(), fill_template(), PDFTemplateFiller, Generic PDF template filler. Works with any template, any number of pages., create(), FusionPdfTemplate, FusionPdfTemplateField (+6 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.11
|
||||
Nodes (14): accessibility_assessment_save(), AuthorizerPortal, Portal controller for Authorizers (OTs/Therapists), Parse straight stair lift specific fields, Parse curved stair lift specific fields, Parse VPL specific fields, Parse ceiling lift specific fields, Parse ramp specific fields (+6 more)
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.21
|
||||
Nodes (22): buildDataKeyOptions(), buildDataKeysSidebar(), init(), jsonrpc(), loadFields(), normalize(), onFieldDragStart(), renderFieldMarker() (+14 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.29
|
||||
Nodes (11): checkClockStatus(), ensureModal(), getLocation(), hideModal(), isTechnicianPortal(), logLocation(), showDeniedBanner(), showModal() (+3 more)
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.18
|
||||
Nodes (3): ADPDocument, Download the document, Get the download URL for portal access
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.2
|
||||
Nodes (5): create_field(), FusionPdfEditorController, Controller for the PDF field position visual editor., update_field(), upload_preview_image()
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.38
|
||||
Nodes (4): page11_sign_form(), page11_sign_submit(), Page11PublicSignController, Look up and validate a signing request by token.
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.4
|
||||
Nodes (1): migrate()
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.5
|
||||
Nodes (1): AuthorizerComment
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.83
|
||||
Nodes (3): _detectAndSaveTimezone(), _getCookie(), start()
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.67
|
||||
Nodes (1): FusionLoanerCheckoutAssessment
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.67
|
||||
Nodes (0):
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 1.0
|
||||
Nodes (2): registerPushSubscription(), urlBase64ToUint8Array()
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Fill a PDF template by overlaying text/checkmarks/signatures at configured posit
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Draw a single field onto the reportlab canvas. Args: c: rep
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Override create to generate reference number
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get authorizer from x_fc_authorizer_id field
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get cases for authorizer portal with optional search
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get cases for sales rep portal with optional search
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Override create to handle revision numbering
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get documents for a sale order, optionally filtered by type
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Get all revisions of a specific document type
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Override create to set author from current user if not provided
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Kanban group expansion — always show all 6 workflow states.
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Straight stair lift: (steps × nose_to_nose) + 13" top landing
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Use manual override if provided, otherwise use calculated
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Curved stair lift calculation: - 12" per step - 16" per curve
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Use manual override if provided, otherwise use calculated
|
||||
|
||||
### Community 38 - "Community 38"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Ontario Building Code: 12 inches length per 1 inch height (1:12 ratio)
|
||||
|
||||
### Community 39 - "Community 39"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Landing required every 30 feet (360 inches)
|
||||
|
||||
### Community 40 - "Community 40"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Total length including landings (5 feet = 60 inches each)
|
||||
|
||||
### Community 41 - "Community 41"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Compute portal access status based on user account and login history.
|
||||
|
||||
### Community 42 - "Community 42"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Count sale orders where this partner is the authorizer
|
||||
|
||||
### Community 43 - "Community 43"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Count assessments where this partner is involved
|
||||
|
||||
### Community 44 - "Community 44"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Count sale orders assigned to this partner as delivery technician
|
||||
|
||||
### Community 45 - "Community 45"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 46 - "Community 46"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 47 - "Community 47"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 48 - "Community 48"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 49 - "Community 49"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 50 - "Community 50"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Display the Page 11 signing form.
|
||||
|
||||
### Community 51 - "Community 51"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Process the submitted Page 11 signature.
|
||||
|
||||
### Community 52 - "Community 52"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Download the signed Page 11 PDF.
|
||||
|
||||
### Community 53 - "Community 53"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Start a new assessment
|
||||
|
||||
### Community 54 - "Community 54"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): View/edit an assessment
|
||||
|
||||
### Community 55 - "Community 55"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Save assessment data (create or update)
|
||||
|
||||
### Community 56 - "Community 56"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Signature capture page
|
||||
|
||||
### Community 57 - "Community 57"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Save a signature (AJAX)
|
||||
|
||||
### Community 58 - "Community 58"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Complete the assessment
|
||||
|
||||
### Community 59 - "Community 59"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Start a new express assessment (Page 1 - Equipment Selection)
|
||||
|
||||
### Community 60 - "Community 60"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Continue/edit an express assessment
|
||||
|
||||
### Community 61 - "Community 61"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Save express assessment data (create or update)
|
||||
|
||||
### Community 62 - "Community 62"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Public page for booking an accessibility assessment.
|
||||
|
||||
### Community 63 - "Community 63"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Process assessment booking form submission.
|
||||
|
||||
### Community 64 - "Community 64"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Render the visual field editor for a PDF template.
|
||||
|
||||
### Community 65 - "Community 65"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Return all fields for a template.
|
||||
|
||||
### Community 66 - "Community 66"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Update a field's position or properties.
|
||||
|
||||
### Community 67 - "Community 67"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Create a new field on a template.
|
||||
|
||||
### Community 68 - "Community 68"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Delete a field from a template.
|
||||
|
||||
### Community 69 - "Community 69"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Return the preview image URL for a specific page.
|
||||
|
||||
### Community 70 - "Community 70"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Upload a preview image for a template page directly from the editor.
|
||||
|
||||
### Community 71 - "Community 71"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Generate a preview filled PDF with sample data.
|
||||
|
||||
### Community 72 - "Community 72"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Auto-save browser-detected timezone to the user profile if not already set.
|
||||
|
||||
### Community 73 - "Community 73"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Override home to add ADP posting info for Fusion users
|
||||
|
||||
### Community 74 - "Community 74"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Authorizer dashboard - simplified mobile-first view
|
||||
|
||||
### Community 75 - "Community 75"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): List of cases assigned to the authorizer
|
||||
|
||||
### Community 76 - "Community 76"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): AJAX search endpoint for real-time search
|
||||
|
||||
### Community 77 - "Community 77"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Add a comment to a case - posts to sale order chatter and emails salesperson
|
||||
|
||||
### Community 78 - "Community 78"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Upload a document for a case
|
||||
|
||||
### Community 79 - "Community 79"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Download an attachment from sale order (original application, xml, proof of deli
|
||||
|
||||
### Community 80 - "Community 80"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): View an approval photo
|
||||
|
||||
### Community 81 - "Community 81"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Sales rep dashboard with search and filters
|
||||
|
||||
### Community 82 - "Community 82"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): List of cases for the sales rep
|
||||
|
||||
### Community 83 - "Community 83"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): AJAX search endpoint for sales rep real-time search
|
||||
|
||||
### Community 84 - "Community 84"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): View a specific case for sales rep
|
||||
|
||||
### Community 85 - "Community 85"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Add a comment to a case (sales rep) - posts to sale order chatter and emails aut
|
||||
|
||||
### Community 86 - "Community 86"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): List of funding claims for the client
|
||||
|
||||
### Community 87 - "Community 87"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): View a specific funding claim
|
||||
|
||||
### Community 88 - "Community 88"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Download a document from a funding claim
|
||||
|
||||
### Community 89 - "Community 89"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Download proof of delivery from a funding claim
|
||||
|
||||
### Community 90 - "Community 90"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Technician dashboard - today's schedule with timeline.
|
||||
|
||||
### Community 91 - "Community 91"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): List of all tasks for the technician.
|
||||
|
||||
### Community 92 - "Community 92"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): View a specific technician task.
|
||||
|
||||
### Community 93 - "Community 93"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Add notes (and optional photos) to a completed task. :param notes: text
|
||||
|
||||
### Community 94 - "Community 94"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Handle task status changes (start, complete, en_route, cancel). Location
|
||||
|
||||
### Community 95 - "Community 95"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Transcribe voice recording using OpenAI Whisper, translate to English.
|
||||
|
||||
### Community 96 - "Community 96"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Use GPT to clean up and format raw notes text.
|
||||
|
||||
### Community 97 - "Community 97"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Format transcription with GPT and complete the task.
|
||||
|
||||
### Community 98 - "Community 98"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Next day preparation view.
|
||||
|
||||
### Community 99 - "Community 99"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): View schedule for a specific date.
|
||||
|
||||
### Community 100 - "Community 100"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Admin map view showing latest technician locations using Google Maps.
|
||||
|
||||
### Community 101 - "Community 101"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Log the technician's current GPS location.
|
||||
|
||||
### Community 102 - "Community 102"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Check if the current technician is clocked in. Returns {clocked_in: boo
|
||||
|
||||
### Community 103 - "Community 103"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Save the technician's personal start location.
|
||||
|
||||
### Community 104 - "Community 104"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Register a push notification subscription.
|
||||
|
||||
### Community 105 - "Community 105"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Legacy: List of deliveries for the technician (redirects to tasks).
|
||||
|
||||
### Community 106 - "Community 106"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): View a specific delivery for technician (legacy, still works).
|
||||
|
||||
### Community 107 - "Community 107"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): POD signature capture page - accessible by technicians and sales reps
|
||||
|
||||
### Community 108 - "Community 108"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Save POD signature via AJAX
|
||||
|
||||
### Community 109 - "Community 109"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Task-level POD signature capture page (works for all tasks including shadow).
|
||||
|
||||
### Community 110 - "Community 110"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Save POD signature directly on a task.
|
||||
|
||||
### Community 111 - "Community 111"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Show the accessibility assessment type selector
|
||||
|
||||
### Community 112 - "Community 112"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): List all accessibility assessments for the current user (sales rep or authorizer
|
||||
|
||||
### Community 113 - "Community 113"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Straight stair lift assessment form
|
||||
|
||||
### Community 114 - "Community 114"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Curved stair lift assessment form
|
||||
|
||||
### Community 115 - "Community 115"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Vertical Platform Lift assessment form
|
||||
|
||||
### Community 116 - "Community 116"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Ceiling Lift assessment form
|
||||
|
||||
### Community 117 - "Community 117"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Custom Ramp assessment form
|
||||
|
||||
### Community 118 - "Community 118"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Bathroom Modification assessment form
|
||||
|
||||
### Community 119 - "Community 119"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Tub Cutout assessment form
|
||||
|
||||
### Community 120 - "Community 120"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Save an accessibility assessment and optionally create a Sale Order
|
||||
|
||||
### Community 121 - "Community 121"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Render the rental pickup inspection form for the technician.
|
||||
|
||||
### Community 122 - "Community 122"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Save the rental inspection results.
|
||||
|
||||
## Knowledge Gaps
|
||||
- **177 isolated node(s):** `Ensure all module views are active after install/update. Odoo silently deac`, `Generic PDF template filler. Works with any template, any number of pages.`, `Fill a PDF template by overlaying text/checkmarks/signatures at configured posit`, `Draw a single field onto the reportlab canvas. Args: c: rep`, `Override create to generate reference number` (+172 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 19`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 20`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 21`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 22`** (1 nodes): `__manifest__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 23`** (1 nodes): `Fill a PDF template by overlaying text/checkmarks/signatures at configured posit`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 24`** (1 nodes): `Draw a single field onto the reportlab canvas. Args: c: rep`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 25`** (1 nodes): `Override create to generate reference number`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 26`** (1 nodes): `Get authorizer from x_fc_authorizer_id field`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 27`** (1 nodes): `Get cases for authorizer portal with optional search`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 28`** (1 nodes): `Get cases for sales rep portal with optional search`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 29`** (1 nodes): `Override create to handle revision numbering`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 30`** (1 nodes): `Get documents for a sale order, optionally filtered by type`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 31`** (1 nodes): `Get all revisions of a specific document type`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 32`** (1 nodes): `Override create to set author from current user if not provided`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 33`** (1 nodes): `Kanban group expansion — always show all 6 workflow states.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 34`** (1 nodes): `Straight stair lift: (steps × nose_to_nose) + 13" top landing`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 35`** (1 nodes): `Use manual override if provided, otherwise use calculated`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 36`** (1 nodes): `Curved stair lift calculation: - 12" per step - 16" per curve`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 37`** (1 nodes): `Use manual override if provided, otherwise use calculated`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 38`** (1 nodes): `Ontario Building Code: 12 inches length per 1 inch height (1:12 ratio)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 39`** (1 nodes): `Landing required every 30 feet (360 inches)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 40`** (1 nodes): `Total length including landings (5 feet = 60 inches each)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 41`** (1 nodes): `Compute portal access status based on user account and login history.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 42`** (1 nodes): `Count sale orders where this partner is the authorizer`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 43`** (1 nodes): `Count assessments where this partner is involved`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 44`** (1 nodes): `Count sale orders assigned to this partner as delivery technician`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 45`** (1 nodes): `assessment_form.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 46`** (1 nodes): `technician_sw.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 47`** (1 nodes): `loaner_portal.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 48`** (1 nodes): `signature_pad.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 49`** (1 nodes): `portal_search.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 50`** (1 nodes): `Display the Page 11 signing form.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 51`** (1 nodes): `Process the submitted Page 11 signature.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 52`** (1 nodes): `Download the signed Page 11 PDF.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 53`** (1 nodes): `Start a new assessment`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 54`** (1 nodes): `View/edit an assessment`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 55`** (1 nodes): `Save assessment data (create or update)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 56`** (1 nodes): `Signature capture page`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 57`** (1 nodes): `Save a signature (AJAX)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 58`** (1 nodes): `Complete the assessment`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 59`** (1 nodes): `Start a new express assessment (Page 1 - Equipment Selection)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 60`** (1 nodes): `Continue/edit an express assessment`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 61`** (1 nodes): `Save express assessment data (create or update)`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 62`** (1 nodes): `Public page for booking an accessibility assessment.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 63`** (1 nodes): `Process assessment booking form submission.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 64`** (1 nodes): `Render the visual field editor for a PDF template.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 65`** (1 nodes): `Return all fields for a template.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 66`** (1 nodes): `Update a field's position or properties.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 67`** (1 nodes): `Create a new field on a template.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 68`** (1 nodes): `Delete a field from a template.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 69`** (1 nodes): `Return the preview image URL for a specific page.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 70`** (1 nodes): `Upload a preview image for a template page directly from the editor.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 71`** (1 nodes): `Generate a preview filled PDF with sample data.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 72`** (1 nodes): `Auto-save browser-detected timezone to the user profile if not already set.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 73`** (1 nodes): `Override home to add ADP posting info for Fusion users`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 74`** (1 nodes): `Authorizer dashboard - simplified mobile-first view`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 75`** (1 nodes): `List of cases assigned to the authorizer`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 76`** (1 nodes): `AJAX search endpoint for real-time search`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 77`** (1 nodes): `Add a comment to a case - posts to sale order chatter and emails salesperson`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 78`** (1 nodes): `Upload a document for a case`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 79`** (1 nodes): `Download an attachment from sale order (original application, xml, proof of deli`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 80`** (1 nodes): `View an approval photo`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 81`** (1 nodes): `Sales rep dashboard with search and filters`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 82`** (1 nodes): `List of cases for the sales rep`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 83`** (1 nodes): `AJAX search endpoint for sales rep real-time search`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 84`** (1 nodes): `View a specific case for sales rep`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 85`** (1 nodes): `Add a comment to a case (sales rep) - posts to sale order chatter and emails aut`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 86`** (1 nodes): `List of funding claims for the client`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 87`** (1 nodes): `View a specific funding claim`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 88`** (1 nodes): `Download a document from a funding claim`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 89`** (1 nodes): `Download proof of delivery from a funding claim`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 90`** (1 nodes): `Technician dashboard - today's schedule with timeline.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 91`** (1 nodes): `List of all tasks for the technician.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 92`** (1 nodes): `View a specific technician task.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 93`** (1 nodes): `Add notes (and optional photos) to a completed task. :param notes: text`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 94`** (1 nodes): `Handle task status changes (start, complete, en_route, cancel). Location`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 95`** (1 nodes): `Transcribe voice recording using OpenAI Whisper, translate to English.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 96`** (1 nodes): `Use GPT to clean up and format raw notes text.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 97`** (1 nodes): `Format transcription with GPT and complete the task.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 98`** (1 nodes): `Next day preparation view.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 99`** (1 nodes): `View schedule for a specific date.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 100`** (1 nodes): `Admin map view showing latest technician locations using Google Maps.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 101`** (1 nodes): `Log the technician's current GPS location.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 102`** (1 nodes): `Check if the current technician is clocked in. Returns {clocked_in: boo`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 103`** (1 nodes): `Save the technician's personal start location.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 104`** (1 nodes): `Register a push notification subscription.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 105`** (1 nodes): `Legacy: List of deliveries for the technician (redirects to tasks).`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 106`** (1 nodes): `View a specific delivery for technician (legacy, still works).`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 107`** (1 nodes): `POD signature capture page - accessible by technicians and sales reps`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 108`** (1 nodes): `Save POD signature via AJAX`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 109`** (1 nodes): `Task-level POD signature capture page (works for all tasks including shadow).`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 110`** (1 nodes): `Save POD signature directly on a task.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 111`** (1 nodes): `Show the accessibility assessment type selector`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 112`** (1 nodes): `List all accessibility assessments for the current user (sales rep or authorizer`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 113`** (1 nodes): `Straight stair lift assessment form`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 114`** (1 nodes): `Curved stair lift assessment form`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 115`** (1 nodes): `Vertical Platform Lift assessment form`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 116`** (1 nodes): `Ceiling Lift assessment form`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 117`** (1 nodes): `Custom Ramp assessment form`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 118`** (1 nodes): `Bathroom Modification assessment form`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 119`** (1 nodes): `Tub Cutout assessment form`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 120`** (1 nodes): `Save an accessibility assessment and optionally create a Sale Order`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 121`** (1 nodes): `Render the rental pickup inspection form for the technician.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 122`** (1 nodes): `Save the rental inspection results.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `create()` connect `Community 3` to `Community 0`, `Community 1`, `Community 4`, `Community 7`, `Community 11`?**
|
||||
_High betweenness centrality (0.080) - this node is a cross-community bridge._
|
||||
- **Why does `FusionAssessment` connect `Community 2` to `Community 4`?**
|
||||
_High betweenness centrality (0.059) - this node is a cross-community bridge._
|
||||
- **Why does `AuthorizerPortal` connect `Community 7` to `Community 0`, `Community 4`?**
|
||||
_High betweenness centrality (0.047) - this node is a cross-community bridge._
|
||||
- **Are the 17 inferred relationships involving `create()` (e.g. with `._generate_tutorial_articles()` and `.action_grant_portal_access()`) actually correct?**
|
||||
_`create()` has 17 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 2 inferred relationships involving `accessibility_assessment_save()` (e.g. with `create()` and `.action_complete()`) actually correct?**
|
||||
_`accessibility_assessment_save()` has 2 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `Ensure all module views are active after install/update. Odoo silently deac`, `Generic PDF template filler. Works with any template, any number of pages.`, `Fill a PDF template by overlaying text/checkmarks/signatures at configured posit` to the rest of the system?**
|
||||
_177 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `Community 0` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.05 - nodes in this community are weakly interconnected._
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "label": "authorizer_comment.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L1"}, {"id": "authorizer_comment_authorizercomment", "label": "AuthorizerComment", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L9"}, {"id": "authorizer_comment_compute_display_name", "label": "_compute_display_name()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L70"}, {"id": "authorizer_comment_create", "label": "create()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L78"}, {"id": "authorizer_comment_rationale_79", "label": "Override create to set author from current user if not provided", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L79"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "authorizer_comment_authorizercomment", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "authorizer_comment_compute_display_name", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L70", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "authorizer_comment_create", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L78", "weight": 1.0}, {"source": "authorizer_comment_rationale_79", "target": "authorizer_comment_authorizercomment_create", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L79", "weight": 1.0}], "raw_calls": [{"caller_nid": "authorizer_comment_compute_display_name", "callee": "strftime", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L73"}, {"caller_nid": "authorizer_comment_compute_display_name", "callee": "_", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L75"}, {"caller_nid": "authorizer_comment_create", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L81"}, {"caller_nid": "authorizer_comment_create", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L83"}, {"caller_nid": "authorizer_comment_create", "callee": "super", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L85"}]}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_5_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L16"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_5_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_5_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L16", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L20"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L31"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L35"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "label": "chatter_message_authorizer.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L1"}, {"id": "chatter_message_authorizer_setup", "label": "setup()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L14"}, {"id": "chatter_message_authorizer_onclickmessageauthorizer", "label": "onClickMessageAuthorizer()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L20"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "chatter", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "patch", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L10", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "hooks", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L11", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "chatter_message_authorizer_setup", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L14", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "chatter_message_authorizer_onclickmessageauthorizer", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L20", "weight": 1.0}], "raw_calls": [{"caller_nid": "chatter_message_authorizer_setup", "callee": "useService", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L16"}, {"caller_nid": "chatter_message_authorizer_setup", "callee": "useService", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L17"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "call", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L25"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "map", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L32"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "split", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L32"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "doAction", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L34"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "warn", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L37"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L6", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L8", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L10", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L11", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "label": "timezone_detect.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L1"}, {"id": "timezone_detect_start", "label": "start()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L8"}, {"id": "timezone_detect_detectandsavetimezone", "label": "_detectAndSaveTimezone()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L13"}, {"id": "timezone_detect_getcookie", "label": "_getCookie()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L30"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "public_widget", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "timezone_detect_start", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L8", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "timezone_detect_detectandsavetimezone", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L13", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "timezone_detect_getcookie", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L30", "weight": 1.0}, {"source": "timezone_detect_start", "target": "timezone_detect_detectandsavetimezone", "relation": "calls", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L10", "weight": 1.0}, {"source": "timezone_detect_detectandsavetimezone", "target": "timezone_detect_getcookie", "relation": "calls", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L22", "weight": 1.0}], "raw_calls": [{"caller_nid": "timezone_detect_start", "callee": "_super", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L9"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "resolvedOptions", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L16"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "DateTimeFormat", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L16"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "catch", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L27"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "_rpc", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L27"}, {"caller_nid": "timezone_detect_getcookie", "callee": "match", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L31"}, {"caller_nid": "timezone_detect_getcookie", "callee": "decodeURIComponent", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L32"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L6", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_6_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L24"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_6_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L11", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_6_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L24", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L28"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchone", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L35"}, {"caller_nid": "end_migrate_migrate", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L36"}, {"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L39"}, {"caller_nid": "end_migrate_migrate", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L44"}, {"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L49"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L60"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L62"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L64"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L1"}, {"id": "init_reactivate_views", "label": "_reactivate_views()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L7"}, {"id": "init_rationale_8", "label": "Ensure all module views are active after install/update. Odoo silently deac", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L8"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "target": "init_reactivate_views", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "init_rationale_8", "target": "init_reactivate_views", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L8", "weight": 1.0}], "raw_calls": [{"caller_nid": "init_reactivate_views", "callee": "search", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L15"}, {"caller_nid": "init_reactivate_views", "callee": "sudo", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L15"}, {"caller_nid": "init_reactivate_views", "callee": "write", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L20"}, {"caller_nid": "init_reactivate_views", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L21"}, {"caller_nid": "init_reactivate_views", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L26"}, {"caller_nid": "init_reactivate_views", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L28"}, {"caller_nid": "init_reactivate_views", "callee": "getLogger", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L28"}, {"caller_nid": "init_reactivate_views", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L29"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_signature_pad_js", "label": "signature_pad.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/signature_pad.js", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_assessment_form_js", "label": "assessment_form.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/assessment_form.js", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_4_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L16"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_4_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_4_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L16", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L20"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L31"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L35"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_3_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L16"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_3_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_3_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L16", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L20"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L31"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L35"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_portal_search_js", "label": "portal_search.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/portal_search.js", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_loaner_checkout_py", "label": "loaner_checkout.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L1"}, {"id": "loaner_checkout_fusionloanercheckoutassessment", "label": "FusionLoanerCheckoutAssessment", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L6"}, {"id": "loaner_checkout_fusionloanercheckoutassessment_action_view_assessment", "label": ".action_view_assessment()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L17"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_loaner_checkout_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_loaner_checkout_py", "target": "loaner_checkout_fusionloanercheckoutassessment", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L6", "weight": 1.0}, {"source": "loaner_checkout_fusionloanercheckoutassessment", "target": "loaner_checkout_fusionloanercheckoutassessment_action_view_assessment", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L17", "weight": 1.0}], "raw_calls": [{"caller_nid": "loaner_checkout_fusionloanercheckoutassessment_action_view_assessment", "callee": "ensure_one", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L18"}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_loaner_portal_js", "label": "loaner_portal.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/loaner_portal.js", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_loaner_portal_js", "target": "public_widget", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/loaner_portal.js", "source_location": "L3", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_technician_sw_js", "label": "technician_sw.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/technician_sw.js", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
@@ -1 +0,0 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_utils_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_utils_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_utils_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/__init__.py", "source_location": "L3", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
106
fusion_centralize_billing/README.md
Normal file
106
fusion_centralize_billing/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Fusion Centralized Billing (`fusion_centralize_billing`)
|
||||
|
||||
Centralized billing engine that makes this Odoo 19 **Enterprise** instance the single
|
||||
billing brain for every NexaSystems service — **NexaCloud** hosting, **NexaDesk** chat,
|
||||
**NexaMaps** API, custom apps, and memberships. It replaces Lago and absorbs NexaCloud's
|
||||
home-grown Stripe billing into one customer ledger and one accounting system.
|
||||
|
||||
> **Design spec:** [`docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`](../docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md)
|
||||
>
|
||||
> **Status:** Core engine (sub-project #1) and the **NexaCloud importer (sub-project #2a)**
|
||||
> are implemented and tested on odoo-trial Enterprise. 2b (usage wiring), 2c (control loop),
|
||||
> and 2d (reconciliation) are pending.
|
||||
|
||||
## Why this module is small
|
||||
|
||||
We build **only** the metering + integration layer. Everything financial — recurring
|
||||
invoicing, HST tax, proration, dunning, customer portal, credit notes, Stripe — is
|
||||
**native Odoo Enterprise** (`sale_subscription`, `account_accountant`, `payment_stripe`),
|
||||
already installed and running.
|
||||
|
||||
## Design decisions (locked)
|
||||
|
||||
1. Odoo fully replaces Lago (we build the metered-billing engine; Lago is decommissioned last).
|
||||
2. One unified `res.partner` per client; **separate invoice per service**.
|
||||
3. **Apps drive**, Odoo is the billing system of record — apps call the inbound API (as they call Lago today); Odoo bills and webhooks back.
|
||||
4. Odoo owns the **billing catalog**; apps own **feature entitlements** (shared `plan_code`).
|
||||
5. Pilot = **NexaCloud**, phased dual-run cutover.
|
||||
6. **Aggregate-push** usage ingestion (periodic counters, not a raw-event firehose).
|
||||
|
||||
## Models (`fusion.billing.*`)
|
||||
|
||||
| Model | Purpose |
|
||||
|---|---|
|
||||
| `fusion.billing.service` | One source app; bearer API key (hashed) + webhook config. |
|
||||
| `fusion.billing.account.link` | External account id → one `res.partner` (identity resolution). |
|
||||
| `fusion.billing.metric` | Billable metric + aggregation (sum/max/last/unique). |
|
||||
| `fusion.billing.charge` | Plan + metric → included quota + overage pricing. |
|
||||
| `fusion.billing.usage` | Aggregated per-period usage rollups (idempotent). |
|
||||
| `fusion.billing.webhook` | Outbound lifecycle event queue (HMAC + retry). |
|
||||
| `fusion.billing.reconciliation` | Dual-run Odoo-vs-app delta during cutover. |
|
||||
|
||||
> **Odoo 19 note (verified):** a subscription is a `sale.order` with `is_subscription=True`
|
||||
> (`plan_id` → `sale.subscription.plan`). There is **no** `sale.subscription` model.
|
||||
> `fusion.billing.usage.subscription_id` therefore points at `sale.order`.
|
||||
|
||||
## Inbound API
|
||||
|
||||
Lago-shaped REST under `/api/billing/v1/*`, bearer auth. Endpoints mirror NexaDesk's
|
||||
existing `lago-client.ts` so migration is a thin client swap. `/health` works today;
|
||||
the rest return `501` until implemented.
|
||||
|
||||
## Relationship to `fusion_api`
|
||||
|
||||
`fusion_api` manages **outbound** provider keys (OpenAI, Maps, Twilio) + cost tracking —
|
||||
i.e. COGS. This module tracks **customer** revenue. Complementary: feed `fusion_api`
|
||||
cost into margin reporting; reuse its daily-rollup aggregation pattern.
|
||||
|
||||
## Dependencies
|
||||
|
||||
`account_accountant`, `sale_subscription`, `sale_management`, `payment_stripe`.
|
||||
|
||||
## Running the NexaCloud import (2a)
|
||||
|
||||
Exposed as **Fusion Billing → Import from NexaCloud** (a wizard). It runs entirely
|
||||
read-only against NexaCloud, and everything it creates in Odoo is shadow-safe (draft
|
||||
subscriptions, no payment token, charges with NULL `plan_id`) so it cannot charge or post
|
||||
during the dual-run.
|
||||
|
||||
**1. Create a least-privilege read-only role in the NexaCloud Postgres (LXC 201):**
|
||||
|
||||
```sql
|
||||
CREATE ROLE odoo_billing_ro WITH LOGIN PASSWORD '<choose-a-strong-password>';
|
||||
GRANT CONNECT ON DATABASE nexacloud TO odoo_billing_ro;
|
||||
GRANT USAGE ON SCHEMA public TO odoo_billing_ro;
|
||||
GRANT SELECT ON users, plans, subscriptions, deployments TO odoo_billing_ro;
|
||||
```
|
||||
|
||||
**2. Point Odoo at it** via the system parameter (Settings → Technical → System Parameters,
|
||||
or odoo-shell). psycopg2 wants a **libpq DSN** — i.e. NexaCloud's SQLAlchemy URL *without*
|
||||
`+asyncpg`:
|
||||
|
||||
```
|
||||
key: fusion_billing.nexacloud_dsn
|
||||
value: postgresql://odoo_billing_ro:<password>@<lxc201-host>:5432/nexacloud
|
||||
```
|
||||
|
||||
(Odoo on nexa / VM 315 must have a network route to the LXC 201 Postgres port.)
|
||||
|
||||
**3. Validate → dry-run → run for real:**
|
||||
|
||||
- **Test Connection** — confirms reachability + schema and reports row counts; imports nothing.
|
||||
- **Run Import** with **Dry run** ticked — computes the whole import inside a rolled-back
|
||||
savepoint and reports created / updated / **skipped** / **failed** counts; writes nothing.
|
||||
A red/amber banner flags any failures — investigate them before proceeding.
|
||||
- Untick **Dry run** and **Run Import** to persist the shadow copy. Re-running is safe and
|
||||
idempotent (upserts, never duplicates).
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
docker exec odoo-nexa-app odoo -d nexamain -u fusion_centralize_billing --stop-after-init
|
||||
# tests (once added):
|
||||
docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init
|
||||
```
|
||||
|
||||
Canadian English, CAD, HST via `account.tax`. New fields on native models use the `x_fc_*` prefix.
|
||||
3
fusion_centralize_billing/__init__.py
Normal file
3
fusion_centralize_billing/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
57
fusion_centralize_billing/__manifest__.py
Normal file
57
fusion_centralize_billing/__manifest__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
"name": "Fusion Centralized Billing",
|
||||
"version": "19.0.1.1.0",
|
||||
"category": "Accounting/Subscriptions",
|
||||
"summary": "Centralized billing engine for all NexaSystems services — metered usage, "
|
||||
"per-app billing API, and outbound webhooks on top of Odoo Enterprise subscriptions.",
|
||||
"description": """
|
||||
Fusion Centralized Billing
|
||||
==========================
|
||||
|
||||
Makes this Odoo Enterprise instance the single billing brain for every NexaSystems
|
||||
service (NexaCloud hosting, NexaDesk chat, NexaMaps API, custom apps, memberships).
|
||||
|
||||
It adds ONLY the metering + integration layer; all financial behaviour (invoicing,
|
||||
HST tax, proration, dunning, portal, credit notes, Stripe) is native Odoo Enterprise.
|
||||
|
||||
Capabilities
|
||||
------------
|
||||
* Service registry — one record per source app (NexaCloud / NexaDesk / NexaMaps) with
|
||||
bearer API key + webhook config.
|
||||
* Identity links — fold each app's external account into one ``res.partner``.
|
||||
* Metric + Charge catalog — billable metrics with quota + overage pricing, keyed by a
|
||||
shared ``plan_code`` (apps own feature entitlements; Odoo owns money).
|
||||
* Usage engine — aggregate-push: apps send periodic counters; a pre-invoice cron feeds
|
||||
billable quantities onto the subscription ``sale.order``.
|
||||
* Inbound API — Lago-shaped REST (``/api/billing/v1/*``), bearer auth.
|
||||
* Outbound webhooks — HMAC-signed lifecycle events (payment failed/succeeded,
|
||||
subscription terminated) so apps suspend / restore / deprovision.
|
||||
|
||||
Design spec: docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md
|
||||
|
||||
Status: SCAFFOLD. Model fields are in place; engine/API/webhook bodies are stubs to be
|
||||
implemented via the writing-plans output. Per repo CLAUDE.md, read live Odoo 19
|
||||
reference files from the container before implementing subscription/account internals.
|
||||
""",
|
||||
"author": "Nexa Systems Inc.",
|
||||
"website": "https://nexasystems.ca",
|
||||
"license": "OPL-1",
|
||||
"depends": [
|
||||
"account_accountant",
|
||||
"sale_subscription",
|
||||
"sale_management",
|
||||
"payment_stripe",
|
||||
],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"data/ir_cron.xml",
|
||||
"views/import_wizard_views.xml",
|
||||
"views/invoice_ledger_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"auto_install": False,
|
||||
}
|
||||
1
fusion_centralize_billing/controllers/__init__.py
Normal file
1
fusion_centralize_billing/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import api
|
||||
95
fusion_centralize_billing/controllers/api.py
Normal file
95
fusion_centralize_billing/controllers/api.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Inbound, Lago-shaped billing API (spec §7).
|
||||
|
||||
Auth: bearer API key matched (by SHA-256 hash) against ``fusion.billing.service``.
|
||||
Routing: ``type="http"`` + ``auth="none"`` + ``csrf=False`` — external apps present
|
||||
bearer tokens, not Odoo sessions (so NOT ``type="jsonrpc"``).
|
||||
|
||||
STATUS: SCAFFOLD. Only auth + /health are wired. Endpoint bodies are stubs (HTTP 501)
|
||||
to be implemented from the writing-plans output. Per repo CLAUDE.md, read live Odoo 19
|
||||
references (sale.order subscription flow, account.move, payment_stripe) before
|
||||
implementing — do NOT code those internals from memory.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "/api/billing/v1"
|
||||
|
||||
|
||||
class FusionBillingApi(http.Controller):
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
def _authenticate(self):
|
||||
"""Return the active fusion.billing.service for the bearer key, else None."""
|
||||
auth = request.httprequest.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return None
|
||||
return request.env["fusion.billing.service"].sudo()._match_api_key(auth[7:].strip()) or None
|
||||
|
||||
def _json(self, payload, status=200):
|
||||
return request.make_json_response(payload, status=status)
|
||||
|
||||
def _read_json(self):
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True) or "{}"
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────
|
||||
@http.route(f"{API_BASE}/health", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
def health(self, **kw):
|
||||
return self._json({"status": "ok", "service": "fusion_centralize_billing"})
|
||||
|
||||
@http.route(f"{API_BASE}/customers", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def post_customer(self, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
payload = self._read_json()
|
||||
if payload is None:
|
||||
return self._json({"error": "invalid json"}, status=400)
|
||||
result = service._api_upsert_customer(payload)
|
||||
if result.get("status") == "error":
|
||||
return self._json(result, status=400)
|
||||
return self._json(result)
|
||||
|
||||
@http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def post_usage(self, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
payload = self._read_json()
|
||||
if payload is None:
|
||||
return self._json({"error": "invalid json"}, status=400)
|
||||
result = service._api_record_usage(payload)
|
||||
if result.get("status") == "error":
|
||||
return self._json(result, status=400)
|
||||
return self._json(result, status=202)
|
||||
|
||||
@http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
def get_plans(self, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
return self._json(service._api_catalog())
|
||||
|
||||
@http.route(f"{API_BASE}/subscriptions", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def post_subscription(self, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
payload = self._read_json()
|
||||
if payload is None:
|
||||
return self._json({"error": "invalid json"}, status=400)
|
||||
result = service._api_create_subscription(payload)
|
||||
if result.get("status") == "error":
|
||||
return self._json(result, status=400)
|
||||
return self._json(result)
|
||||
35
fusion_centralize_billing/data/ir_cron.xml
Normal file
35
fusion_centralize_billing/data/ir_cron.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="cron_fc_rate_usage" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Rate usage before invoicing</field>
|
||||
<field name="model_id" ref="model_fusion_billing_usage"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_rate_open_periods()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<record id="cron_fc_dispatch_webhooks" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Dispatch outbound webhooks</field>
|
||||
<field name="model_id" ref="model_fusion_billing_webhook"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_dispatch()</field>
|
||||
<field name="interval_number">2</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Go-forward NexaCloud ledger sync. Ships INACTIVE: only enable once the Stripe
|
||||
(and Lago) API credentials are set on the instance and a manual run is verified,
|
||||
because the sync verifies each invoice against those sources before posting. -->
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Sync NexaCloud invoices (Stripe/Lago verified)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_sync_verified()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
</odoo>
|
||||
10
fusion_centralize_billing/models/__init__.py
Normal file
10
fusion_centralize_billing/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from . import service
|
||||
from . import account_link
|
||||
from . import metric
|
||||
from . import charge
|
||||
from . import usage
|
||||
from . import webhook
|
||||
from . import reconciliation
|
||||
from . import sale_order
|
||||
from . import res_partner
|
||||
from . import account_move
|
||||
59
fusion_centralize_billing/models/account_link.py
Normal file
59
fusion_centralize_billing/models/account_link.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionBillingAccountLink(models.Model):
|
||||
"""Identity resolution: maps an app's external account id to one res.partner.
|
||||
|
||||
Folds the NexaCloud user / NexaDesk tenant / NexaMaps client for the same
|
||||
real-world client onto a single partner (the unified customer). See spec §5.1.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.account.link"
|
||||
_description = "Fusion Billing — External Account → Partner Link"
|
||||
_order = "service_id, external_id"
|
||||
|
||||
service_id = fields.Many2one(
|
||||
"fusion.billing.service", required=True, ondelete="cascade", index=True,
|
||||
)
|
||||
external_id = fields.Char(
|
||||
required=True, index=True,
|
||||
help="The app's own account id (NexaCloud user, NexaDesk tenant, Maps client).",
|
||||
)
|
||||
external_email = fields.Char()
|
||||
partner_id = fields.Many2one(
|
||||
"res.partner", required=True, ondelete="restrict", index=True,
|
||||
)
|
||||
|
||||
_service_external_uniq = models.Constraint(
|
||||
"unique(service_id, external_id)",
|
||||
"An external account can only link to one partner per service.",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _resolve_or_create_partner(self, service, external_id, name=None, email=None, extra=None):
|
||||
"""Return the link for (service, external_id), creating partner+link if needed.
|
||||
|
||||
Unifies customers: if a link for this external_id exists, reuse it; else if a
|
||||
partner with the same email already exists (possibly from another service),
|
||||
link to it; else create a new partner.
|
||||
"""
|
||||
existing = self.search(
|
||||
[('service_id', '=', service.id), ('external_id', '=', external_id)], limit=1)
|
||||
if existing:
|
||||
return existing
|
||||
partner = self.env['res.partner']
|
||||
if email:
|
||||
# case-insensitive so a pre-existing partner with a differently-cased email
|
||||
# (created via the web UI or another sync) is reused, not duplicated.
|
||||
partner = partner.search([('email', '=ilike', email)], limit=1)
|
||||
if not partner:
|
||||
partner = partner.create({'name': name or external_id, 'email': email, **(extra or {})})
|
||||
return self.create({
|
||||
'service_id': service.id,
|
||||
'external_id': external_id,
|
||||
'external_email': email,
|
||||
'partner_id': partner.id,
|
||||
})
|
||||
17
fusion_centralize_billing/models/account_move.py
Normal file
17
fusion_centralize_billing/models/account_move.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
x_fc_nexacloud_invoice_id = fields.Char(
|
||||
index=True, copy=False, help="Source NexaCloud invoice id — ledger idempotency key.")
|
||||
x_fc_stripe_invoice_id = fields.Char(index=True, copy=False)
|
||||
|
||||
_fc_nc_invoice_uniq = models.Constraint(
|
||||
"unique(x_fc_nexacloud_invoice_id)",
|
||||
"One Odoo invoice per NexaCloud invoice id.",
|
||||
)
|
||||
92
fusion_centralize_billing/models/charge.py
Normal file
92
fusion_centralize_billing/models/charge.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import math
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionBillingCharge(models.Model):
|
||||
"""Maps a plan + metric to quota + overage pricing.
|
||||
|
||||
This is where "5,000,000 included / $0.10 per 1k overage" (NexaMaps) or a
|
||||
NexaCloud CPU-seconds quota lives. Keyed by the shared ``plan_code`` the app
|
||||
references; Odoo owns the money, the app owns feature entitlements. See spec §5.1.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.charge"
|
||||
_description = "Fusion Billing — Metered Charge (quota + overage)"
|
||||
_order = "plan_code, name"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
plan_code = fields.Char(
|
||||
required=True, index=True,
|
||||
help="Shared plan_code the source app references (matches a sale.subscription.plan).",
|
||||
)
|
||||
plan_id = fields.Many2one(
|
||||
"sale.subscription.plan",
|
||||
help="Optional link to the Odoo recurrence/plan for this charge.",
|
||||
)
|
||||
metric_id = fields.Many2one(
|
||||
"fusion.billing.metric", required=True, ondelete="restrict",
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
"product.product", help="Usage product invoiced for overage.",
|
||||
)
|
||||
included_quota = fields.Float(
|
||||
default=0.0, help="Units included before overage applies, per period.",
|
||||
)
|
||||
price_per_unit = fields.Float(
|
||||
digits=(16, 6),
|
||||
help="Overage price per unit_batch. A Float (not Monetary) so sub-cent rates "
|
||||
"like $0.0075/core-hour are stored exactly — Monetary rounds to the "
|
||||
"currency's 2 decimals and would corrupt the rate. Final cent-rounding "
|
||||
"happens at the invoice line/total, not in the per-charge math.",
|
||||
)
|
||||
unit_batch = fields.Float(
|
||||
default=1.0, help="Batch size for overage pricing, e.g. 1000 = priced per 1k.",
|
||||
)
|
||||
charge_model = fields.Selection(
|
||||
[
|
||||
("standard", "Standard (per unit)"),
|
||||
("package", "Package"),
|
||||
],
|
||||
default="standard", required=True,
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
"res.currency", required=True,
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_price_non_negative = models.Constraint(
|
||||
"CHECK (price_per_unit >= 0)", "Overage price per unit cannot be negative.",
|
||||
)
|
||||
_unit_batch_positive = models.Constraint(
|
||||
"CHECK (unit_batch > 0)", "Unit batch must be greater than zero.",
|
||||
)
|
||||
|
||||
def _compute_billable(self, total_quantity):
|
||||
"""Return (overage_units, amount) for total period usage under this charge.
|
||||
|
||||
- overage_units = usage above included_quota (never negative)
|
||||
- 'standard': price the overage in (rounded-up) `unit_batch` blocks.
|
||||
- 'package': price whole packages over the RAW quantity (quota ignored for
|
||||
package counting); a partial package rounds up.
|
||||
|
||||
The amount keeps the rate's precision (rounded to 6 dp only to clear float
|
||||
noise) — it must NOT be rounded to cents here. Sub-cent rates (e.g.
|
||||
$0.0075/core-hour) and fractional totals are preserved so they match the
|
||||
source app's own sub-cent usage amounts; final cent-rounding happens once at
|
||||
the invoice line / invoice total, exactly as the source app does.
|
||||
"""
|
||||
self.ensure_one()
|
||||
overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0))
|
||||
batch = self.unit_batch or 1.0
|
||||
if self.charge_model == 'package':
|
||||
# whole packages over the RAW quantity (quota ignored for package counting)
|
||||
blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0
|
||||
return overage, round(blocks * (self.price_per_unit or 0.0), 6)
|
||||
# standard: price the overage in (rounded-up) batches
|
||||
blocks = math.ceil(overage / batch) if overage > 0 else 0
|
||||
return overage, round(blocks * (self.price_per_unit or 0.0), 6)
|
||||
32
fusion_centralize_billing/models/metric.py
Normal file
32
fusion_centralize_billing/models/metric.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionBillingMetric(models.Model):
|
||||
"""A billable metric (CPU-seconds, API calls, messages, tokens ...).
|
||||
|
||||
Defines how raw usage is aggregated within a billing period. See spec §5.1 / §6.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.metric"
|
||||
_description = "Fusion Billing — Billable Metric"
|
||||
_order = "code"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char(required=True, index=True)
|
||||
aggregation = fields.Selection(
|
||||
[
|
||||
("sum", "Sum"),
|
||||
("max", "Max"),
|
||||
("last", "Last value"),
|
||||
("unique_count", "Unique count"),
|
||||
],
|
||||
default="sum", required=True,
|
||||
)
|
||||
unit_label = fields.Char(help="e.g. CPU-seconds, API calls, messages, tokens.")
|
||||
rounding = fields.Float(default=1.0)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_code_uniq = models.Constraint("unique(code)", "Metric code must be unique.")
|
||||
128
fusion_centralize_billing/models/reconciliation.py
Normal file
128
fusion_centralize_billing/models/reconciliation.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBillingReconciliation(models.Model):
|
||||
"""Dual-run shadow-mode comparison: Odoo-computed vs the app's actual billing.
|
||||
|
||||
During phased cutover (NexaCloud first), Odoo computes invoices while the app
|
||||
keeps charging. This row records the per-customer, per-period delta so we only
|
||||
flip once deltas are within tolerance. See spec §10.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.reconciliation"
|
||||
_description = "Fusion Billing — Dual-Run Reconciliation"
|
||||
_order = "period desc, service_id"
|
||||
|
||||
service_id = fields.Many2one(
|
||||
"fusion.billing.service", required=True, ondelete="cascade", index=True,
|
||||
)
|
||||
partner_id = fields.Many2one("res.partner", required=True, ondelete="cascade", index=True)
|
||||
period = fields.Char(required=True, help="Billing period label, e.g. 2026-05.")
|
||||
external_subscription_id = fields.Char(
|
||||
index=True,
|
||||
help="Source-app subscription id this row reconciles (NexaCloud sub UUID). Part of "
|
||||
"the upsert key so a customer with multiple deployments gets one row PER "
|
||||
"subscription per period, not a single colliding row.")
|
||||
odoo_amount = fields.Monetary()
|
||||
external_amount = fields.Monetary(string="App-actual Amount")
|
||||
delta = fields.Monetary(help="odoo_amount - external_amount.")
|
||||
currency_id = fields.Many2one(
|
||||
"res.currency", required=True,
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
status = fields.Selection(
|
||||
[
|
||||
("match", "Within tolerance"),
|
||||
("delta", "Delta — investigate"),
|
||||
("resolved", "Resolved"),
|
||||
],
|
||||
default="delta", required=True, index=True,
|
||||
)
|
||||
note = fields.Text()
|
||||
|
||||
_service_sub_period_uniq = models.Constraint(
|
||||
"UNIQUE(service_id, external_subscription_id, period)",
|
||||
"One reconciliation row per service, subscription, and period.",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _compute_reconciliation(self, flat_amount, charge, cpu_seconds, external_amount,
|
||||
tolerance=0.01):
|
||||
"""Return (odoo_amount, delta, status).
|
||||
|
||||
odoo_amount = flat + CPU overage(cpu_seconds); delta = odoo - external;
|
||||
status 'match' if |delta| <= tolerance else 'delta'. Amounts are compared at cent
|
||||
precision (the dual-run cares about cent-level invoice parity)."""
|
||||
overage = 0.0
|
||||
if charge:
|
||||
_units, overage = charge._compute_billable(cpu_seconds)
|
||||
odoo_amount = round((flat_amount or 0.0) + (overage or 0.0), 2)
|
||||
delta = round(odoo_amount - (external_amount or 0.0), 2)
|
||||
status = 'match' if abs(delta) <= (tolerance or 0.0) else 'delta'
|
||||
return odoo_amount, delta, status
|
||||
|
||||
@api.model
|
||||
def _reconcile_rows(self, rows, tolerance=0.01):
|
||||
"""For each {subscription_external_id, period, cpu_seconds, external_amount},
|
||||
resolve the shadow sale.order, compute Odoo-vs-external, and UPSERT one
|
||||
reconciliation row keyed by (service_id, partner_id, period). Per-row isolated."""
|
||||
SaleOrder = self.env['sale.order']
|
||||
Charge = self.env['fusion.billing.charge']
|
||||
service = self.env['fusion.billing.service'].search(
|
||||
[('code', '=', 'nexacloud')], limit=1)
|
||||
if not service:
|
||||
raise UserError(
|
||||
"NexaCloud billing service not found — run the importer first so the "
|
||||
"service, catalog, and shadow subscriptions exist.")
|
||||
summary = {'match': 0, 'delta': 0, 'skipped': [], 'failed': []}
|
||||
for r in rows:
|
||||
sub_ext = str(r.get('subscription_external_id') or '')
|
||||
period = str(r.get('period') or '')
|
||||
try:
|
||||
sub = SaleOrder.search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
|
||||
if not sub:
|
||||
summary['skipped'].append(
|
||||
{'id': sub_ext, 'reason': 'unknown subscription'})
|
||||
continue
|
||||
charge = Charge.search(
|
||||
[('plan_code', '=', sub.x_fc_nexacloud_plan_id)], limit=1)
|
||||
plan_line = sub.order_line.filtered(
|
||||
lambda l: l.product_id.default_code
|
||||
and l.product_id.default_code.startswith('NC-PLAN-'))
|
||||
flat = plan_line[:1].price_unit
|
||||
external_amount = float(r.get('external_amount') or 0.0)
|
||||
odoo_amount, delta, status = self._compute_reconciliation(
|
||||
flat, charge, float(r.get('cpu_seconds') or 0.0),
|
||||
external_amount, tolerance)
|
||||
vals = {
|
||||
'service_id': service.id,
|
||||
'partner_id': sub.partner_id.id, 'period': period,
|
||||
'external_subscription_id': sub_ext,
|
||||
'odoo_amount': odoo_amount, 'external_amount': external_amount,
|
||||
'delta': delta, 'status': status,
|
||||
}
|
||||
# Upsert per (service, subscription, period) — NOT per partner — so a
|
||||
# customer with two deployments gets a row for each, no overwrite.
|
||||
existing = self.search([
|
||||
('service_id', '=', service.id),
|
||||
('external_subscription_id', '=', sub_ext),
|
||||
('period', '=', period)], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
self.create(vals)
|
||||
summary['match' if status == 'match' else 'delta'] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||
_logger.exception("Reconciliation row %s failed", sub_ext)
|
||||
summary['failed'].append(
|
||||
{'id': sub_ext, 'error': '%s: %s' % (type(e).__name__, e)})
|
||||
return summary
|
||||
12
fusion_centralize_billing/models/res_partner.py
Normal file
12
fusion_centralize_billing/models/res_partner.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = "res.partner"
|
||||
|
||||
x_fc_stripe_customer_id = fields.Char(
|
||||
index=True, copy=False,
|
||||
help="Existing Stripe customer id imported from a source app, reused at flip.")
|
||||
45
fusion_centralize_billing/models/sale_order.py
Normal file
45
fusion_centralize_billing/models/sale_order.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = "sale.order"
|
||||
|
||||
x_fc_nexacloud_subscription_id = fields.Char(
|
||||
index=True, copy=False,
|
||||
help="Source NexaCloud subscription id — the importer's idempotency key.")
|
||||
x_fc_nexacloud_deployment_id = fields.Char(index=True, copy=False)
|
||||
x_fc_nexacloud_plan_id = fields.Char(
|
||||
index=True, copy=False,
|
||||
help="Source NexaCloud plan id — links the shadow sub to its charge for 2d reconciliation.")
|
||||
x_fc_billing_service_id = fields.Many2one(
|
||||
"fusion.billing.service", index=True, copy=False, ondelete="set null")
|
||||
x_fc_shadow = fields.Boolean(
|
||||
default=False, copy=False,
|
||||
help="Imported in shadow mode: Odoo computes but must not charge/post/email.")
|
||||
|
||||
def _fc_rate_usage(self, charge, period_start, period_end):
|
||||
"""Aggregate this subscription's usage for `charge`'s metric in the period,
|
||||
compute the overage amount, and upsert a matching overage order line.
|
||||
Returns the amount.
|
||||
|
||||
A zero amount never *creates* a new line (no $0.00 overage clutter); if a
|
||||
line already exists it is still updated so a dropped-to-zero overage clears.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Usage = self.env['fusion.billing.usage']
|
||||
total = Usage._aggregate(self, charge.metric_id, period_start, period_end)
|
||||
_overage, amount = charge._compute_billable(total)
|
||||
if charge.product_id:
|
||||
line = self.order_line.filtered(lambda l: l.product_id == charge.product_id)
|
||||
if not line and amount == 0:
|
||||
return amount
|
||||
vals = {'product_uom_qty': 1, 'price_unit': amount}
|
||||
if line:
|
||||
line.write(vals)
|
||||
else:
|
||||
self.env['sale.order.line'].create(
|
||||
{'order_id': self.id, 'product_id': charge.product_id.id, **vals})
|
||||
return amount
|
||||
270
fusion_centralize_billing/models/service.py
Normal file
270
fusion_centralize_billing/models/service.py
Normal file
@@ -0,0 +1,270 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import secrets
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class FusionBillingService(models.Model):
|
||||
"""A source app that pushes billing data (NexaCloud / NexaDesk / NexaMaps).
|
||||
|
||||
The bearer API key is shown ONCE on generation and stored only as a SHA-256
|
||||
hash. This record is the auth + routing boundary for the inbound API and the
|
||||
target for outbound webhooks. See spec §5.1 / §7 / §8.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.service"
|
||||
_description = "Fusion Billing — Source Service"
|
||||
_order = "name"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char(
|
||||
required=True, index=True,
|
||||
help="Stable code the app identifies itself with, e.g. nexacloud / nexadesk / nexamaps.",
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
api_key_hash = fields.Char(
|
||||
string="API Key (SHA-256)",
|
||||
help="Hash of the bearer key. The raw key is displayed once at generation time.",
|
||||
)
|
||||
webhook_url = fields.Char(help="Endpoint this app exposes to receive billing webhooks.")
|
||||
webhook_secret = fields.Char(help="Shared secret for HMAC-SHA256 webhook signatures.")
|
||||
|
||||
account_link_ids = fields.One2many(
|
||||
"fusion.billing.account.link", "service_id", string="Customer Links",
|
||||
)
|
||||
account_link_count = fields.Integer(compute="_compute_account_link_count")
|
||||
|
||||
_code_uniq = models.Constraint("unique(code)", "Service code must be unique.")
|
||||
|
||||
@api.depends("account_link_ids")
|
||||
def _compute_account_link_count(self):
|
||||
for rec in self:
|
||||
rec.account_link_count = len(rec.account_link_ids)
|
||||
|
||||
@api.constrains("webhook_url")
|
||||
def _check_webhook_url(self):
|
||||
"""Reject SSRF-prone webhook targets: a non-empty URL must be https and must
|
||||
not point at localhost or a private / link-local / loopback IP literal. Empty
|
||||
is allowed (no webhook configured)."""
|
||||
for rec in self:
|
||||
url = (rec.webhook_url or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https":
|
||||
raise ValidationError("Webhook URL must use https.")
|
||||
host = parsed.hostname or ""
|
||||
if not host or host.lower() in ("localhost", "ip6-localhost", "ip6-loopback"):
|
||||
raise ValidationError(
|
||||
"Webhook URL must not target localhost or a private address.")
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
ip = None
|
||||
if ip is not None and (
|
||||
ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_reserved or ip.is_unspecified or ip.is_multicast
|
||||
):
|
||||
raise ValidationError(
|
||||
"Webhook URL must not target a private or loopback address.")
|
||||
|
||||
def action_generate_api_key(self):
|
||||
"""Generate a fresh bearer key, store only its hash, return the raw key.
|
||||
|
||||
TODO(spec §7): surface the raw key once in the UI (wizard/notification).
|
||||
"""
|
||||
self.ensure_one()
|
||||
raw = secrets.token_urlsafe(32)
|
||||
self.api_key_hash = hashlib.sha256(raw.encode()).hexdigest()
|
||||
return raw
|
||||
|
||||
@api.model
|
||||
def _match_api_key(self, raw_key):
|
||||
"""Return the active service whose stored hash matches raw_key, else empty recordset."""
|
||||
if not raw_key:
|
||||
return self.browse()
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1)
|
||||
|
||||
def _api_upsert_customer(self, payload):
|
||||
"""Resolve/create the partner link for an external account.
|
||||
|
||||
Defensive: a non-dict payload or a missing/empty ``external_id`` returns a
|
||||
4xx-shaped error instead of raising (C3).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not isinstance(payload, dict):
|
||||
return {'status': 'error', 'error': 'invalid payload'}
|
||||
ext = payload.get('external_id')
|
||||
if not ext:
|
||||
return {'status': 'error', 'error': 'external_id required'}
|
||||
link = self.env['fusion.billing.account.link']._resolve_or_create_partner(
|
||||
self, ext, name=payload.get('name'), email=payload.get('email'))
|
||||
return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext}
|
||||
|
||||
def _fc_resolve_subscription(self, external_ref):
|
||||
"""Resolve the subscription sale.order a usage event targets.
|
||||
|
||||
Prefer the source app's OWN id (``x_fc_nexacloud_subscription_id`` scoped to this
|
||||
service) so apps reference their own ids — this is what lets NexaCloud push usage
|
||||
against shadow subscriptions the importer created from its UUIDs. Falls back to a
|
||||
direct Odoo ``sale.order`` id for live-created subs (post-flip). Authorization is
|
||||
still enforced by the caller (partner must be linked to this service)."""
|
||||
self.ensure_one()
|
||||
SaleOrder = self.env['sale.order']
|
||||
sub = SaleOrder.search([
|
||||
('x_fc_nexacloud_subscription_id', '=', str(external_ref)),
|
||||
('x_fc_billing_service_id', '=', self.id),
|
||||
], limit=1)
|
||||
if sub:
|
||||
return sub
|
||||
try:
|
||||
candidate = SaleOrder.browse(int(external_ref))
|
||||
except (TypeError, ValueError):
|
||||
return SaleOrder
|
||||
# Don't let the integer fallback reach a DIFFERENT service's tagged subscription.
|
||||
# (Live, API-created subs carry no service tag and stay resolvable here; the caller
|
||||
# still enforces partner-is-linked-to-this-service authorization.)
|
||||
if candidate.exists() and candidate.x_fc_billing_service_id \
|
||||
and candidate.x_fc_billing_service_id != self:
|
||||
return SaleOrder
|
||||
return candidate
|
||||
|
||||
def _api_record_usage(self, payload):
|
||||
"""Ingest a batch of usage events.
|
||||
|
||||
Authorization (C2/C4): each event must target a subscription sale.order that
|
||||
(a) exists, (b) is actually a subscription, and (c) belongs to a customer THIS
|
||||
service is linked to. Any failing event is rejected and stops processing for
|
||||
that event without writing a usage row.
|
||||
|
||||
Validation (C3): a non-dict payload, a non-list ``events``, missing required
|
||||
keys, or non-numeric ``quantity``/ids return a 4xx-shaped error instead of
|
||||
raising (no 500s).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not isinstance(payload, dict):
|
||||
return {'status': 'error', 'error': 'invalid payload'}
|
||||
events = payload.get('events')
|
||||
if events is None:
|
||||
events = []
|
||||
if not isinstance(events, list):
|
||||
return {'status': 'error', 'error': 'events must be a list'}
|
||||
Usage = self.env['fusion.billing.usage']
|
||||
linked_partners = self.account_link_ids.mapped('partner_id')
|
||||
accepted = 0
|
||||
for ev in events:
|
||||
if not isinstance(ev, dict):
|
||||
return {'status': 'error', 'error': 'invalid event'}
|
||||
for key in ('subscription_external_id', 'metric_code', 'quantity',
|
||||
'period_start', 'period_end'):
|
||||
if ev.get(key) in (None, ''):
|
||||
return {'status': 'error', 'error': 'missing %s' % key}
|
||||
try:
|
||||
quantity = float(ev['quantity'])
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'error': 'invalid quantity'}
|
||||
sub = self._fc_resolve_subscription(ev['subscription_external_id'])
|
||||
if not sub.exists() or not sub.is_subscription \
|
||||
or sub.partner_id not in linked_partners:
|
||||
return {'status': 'error', 'error': 'unknown subscription'}
|
||||
try:
|
||||
Usage._record_usage(
|
||||
sub, ev['metric_code'], quantity,
|
||||
ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key'))
|
||||
except ValueError as e:
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
accepted += 1
|
||||
return {'status': 'ok', 'accepted': accepted}
|
||||
|
||||
def _api_catalog(self):
|
||||
self.ensure_one()
|
||||
charges = self.env['fusion.billing.charge'].search([('active', '=', True)])
|
||||
return {'status': 'ok', 'charges': [{
|
||||
'plan_code': c.plan_code, 'metric': c.metric_id.code,
|
||||
'included_quota': c.included_quota, 'price_per_unit': c.price_per_unit,
|
||||
'unit_batch': c.unit_batch, 'charge_model': c.charge_model,
|
||||
} for c in charges]}
|
||||
|
||||
def _api_create_subscription(self, payload):
|
||||
"""Create and confirm a subscription sale.order for an external customer.
|
||||
|
||||
The product on each line must have recurring_invoice=True so that
|
||||
Odoo recognises the order as a subscription with has_recurring_line and
|
||||
action_confirm() reaches subscription_state='3_progress'.
|
||||
|
||||
Validation (C3): a non-dict payload, a missing/unknown customer, a missing
|
||||
``plan_id``, a non-list ``lines``, or a non-numeric product id/quantity
|
||||
return a 4xx-shaped error instead of raising (no 500s).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not isinstance(payload, dict):
|
||||
return {'status': 'error', 'error': 'invalid payload'}
|
||||
if not payload.get('external_customer_id'):
|
||||
return {'status': 'error', 'error': 'external_customer_id required'}
|
||||
if not payload.get('plan_id'):
|
||||
return {'status': 'error', 'error': 'plan_id required'}
|
||||
try:
|
||||
plan_id = int(payload['plan_id'])
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'error': 'invalid plan_id'}
|
||||
link = self.env['fusion.billing.account.link'].search([
|
||||
('service_id', '=', self.id),
|
||||
('external_id', '=', payload.get('external_customer_id')),
|
||||
], limit=1)
|
||||
if not link:
|
||||
return {'status': 'error', 'error': 'unknown customer'}
|
||||
lines = payload.get('lines')
|
||||
if lines is None:
|
||||
lines = []
|
||||
if not isinstance(lines, list):
|
||||
return {'status': 'error', 'error': 'lines must be a list'}
|
||||
order_lines = []
|
||||
for line in lines:
|
||||
if not isinstance(line, dict) or line.get('product_id') in (None, ''):
|
||||
return {'status': 'error', 'error': 'invalid line'}
|
||||
try:
|
||||
product_id = int(line['product_id'])
|
||||
quantity = float(line.get('quantity', 1))
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'error': 'invalid line'}
|
||||
order_lines.append((0, 0, {
|
||||
'product_id': product_id,
|
||||
'product_uom_qty': quantity,
|
||||
}))
|
||||
sub = self.env['sale.order'].sudo().create({
|
||||
'partner_id': link.partner_id.id,
|
||||
'plan_id': plan_id,
|
||||
'order_line': order_lines,
|
||||
})
|
||||
sub.action_confirm()
|
||||
return {'status': 'ok', 'subscription_id': sub.id,
|
||||
'subscription_state': sub.subscription_state}
|
||||
|
||||
def _api_cancel_subscription(self, external_ref):
|
||||
"""Cancel (close) the subscription identified by ``external_ref``.
|
||||
|
||||
Authorization mirrors ``_api_record_usage``: the resolved sale.order must
|
||||
exist, be a subscription, and belong to a customer THIS service is linked
|
||||
to. Idempotent — closing an already-churned subscription returns ok.
|
||||
Validation (C3): an empty ref returns a 4xx-shaped error, never raises.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if external_ref in (None, ''):
|
||||
return {'status': 'error', 'error': 'subscription id required'}
|
||||
sub = self._fc_resolve_subscription(external_ref)
|
||||
linked_partners = self.account_link_ids.mapped('partner_id')
|
||||
if not sub.exists() or not sub.is_subscription \
|
||||
or sub.partner_id not in linked_partners:
|
||||
return {'status': 'error', 'error': 'unknown subscription'}
|
||||
if sub.subscription_state != '6_churn':
|
||||
sub.set_close()
|
||||
return {'status': 'ok', 'subscription_id': sub.id,
|
||||
'subscription_state': sub.subscription_state}
|
||||
120
fusion_centralize_billing/models/usage.py
Normal file
120
fusion_centralize_billing/models/usage.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionBillingUsage(models.Model):
|
||||
"""Aggregated usage rollup for a (subscription, metric, period).
|
||||
|
||||
Aggregate-push model: apps send periodic counters (not raw events). The
|
||||
``idempotency_key`` makes re-sent counters safe — they never double-count.
|
||||
A pre-invoice cron sums these and feeds billable quantity onto the subscription.
|
||||
|
||||
NOTE (Odoo 19, verified): the subscription is a ``sale.order`` with
|
||||
``is_subscription=True`` — there is no ``sale.subscription`` model. See spec §5.2.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.usage"
|
||||
_description = "Fusion Billing — Aggregated Usage (period rollup)"
|
||||
_order = "period_start desc"
|
||||
|
||||
subscription_id = fields.Many2one(
|
||||
"sale.order", required=True, ondelete="cascade", index=True,
|
||||
string="Subscription", domain=[("is_subscription", "=", True)],
|
||||
)
|
||||
metric_id = fields.Many2one(
|
||||
"fusion.billing.metric", required=True, ondelete="restrict", index=True,
|
||||
)
|
||||
period_start = fields.Datetime(required=True)
|
||||
period_end = fields.Datetime(required=True)
|
||||
quantity = fields.Float(default=0.0)
|
||||
source = fields.Char(default="push")
|
||||
idempotency_key = fields.Char(
|
||||
index=True, help="Dedupe key so re-sent counters never double-count.",
|
||||
)
|
||||
|
||||
_idempotency_uniq = models.Constraint(
|
||||
"unique(subscription_id, metric_id, idempotency_key)",
|
||||
"Usage idempotency key must be unique per subscription and metric.",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None):
|
||||
"""Upsert one aggregated usage row. Same idempotency key (scoped to the same
|
||||
subscription + metric) updates in place (no double-count)."""
|
||||
metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1)
|
||||
if not metric:
|
||||
raise ValueError("Unknown metric code: %s" % metric_code)
|
||||
vals = {
|
||||
'subscription_id': subscription.id,
|
||||
'metric_id': metric.id,
|
||||
'period_start': period_start,
|
||||
'period_end': period_end,
|
||||
'quantity': quantity,
|
||||
'idempotency_key': idem,
|
||||
}
|
||||
if idem:
|
||||
existing = self.search([
|
||||
('subscription_id', '=', subscription.id),
|
||||
('metric_id', '=', metric.id),
|
||||
('idempotency_key', '=', idem),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write({'quantity': quantity})
|
||||
return existing
|
||||
return self.create(vals)
|
||||
|
||||
@api.model
|
||||
def _cron_rate_open_periods(self):
|
||||
"""Hourly cron: for every active charge, aggregate usage and upsert overage lines
|
||||
on the in-progress subscriptions that are on the charge's own plan.
|
||||
|
||||
A charge only rates subscriptions whose ``plan_id`` matches the charge's
|
||||
``plan_id`` — never every subscription against every charge (C1/H4). The
|
||||
billing-period window is the subscription's real open period
|
||||
``[last_invoice_date or start_date, next_invoice_date)`` (H1)."""
|
||||
Charge = self.env['fusion.billing.charge'].search([('active', '=', True)])
|
||||
SaleOrder = self.env['sale.order']
|
||||
for charge in Charge:
|
||||
if not charge.plan_id:
|
||||
continue
|
||||
subs = SaleOrder.search([
|
||||
('is_subscription', '=', True),
|
||||
('subscription_state', '=', '3_progress'),
|
||||
('plan_id', '=', charge.plan_id.id),
|
||||
])
|
||||
for sub in subs:
|
||||
if not sub.next_invoice_date:
|
||||
continue
|
||||
period_end = fields.Datetime.to_datetime(sub.next_invoice_date)
|
||||
period_start = fields.Datetime.to_datetime(
|
||||
sub.last_invoice_date or sub.start_date)
|
||||
if not period_start:
|
||||
continue
|
||||
sub._fc_rate_usage(charge, period_start, period_end)
|
||||
|
||||
@api.model
|
||||
def _aggregate(self, subscription, metric, period_start, period_end):
|
||||
"""Aggregate stored usage for a subscription+metric over the half-open window
|
||||
``[period_start, period_end)``, anchored on each rollup's ``period_start``,
|
||||
using the metric's aggregation function."""
|
||||
rows = self.search([
|
||||
('subscription_id', '=', subscription.id),
|
||||
('metric_id', '=', metric.id),
|
||||
('period_start', '>=', period_start),
|
||||
('period_start', '<', period_end),
|
||||
])
|
||||
qtys = rows.mapped('quantity')
|
||||
if not qtys:
|
||||
return 0.0
|
||||
agg = metric.aggregation
|
||||
if agg == 'sum':
|
||||
return sum(qtys)
|
||||
if agg == 'max':
|
||||
return max(qtys)
|
||||
if agg == 'last':
|
||||
return rows.sorted('period_start')[-1].quantity
|
||||
if agg == 'unique_count':
|
||||
return float(len(set(qtys)))
|
||||
return sum(qtys)
|
||||
113
fusion_centralize_billing/models/webhook.py
Normal file
113
fusion_centralize_billing/models/webhook.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_ATTEMPTS = 8
|
||||
|
||||
|
||||
class FusionBillingWebhook(models.Model):
|
||||
"""Outbound webhook queue: lifecycle events delivered to source apps.
|
||||
|
||||
Processed by a cron with exponential backoff + HMAC-SHA256 signing, dead-lettered
|
||||
after N attempts (mirror the proven retry pattern in NexaDesk's
|
||||
lago-payment-retry-job). Apps react: suspend / restore / deprovision. See spec §8.
|
||||
|
||||
TODO(spec §8): cron processor, HMAC signing, backoff schedule.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.webhook"
|
||||
_description = "Fusion Billing — Outbound Webhook Event"
|
||||
_order = "create_date desc"
|
||||
|
||||
service_id = fields.Many2one(
|
||||
"fusion.billing.service", required=True, ondelete="cascade", index=True,
|
||||
)
|
||||
event_type = fields.Char(
|
||||
required=True, index=True,
|
||||
help="invoice.payment_failed / invoice.payment_succeeded / "
|
||||
"subscription.terminated / subscription.reactivated / usage.threshold_reached",
|
||||
)
|
||||
payload = fields.Json()
|
||||
body = fields.Text(
|
||||
help="Canonical JSON body that was signed and is POSTed verbatim "
|
||||
"(so the signature always matches the bytes on the wire).",
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
("pending", "Pending"),
|
||||
("sent", "Sent"),
|
||||
("failed", "Failed"),
|
||||
("dead", "Dead-lettered"),
|
||||
],
|
||||
default="pending", required=True, index=True,
|
||||
)
|
||||
attempts = fields.Integer(default=0)
|
||||
next_retry_at = fields.Datetime()
|
||||
signature = fields.Char(help="HMAC-SHA256 of the payload using the service webhook_secret.")
|
||||
last_error = fields.Text()
|
||||
|
||||
@api.model
|
||||
def _sign(self, secret, body):
|
||||
return hmac.new((secret or '').encode(), body.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
@api.model
|
||||
def _enqueue(self, service, event_type, payload):
|
||||
# Serialize the canonical body ONCE, store it, and sign that exact string so
|
||||
# the dispatched bytes always match the signature (no re-serialization drift).
|
||||
body = json.dumps(payload, sort_keys=True, separators=(',', ':'))
|
||||
return self.create({
|
||||
'service_id': service.id,
|
||||
'event_type': event_type,
|
||||
'payload': payload,
|
||||
'body': body,
|
||||
'signature': self._sign(service.webhook_secret, body),
|
||||
'state': 'pending',
|
||||
'next_retry_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
@api.model
|
||||
def _cron_dispatch(self):
|
||||
now = fields.Datetime.now()
|
||||
due = self.search([
|
||||
('state', 'in', ('pending', 'failed')),
|
||||
('next_retry_at', '<=', now),
|
||||
], limit=100)
|
||||
for wh in due:
|
||||
# POST the exact bytes that were signed at enqueue time. Fall back to
|
||||
# re-serializing the payload only for legacy rows enqueued before `body`
|
||||
# existed (the signature was computed over the same canonical form).
|
||||
body = wh.body or json.dumps(wh.payload, sort_keys=True, separators=(',', ':'))
|
||||
try:
|
||||
resp = requests.post(
|
||||
wh.service_id.webhook_url,
|
||||
data=body,
|
||||
headers={'Content-Type': 'application/json',
|
||||
'X-Fusion-Signature': wh.signature,
|
||||
'X-Fusion-Event': wh.event_type,
|
||||
'X-Fusion-Event-Id': str(wh.id)},
|
||||
timeout=10,
|
||||
)
|
||||
ok = 200 <= resp.status_code < 300
|
||||
except Exception as e: # noqa: BLE001 - record and retry
|
||||
ok = False
|
||||
wh.last_error = str(e)[:500]
|
||||
wh.attempts += 1
|
||||
if ok:
|
||||
wh.state = 'sent'
|
||||
elif wh.attempts >= MAX_ATTEMPTS:
|
||||
wh.state = 'dead'
|
||||
else:
|
||||
wh.state = 'failed'
|
||||
# Cap the exponential backoff so the interval can't overflow.
|
||||
wh.next_retry_at = now + timedelta(minutes=2 ** min(wh.attempts, 10))
|
||||
13
fusion_centralize_billing/security/ir.model.access.csv
Normal file
13
fusion_centralize_billing/security/ir.model.access.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_billing_service_admin,fusion.billing.service admin,model_fusion_billing_service,base.group_system,1,1,1,1
|
||||
access_fusion_billing_account_link_admin,fusion.billing.account.link admin,model_fusion_billing_account_link,base.group_system,1,1,1,1
|
||||
access_fusion_billing_metric_admin,fusion.billing.metric admin,model_fusion_billing_metric,base.group_system,1,1,1,1
|
||||
access_fusion_billing_charge_admin,fusion.billing.charge admin,model_fusion_billing_charge,base.group_system,1,1,1,1
|
||||
access_fusion_billing_usage_admin,fusion.billing.usage admin,model_fusion_billing_usage,base.group_system,1,1,1,1
|
||||
access_fusion_billing_webhook_admin,fusion.billing.webhook admin,model_fusion_billing_webhook,base.group_system,1,1,1,1
|
||||
access_fusion_billing_reconciliation_admin,fusion.billing.reconciliation admin,model_fusion_billing_reconciliation,base.group_system,1,1,1,1
|
||||
access_fusion_billing_metric_acct,fusion.billing.metric accountant,model_fusion_billing_metric,account.group_account_manager,1,1,1,0
|
||||
access_fusion_billing_charge_acct,fusion.billing.charge accountant,model_fusion_billing_charge,account.group_account_manager,1,1,1,0
|
||||
access_fusion_billing_reconciliation_acct,fusion.billing.reconciliation accountant,model_fusion_billing_reconciliation,account.group_account_manager,1,1,1,0
|
||||
access_fusion_billing_import_wizard,fusion.billing.import.wizard,model_fusion_billing_import_wizard,base.group_system,1,1,1,1
|
||||
access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1
|
||||
|
9
fusion_centralize_billing/tests/__init__.py
Normal file
9
fusion_centralize_billing/tests/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from . import test_identity
|
||||
from . import test_charge
|
||||
from . import test_usage
|
||||
from . import test_api
|
||||
from . import test_webhook
|
||||
from . import test_importer
|
||||
from . import test_reconciliation
|
||||
from . import test_invoice_ledger
|
||||
from . import test_subscription_cancel
|
||||
139
fusion_centralize_billing/tests/test_api.py
Normal file
139
fusion_centralize_billing/tests/test_api.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestApiHandlers(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaMaps', 'code': 'nexamaps'})
|
||||
self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
|
||||
def test_api_upsert_customer(self):
|
||||
res = self.service._api_upsert_customer(
|
||||
{'external_id': 'client-9', 'name': 'Globex', 'email': 'billing@globex.test'})
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
link = self.env['fusion.billing.account.link'].search(
|
||||
[('service_id', '=', self.service.id), ('external_id', '=', 'client-9')])
|
||||
self.assertEqual(link.partner_id.name, 'Globex')
|
||||
|
||||
def test_api_record_usage_batch(self):
|
||||
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
|
||||
partner = self.env['fusion.billing.account.link'].search(
|
||||
[('external_id', '=', 'client-9')]).partner_id
|
||||
sub = self.env['sale.order'].sudo().create(
|
||||
{'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id})
|
||||
res = self.service._api_record_usage({'events': [{
|
||||
'subscription_external_id': str(sub.id), 'metric_code': 'api_calls',
|
||||
'quantity': 1234.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
|
||||
'idempotency_key': 'maps:client-9:2026-05-01',
|
||||
}]})
|
||||
self.assertEqual(res['accepted'], 1)
|
||||
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
|
||||
self.assertEqual(usage.quantity, 1234.0)
|
||||
|
||||
def test_api_catalog_lists_active_charges(self):
|
||||
self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'Maps overage', 'plan_code': 'maps-business',
|
||||
'metric_id': self.env['fusion.billing.metric'].search([('code', '=', 'api_calls')]).id,
|
||||
'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0})
|
||||
cat = self.service._api_catalog()
|
||||
codes = [c['plan_code'] for c in cat['charges']]
|
||||
self.assertIn('maps-business', codes)
|
||||
|
||||
def test_api_create_subscription(self):
|
||||
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
|
||||
product = self.env['product.product'].sudo().create(
|
||||
{'name': 'Maps Business', 'type': 'service', 'recurring_invoice': True,
|
||||
'list_price': 249.0})
|
||||
res = self.service._api_create_subscription({
|
||||
'external_customer_id': 'client-9',
|
||||
'plan_id': self.plan.id,
|
||||
'lines': [{'product_id': product.id, 'quantity': 1}],
|
||||
})
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
sub = self.env['sale.order'].browse(res['subscription_id'])
|
||||
self.assertTrue(sub.is_subscription)
|
||||
self.assertEqual(sub.plan_id, self.plan)
|
||||
self.assertEqual(sub.subscription_state, '3_progress')
|
||||
|
||||
# ── item 4 (C3): malformed input returns a 4xx-shaped error, never raises ──
|
||||
def test_record_usage_missing_metric_code_returns_error(self):
|
||||
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
|
||||
partner = self.env['fusion.billing.account.link'].search(
|
||||
[('external_id', '=', 'client-9')]).partner_id
|
||||
sub = self.env['sale.order'].sudo().create(
|
||||
{'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id})
|
||||
# metric_code intentionally omitted — must return an error dict, not raise
|
||||
res = self.service._api_record_usage({'events': [{
|
||||
'subscription_external_id': str(sub.id),
|
||||
'quantity': 10.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
|
||||
}]})
|
||||
self.assertEqual(res['status'], 'error')
|
||||
# no usage row written
|
||||
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
|
||||
self.assertFalse(usage)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUsageAuthorization(TransactionCase):
|
||||
"""/usage must only accept events for subscriptions the calling service is linked
|
||||
to, and reject unknown / non-subscription targets (items 3/C2/C4)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.service_a = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'Service A', 'code': 'svc_a'})
|
||||
self.service_b = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'Service B', 'code': 'svc_b'})
|
||||
# Service A owns customer + subscription
|
||||
self.service_a._api_upsert_customer({'external_id': 'cust-a', 'name': 'Cust A'})
|
||||
self.partner_a = self.env['fusion.billing.account.link'].search(
|
||||
[('service_id', '=', self.service_a.id), ('external_id', '=', 'cust-a')]).partner_id
|
||||
self.sub_a = self.env['sale.order'].sudo().create(
|
||||
{'partner_id': self.partner_a.id, 'is_subscription': True, 'plan_id': self.plan.id})
|
||||
self.Usage = self.env['fusion.billing.usage'].sudo()
|
||||
|
||||
def _event(self, sub_id, idem):
|
||||
return {'events': [{
|
||||
'subscription_external_id': str(sub_id), 'metric_code': 'api_calls',
|
||||
'quantity': 42.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
|
||||
'idempotency_key': idem,
|
||||
}]}
|
||||
|
||||
def test_cross_service_usage_rejected(self):
|
||||
"""Service B pushing usage onto Service A's subscription is rejected, no row."""
|
||||
res = self.service_b._api_record_usage(self._event(self.sub_a.id, 'cross-1'))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
self.assertFalse(self.Usage.search([('subscription_id', '=', self.sub_a.id)]))
|
||||
|
||||
def test_same_service_usage_accepted(self):
|
||||
"""Positive control: Service A pushing onto its own subscription is accepted."""
|
||||
res = self.service_a._api_record_usage(self._event(self.sub_a.id, 'ok-1'))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(res['accepted'], 1)
|
||||
self.assertTrue(self.Usage.search([('subscription_id', '=', self.sub_a.id)]))
|
||||
|
||||
def test_nonexistent_subscription_rejected(self):
|
||||
res = self.service_a._api_record_usage(self._event(999_999_999, 'ghost-1'))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
|
||||
def test_non_subscription_order_rejected(self):
|
||||
"""A plain (non-subscription) sale.order owned by the linked customer is rejected."""
|
||||
plain = self.env['sale.order'].sudo().create({'partner_id': self.partner_a.id})
|
||||
self.assertFalse(plain.is_subscription)
|
||||
res = self.service_a._api_record_usage(self._event(plain.id, 'plain-1'))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
self.assertFalse(self.Usage.search([('subscription_id', '=', plain.id)]))
|
||||
74
fusion_centralize_billing/tests/test_charge.py
Normal file
74
fusion_centralize_billing/tests/test_charge.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from psycopg2 import IntegrityError
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.tools.misc import mute_logger
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestChargeMath(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
|
||||
|
||||
def _charge(self, **kw):
|
||||
vals = {
|
||||
'name': 'Maps overage', 'plan_code': 'maps-business',
|
||||
'metric_id': self.metric.id, 'charge_model': 'standard',
|
||||
'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fusion.billing.charge'].sudo().create(vals)
|
||||
|
||||
def test_under_quota_is_free(self):
|
||||
charge = self._charge()
|
||||
overage_units, amount = charge._compute_billable(4_000_000.0)
|
||||
self.assertEqual(overage_units, 0.0)
|
||||
self.assertEqual(amount, 0.0)
|
||||
|
||||
def test_standard_overage_per_1k(self):
|
||||
charge = self._charge()
|
||||
# 6,000,000 used - 5,000,000 quota = 1,000,000 overage = 1000 batches * $0.10
|
||||
overage_units, amount = charge._compute_billable(6_000_000.0)
|
||||
self.assertEqual(overage_units, 1_000_000.0)
|
||||
self.assertAlmostEqual(amount, 100.0, places=2)
|
||||
|
||||
def test_partial_batch_rounds_up(self):
|
||||
charge = self._charge(included_quota=0.0)
|
||||
# 1,500 units, batch 1000 -> 2 batches -> $0.20
|
||||
_, amount = charge._compute_billable(1_500.0)
|
||||
self.assertAlmostEqual(amount, 0.20, places=2)
|
||||
|
||||
def test_package_model_charges_whole_packages(self):
|
||||
charge = self._charge(charge_model='package', included_quota=0.0, unit_batch=1000.0, price_per_unit=2.0)
|
||||
# 2,001 units -> 3 packages -> $6.00
|
||||
_, amount = charge._compute_billable(2_001.0)
|
||||
self.assertAlmostEqual(amount, 6.0, places=2)
|
||||
|
||||
# ── item 10 (M7): only the two implemented charge models remain ──
|
||||
def test_charge_model_selection_limited(self):
|
||||
field = self.env['fusion.billing.charge']._fields['charge_model']
|
||||
keys = [k for k, _label in field.selection]
|
||||
self.assertEqual(sorted(keys), ['package', 'standard'])
|
||||
self.assertNotIn('graduated', keys)
|
||||
self.assertNotIn('volume', keys)
|
||||
|
||||
# ── item 11 (L1): currency is required and defaults to company currency ──
|
||||
def test_currency_required_and_defaulted(self):
|
||||
field = self.env['fusion.billing.charge']._fields['currency_id']
|
||||
self.assertTrue(field.required)
|
||||
charge = self._charge()
|
||||
self.assertEqual(charge.currency_id, self.env.company.currency_id)
|
||||
|
||||
# ── item 12 (L2): non-negative price + positive batch DB constraints ──
|
||||
def test_negative_price_rejected(self):
|
||||
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
|
||||
with self.env.cr.savepoint():
|
||||
self._charge(price_per_unit=-1.0)
|
||||
|
||||
def test_zero_batch_rejected(self):
|
||||
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
|
||||
with self.env.cr.savepoint():
|
||||
self._charge(unit_batch=0.0)
|
||||
55
fusion_centralize_billing/tests/test_identity.py
Normal file
55
fusion_centralize_billing/tests/test_identity.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestServiceApiKey(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Service = self.env['fusion.billing.service'].sudo()
|
||||
self.service = self.Service.create({'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
|
||||
def test_generate_and_match_api_key(self):
|
||||
raw = self.service.action_generate_api_key()
|
||||
self.assertTrue(raw and len(raw) >= 20)
|
||||
self.assertTrue(self.service.api_key_hash)
|
||||
self.assertNotEqual(raw, self.service.api_key_hash) # only the hash is stored
|
||||
matched = self.Service._match_api_key(raw)
|
||||
self.assertEqual(matched, self.service)
|
||||
|
||||
def test_match_api_key_rejects_unknown_and_inactive(self):
|
||||
raw = self.service.action_generate_api_key()
|
||||
self.assertFalse(self.Service._match_api_key('nope-not-a-key'))
|
||||
self.service.active = False
|
||||
self.assertFalse(self.Service._match_api_key(raw))
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestIdentityResolution(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaDesk', 'code': 'nexadesk'})
|
||||
self.Link = self.env['fusion.billing.account.link'].sudo()
|
||||
|
||||
def test_creates_partner_first_time(self):
|
||||
link = self.Link._resolve_or_create_partner(
|
||||
self.service, external_id='tenant-1', name='Acme Inc', email='ar@acme.test')
|
||||
self.assertTrue(link.partner_id)
|
||||
self.assertEqual(link.partner_id.name, 'Acme Inc')
|
||||
self.assertEqual(link.external_id, 'tenant-1')
|
||||
|
||||
def test_idempotent_same_external_id(self):
|
||||
a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test')
|
||||
b = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme Renamed', 'ar@acme.test')
|
||||
self.assertEqual(a, b) # same link row
|
||||
self.assertEqual(a.partner_id, b.partner_id) # same partner
|
||||
|
||||
def test_reuses_partner_by_email_across_services(self):
|
||||
other = self.env['fusion.billing.service'].sudo().create({'name': 'Maps', 'code': 'nexamaps'})
|
||||
a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test')
|
||||
b = self.Link._resolve_or_create_partner(other, 'client-9', 'Acme', 'ar@acme.test')
|
||||
self.assertEqual(a.partner_id, b.partner_id) # one unified customer
|
||||
self.assertNotEqual(a, b) # but distinct link rows
|
||||
279
fusion_centralize_billing/tests/test_importer.py
Normal file
279
fusion_centralize_billing/tests/test_importer.py
Normal file
@@ -0,0 +1,279 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
def _fixture():
|
||||
"""Two users, one plan, two subscriptions (monthly + yearly) — the canonical
|
||||
NexaCloud row dicts the importer consumes."""
|
||||
return {
|
||||
"users": [
|
||||
{"id": "u-1", "email": "ar@acme.test", "full_name": "Acme Inc",
|
||||
"company": "Acme", "billing_email": "billing@acme.test",
|
||||
"billing_address": "1 Main St", "billing_city": "Toronto",
|
||||
"billing_state": "ON", "billing_postal_code": "M1M1M1",
|
||||
"billing_country": "CA", "tax_id": "123456789RT0001",
|
||||
"stripe_customer_id": "cus_ACME"},
|
||||
{"id": "u-2", "email": "ops@globex.test", "full_name": "Globex",
|
||||
"company": "Globex", "billing_email": None, "billing_address": None,
|
||||
"billing_city": None, "billing_state": None, "billing_postal_code": None,
|
||||
"billing_country": None, "tax_id": None, "stripe_customer_id": "cus_GLBX"},
|
||||
],
|
||||
"plans": [
|
||||
{"id": "p-1", "name": "Starter", "price_monthly": 20.0,
|
||||
"price_yearly": 200.0, "cpu_seconds_quota": 18000.0, "is_active": True},
|
||||
],
|
||||
"subscriptions": [
|
||||
{"id": "s-1", "user_id": "u-1", "deployment_id": "d-1", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "monthly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"},
|
||||
{"id": "s-2", "user_id": "u-2", "deployment_id": "d-2", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "yearly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2027-05-01"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterIdentity(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
self.Link = self.env['fusion.billing.account.link'].sudo()
|
||||
|
||||
def test_imports_users_as_partners_and_links(self):
|
||||
self.Wizard._import_rows({'users': _fixture()['users']})
|
||||
svc = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')])
|
||||
self.assertTrue(svc, "importer must find-or-create the nexacloud service")
|
||||
link1 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-1')])
|
||||
self.assertEqual(len(link1), 1)
|
||||
self.assertEqual(link1.partner_id.email, 'billing@acme.test') # billing_email wins
|
||||
self.assertEqual(link1.partner_id.city, 'Toronto')
|
||||
self.assertEqual(link1.partner_id.vat, '123456789RT0001')
|
||||
self.assertEqual(link1.partner_id.x_fc_stripe_customer_id, 'cus_ACME')
|
||||
self.assertEqual(link1.partner_id.country_id.code, 'CA')
|
||||
link2 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-2')])
|
||||
self.assertEqual(link2.partner_id.email, 'ops@globex.test') # falls back to email
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterCatalog(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_imports_plan_as_charge_with_null_plan_id(self):
|
||||
self.Wizard._import_rows({'plans': _fixture()['plans']})
|
||||
metric = self.env['fusion.billing.metric'].search([('code', '=', 'cpu_seconds')])
|
||||
self.assertTrue(metric)
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
self.assertEqual(len(charge), 1)
|
||||
self.assertEqual(charge.metric_id, metric)
|
||||
self.assertEqual(charge.included_quota, 18000.0) # = plan.cpu_seconds_quota
|
||||
self.assertEqual(charge.unit_batch, 3600.0) # one core-hour
|
||||
self.assertAlmostEqual(charge.price_per_unit, 0.0075) # CAD per core-hour
|
||||
self.assertEqual(charge.charge_model, 'standard')
|
||||
self.assertFalse(charge.plan_id, "shadow: charge.plan_id must be NULL so the "
|
||||
"rating cron never auto-mutates order lines")
|
||||
self.assertTrue(charge.product_id, "charge needs an overage product")
|
||||
# the subscription product is a recurring product (so orders using it are subs)
|
||||
sub_product = self.env['product.product'].search(
|
||||
[('default_code', '=', 'NC-PLAN-p-1')])
|
||||
self.assertTrue(sub_product.recurring_invoice)
|
||||
|
||||
def test_charge_math_matches_nexacloud(self):
|
||||
# 18000 quota + 2 core-hours overage (7200s) -> 2 batches * $0.0075 = $0.015
|
||||
self.Wizard._import_rows({'plans': _fixture()['plans']})
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
_overage, amount = charge._compute_billable(18000.0 + 7200.0)
|
||||
self.assertAlmostEqual(amount, 0.015, places=4)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterSubscriptions(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_imports_one_draft_shadow_subscription_per_deployment(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
SaleOrder = self.env['sale.order']
|
||||
sub1 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
self.assertEqual(len(sub1), 1)
|
||||
self.assertTrue(sub1.is_subscription)
|
||||
self.assertTrue(sub1.x_fc_shadow)
|
||||
self.assertEqual(sub1.x_fc_nexacloud_deployment_id, 'd-1')
|
||||
self.assertNotEqual(sub1.subscription_state, '3_progress') # left in draft
|
||||
plan_line = sub1.order_line.filtered(
|
||||
lambda l: l.product_id.default_code == 'NC-PLAN-p-1')
|
||||
self.assertEqual(len(plan_line), 1)
|
||||
self.assertAlmostEqual(plan_line.price_unit, 20.0) # price_monthly
|
||||
sub2 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-2')])
|
||||
line2 = sub2.order_line.filtered(lambda l: l.product_id.default_code == 'NC-PLAN-p-1')
|
||||
self.assertAlmostEqual(line2.price_unit, 200.0) # price_yearly
|
||||
self.assertEqual(sub2.plan_id.billing_period_unit, 'year')
|
||||
|
||||
def test_subscription_records_nexacloud_plan_id(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
sub1 = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
self.assertEqual(sub1.x_fc_nexacloud_plan_id, 'p-1')
|
||||
|
||||
def test_subscription_skipped_when_user_or_plan_unresolved(self):
|
||||
data = _fixture()
|
||||
data['subscriptions'].append(
|
||||
{"id": "s-3", "user_id": "u-missing", "deployment_id": "d-3", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "monthly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"})
|
||||
summary = self.Wizard._import_rows(data)
|
||||
self.assertFalse(self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-3')]))
|
||||
self.assertTrue(any(s.get('id') == 's-3' for s in summary['skipped']))
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterIdempotencyDryRun(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def _counts(self):
|
||||
return (
|
||||
self.env['fusion.billing.account.link'].search_count([]),
|
||||
self.env['fusion.billing.charge'].search_count([]),
|
||||
self.env['sale.order'].search_count([('x_fc_shadow', '=', True)]),
|
||||
)
|
||||
|
||||
def test_rerun_updates_not_duplicates(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
before = self._counts()
|
||||
data = _fixture()
|
||||
data['plans'][0]['cpu_seconds_quota'] = 99999.0
|
||||
self.Wizard._import_rows(data)
|
||||
self.assertEqual(self._counts(), before, "re-run must upsert, not duplicate")
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
self.assertEqual(charge.included_quota, 99999.0)
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
summary = self.Wizard._import_rows(_fixture(), dry_run=True)
|
||||
self.assertTrue(summary.get('dry_run'))
|
||||
self.assertEqual(self._counts(), (0, 0, 0), "dry-run must not persist anything")
|
||||
self.assertFalse(
|
||||
self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')]))
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterShadowSafety(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_import_creates_no_invoice_and_no_payment_token(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
self.assertTrue(subs)
|
||||
partners = subs.mapped('partner_id')
|
||||
invoices = self.env['account.move'].search([
|
||||
('partner_id', 'in', partners.ids), ('move_type', '=', 'out_invoice')])
|
||||
self.assertFalse(invoices, "shadow import must not create any invoice")
|
||||
tokens = self.env['payment.token'].search([('partner_id', 'in', partners.ids)])
|
||||
self.assertFalse(tokens, "shadow import must not attach a payment token")
|
||||
charges = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
self.assertTrue(charges)
|
||||
self.assertFalse(any(charges.mapped('plan_id')))
|
||||
|
||||
def test_rating_cron_leaves_shadow_subscriptions_untouched(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
lines_before = sum(len(s.order_line) for s in subs)
|
||||
self.env['fusion.billing.usage']._cron_rate_open_periods()
|
||||
subs.invalidate_recordset()
|
||||
lines_after = sum(len(s.order_line) for s in subs)
|
||||
self.assertEqual(lines_before, lines_after,
|
||||
"charges with NULL plan_id must keep the rating cron a no-op")
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterErrorIsolation(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_one_bad_user_does_not_abort_the_batch(self):
|
||||
data = _fixture()
|
||||
# a row with no id -> str(urow['id']) raises KeyError, must be caught per-row
|
||||
data['users'].insert(0, {"email": "broken@x.test"})
|
||||
summary = self.Wizard._import_rows(data)
|
||||
self.assertEqual(
|
||||
self.env['fusion.billing.account.link'].search_count([]), 2)
|
||||
self.assertTrue(summary['failed'], "the bad row must be recorded in failed[]")
|
||||
self.assertTrue(any(f['kind'] == 'user' for f in summary['failed']))
|
||||
|
||||
def test_unknown_billing_cycle_is_failed_not_silently_monthly(self):
|
||||
data = _fixture()
|
||||
data['subscriptions'][0]['billing_cycle'] = 'annual' # not monthly/yearly
|
||||
summary = self.Wizard._import_rows(data)
|
||||
self.assertFalse(self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-1')]),
|
||||
"an unrecognized billing_cycle must NOT silently create a monthly sub")
|
||||
self.assertTrue(any(f['kind'] == 'subscription' and f['id'] == 's-1'
|
||||
for f in summary['failed']))
|
||||
|
||||
def test_missing_price_for_cycle_is_failed_not_zero(self):
|
||||
data = _fixture()
|
||||
data['plans'][0]['price_yearly'] = None # s-2 is yearly -> no price for it
|
||||
summary = self.Wizard._import_rows(data)
|
||||
# the yearly sub fails (no silent $0 line); the monthly one still imports
|
||||
self.assertFalse(self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-2')]),
|
||||
"a missing price for the cycle must NOT silently create a $0 line")
|
||||
self.assertTrue(self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-1')]))
|
||||
self.assertTrue(any(f['kind'] == 'subscription' and f['id'] == 's-2'
|
||||
for f in summary['failed']))
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterReadGuard(TransactionCase):
|
||||
|
||||
def test_missing_dsn_raises_usererror(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||
with self.assertRaises(UserError):
|
||||
wiz._read_nexacloud_rows()
|
||||
|
||||
def test_test_connection_guards_missing_dsn(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||
with self.assertRaises(UserError):
|
||||
wiz.action_test_connection()
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUsageApiSourceId(TransactionCase):
|
||||
"""The /usage API must resolve a subscription by NexaCloud's OWN id, so usage can be
|
||||
pushed against shadow subs the importer created from UUIDs (the flip-day gap)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.env['fusion.billing.import.wizard'].sudo()._import_rows(_fixture())
|
||||
self.service = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')])
|
||||
|
||||
def test_record_usage_resolves_by_nexacloud_subscription_id(self):
|
||||
res = self.service._api_record_usage({'events': [{
|
||||
'subscription_external_id': 's-1', # NexaCloud UUID, not the Odoo id
|
||||
'metric_code': 'cpu_seconds', 'quantity': 3600.0,
|
||||
'period_start': '2026-05-01', 'period_end': '2026-06-01',
|
||||
'idempotency_key': 'nc:s-1:2026-05'}]})
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(res['accepted'], 1)
|
||||
sub = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
|
||||
self.assertEqual(usage.quantity, 3600.0)
|
||||
281
fusion_centralize_billing/tests/test_invoice_ledger.py
Normal file
281
fusion_centralize_billing/tests/test_invoice_ledger.py
Normal file
@@ -0,0 +1,281 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
def _inv_fixture():
|
||||
return [{
|
||||
'id': 'inv-1', 'stripe_invoice_id': 'in_test1', 'invoice_number': 'NEX-0001',
|
||||
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
||||
'subtotal': 100.0, 'tax': 13.0, 'amount_paid': 0.0, 'paid_at': None,
|
||||
'items': [{'description': 'Odoo ERP Hosting (2026-05-01 to 2026-06-01)',
|
||||
'quantity': 1.0, 'unit_price': 100.0, 'amount': 100.0}],
|
||||
}]
|
||||
|
||||
|
||||
def _fc_ensure_ca_billing_env(env):
|
||||
"""Prod (`nexamain`) is a fully-configured Canadian company; a bare test DB is not.
|
||||
Give it the two things the ledger needs: an active CAD currency and a 13% sale tax
|
||||
matching invoice.ledger.wizard._fc_tax_for (type_tax_use=sale, percent, amount=13)."""
|
||||
cad = env.ref('base.CAD')
|
||||
if not cad.active:
|
||||
cad.sudo().write({'active': True})
|
||||
Tax = env['account.tax'].sudo()
|
||||
if not Tax.search([('type_tax_use', '=', 'sale'),
|
||||
('amount_type', '=', 'percent'), ('amount', '=', 13.0)], limit=1):
|
||||
Tax.create({'name': 'HST 13%', 'type_tax_use': 'sale',
|
||||
'amount_type': 'percent', 'amount': 13.0})
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerFamily(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_fc_ensure_ca_billing_env(self.env)
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_family_classification(self):
|
||||
f = self.W._fc_family_for
|
||||
self.assertEqual(f('Odoo ERP Hosting (2026-05-01 to 2026-06-01)'), 'hosting')
|
||||
self.assertEqual(f('WordPress Website Hosting - Managed (at $50.00 / month)'), 'hosting')
|
||||
self.assertEqual(f('Managed Odoo - Standard (at $49.99 / month)'), 'managed')
|
||||
self.assertEqual(f('Daily Backup Protection'), 'addons')
|
||||
self.assertEqual(f('Remaining time on Daily Backup Protection after 27 May 2026'), 'addons')
|
||||
self.assertEqual(f('Something Unmapped'), 'other')
|
||||
|
||||
def test_income_account_per_family_distinct(self):
|
||||
a_host = self.W._fc_income_account('hosting')
|
||||
a_add = self.W._fc_income_account('addons')
|
||||
self.assertEqual(a_host.account_type, 'income')
|
||||
self.assertNotEqual(a_host, a_add)
|
||||
self.assertEqual(self.W._fc_income_account('hosting'), a_host) # idempotent
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerTax(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_fc_ensure_ca_billing_env(self.env)
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
|
||||
tax = self.W._fc_tax_for(100.0, 13.0)
|
||||
self.assertTrue(tax, "expected an HST/13% sale tax on the Canadian COA")
|
||||
self.assertEqual(tax.type_tax_use, 'sale')
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 13.0, places=2)
|
||||
|
||||
def test_tax_for_zero_is_zero_or_empty(self):
|
||||
tax = self.W._fc_tax_for(100.0, 0.0)
|
||||
if tax:
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 0.0, places=2)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerIngest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_fc_ensure_ca_billing_env(self.env)
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.Move = self.env['account.move']
|
||||
|
||||
def test_ingest_creates_draft_invoice_with_right_totals(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(len(mv), 1)
|
||||
self.assertEqual(mv.move_type, 'out_invoice')
|
||||
self.assertEqual(mv.state, 'draft')
|
||||
self.assertAlmostEqual(mv.amount_untaxed, 100.0, places=2)
|
||||
self.assertAlmostEqual(mv.amount_tax, 13.0, places=2) # equals source tax
|
||||
self.assertAlmostEqual(mv.amount_total, 113.0, places=2)
|
||||
self.assertEqual(mv.partner_id.email, 'ar@acme.test')
|
||||
self.assertEqual(mv.invoice_line_ids.account_id, self.W._fc_income_account('hosting'))
|
||||
|
||||
def test_ingest_is_idempotent(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.assertEqual(self.Move.search_count(
|
||||
[('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1)
|
||||
|
||||
def test_paid_invoice_is_reconciled_and_shows_paid(self):
|
||||
data = _inv_fixture()
|
||||
data[0].update({'status': 'paid', 'amount_paid': 113.0, 'paid_at': '2026-05-02'})
|
||||
self.W._ingest_invoices(data, post=True)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||
|
||||
def test_post_ingested_posts_drafts(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
n = self.W._post_ingested()
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertGreaterEqual(n, 1)
|
||||
|
||||
def test_read_invoices_guards_missing_dsn(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
with self.assertRaises(UserError):
|
||||
self.W._read_nexacloud_invoices()
|
||||
|
||||
def test_unitemized_subtotal_gets_reconciling_line(self):
|
||||
data = [{
|
||||
'id': 'inv-base', 'stripe_invoice_id': 'in_base', 'invoice_number': 'NEX-BASE',
|
||||
'user_external_id': 'u-2', 'partner_name': 'Globex', 'partner_email': 'ops@globex.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
||||
'subtotal': 200.0, 'tax': 0.0, 'amount_paid': 0.0, 'paid_at': None,
|
||||
'items': [], # base plan billed via Stripe only — no line items
|
||||
}]
|
||||
self.W._ingest_invoices(data, post=False)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-base')])
|
||||
self.assertAlmostEqual(mv.amount_untaxed, 200.0, places=2) # captured via reconciling line
|
||||
self.assertTrue(any('base/unitemized' in (l.name or '') for l in mv.invoice_line_ids))
|
||||
|
||||
def test_zero_amount_invoice_skipped(self):
|
||||
data = [{'id': 'inv-zero', 'stripe_invoice_id': 'in_z', 'invoice_number': 'NEX-ZERO',
|
||||
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'paid',
|
||||
'subtotal': 0.0, 'tax': 0.0, 'amount_paid': 0.0, 'paid_at': None, 'items': []}]
|
||||
summary = self.W._ingest_invoices(data, post=False)
|
||||
self.assertFalse(self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-zero')]))
|
||||
self.assertTrue(any(s.get('reason') == 'zero-amount invoice' for s in summary['skipped']))
|
||||
|
||||
def test_post_and_reconcile_paid_only(self):
|
||||
base = _inv_fixture()[0]
|
||||
paid = dict(base, id='inv-paid', invoice_number='NEX-PAID',
|
||||
status='paid', amount_paid=113.0, paid_at='2026-05-02',
|
||||
invoice_date='2026-05-01')
|
||||
unpaid = dict(base, id='inv-unpaid', invoice_number='NEX-UNPAID',
|
||||
status='open', amount_paid=0.0, invoice_date='2026-04-01')
|
||||
self.W._ingest_invoices([paid, unpaid], post=False)
|
||||
summary = self.W._post_and_reconcile_paid([paid, unpaid])
|
||||
pm = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-paid')])
|
||||
um = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-unpaid')])
|
||||
self.assertEqual(pm.state, 'posted')
|
||||
self.assertIn(pm.payment_state, ('paid', 'in_payment'))
|
||||
self.assertEqual(str(pm.invoice_date), '2026-05-01') # original invoice date kept
|
||||
self.assertEqual(um.state, 'draft') # unpaid stays draft
|
||||
self.assertEqual(summary['posted'], 1)
|
||||
self.assertEqual(summary['skipped_unpaid'], 1)
|
||||
|
||||
def test_partner_named_by_company_not_person(self):
|
||||
data = _inv_fixture()
|
||||
data[0]['partner_company'] = 'Acme Holdings Inc' # full_name is "Acme"; company wins
|
||||
self.W._ingest_invoices(data, post=False)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.partner_id.name, 'Acme Holdings Inc')
|
||||
self.assertTrue(mv.partner_id.is_company)
|
||||
|
||||
def test_prune_shadow_removes_shadow_subs_only(self):
|
||||
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
||||
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
||||
counts = self.W._fc_prune_metered_shadow()
|
||||
self.assertFalse(shadow.exists())
|
||||
self.assertGreaterEqual(counts.get('subscriptions', 0), 1)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerVerifiedSync(TransactionCase):
|
||||
"""The go-forward path: invoice date + paid status come from the SOURCE billing
|
||||
system (Stripe/Lago), never NexaCloud's own fields. HTTP is never hit in tests —
|
||||
routing short-circuits when no API credentials are configured, and the cron is
|
||||
exercised with _read_nexacloud_invoices / _fc_verify patched out."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
_fc_ensure_ca_billing_env(self.env)
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.Move = self.env['account.move']
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
# ensure no real credentials -> verify helpers short-circuit, never touch network
|
||||
ICP.set_param('fusion_billing.stripe_api_key', '')
|
||||
ICP.set_param('fusion_billing.lago_api_url', '')
|
||||
ICP.set_param('fusion_billing.lago_api_key', '')
|
||||
|
||||
def test_ts_to_date_is_utc_and_none_safe(self):
|
||||
self.assertEqual(self.W._fc_ts_to_date(0), '1970-01-01')
|
||||
self.assertEqual(self.W._fc_ts_to_date(86400), '1970-01-02')
|
||||
self.assertIsNone(self.W._fc_ts_to_date(None))
|
||||
|
||||
def test_verify_routes_and_guards_without_network(self):
|
||||
# Stripe id with no key, Lago id with no config, and an unroutable id all -> None
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'in_abc'}))
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'lago:xyz'}))
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'mystery'}))
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': None}))
|
||||
|
||||
def test_verified_paid_uses_source_date_and_reconciles(self):
|
||||
v = {'inv-1': {'invoice_date': '2026-02-10', 'void': False, 'paid': True,
|
||||
'paid_at': '2026-02-12', 'amount_paid': 113.0}}
|
||||
self.W._ingest_invoices(_inv_fixture(), post=True, verified=v)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertEqual(str(mv.invoice_date), '2026-02-10') # source date, not NexaCloud's
|
||||
self.assertEqual(str(mv.date), str(mv.invoice_date)) # accounting date tracks it
|
||||
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||
|
||||
def test_verified_unpaid_posts_but_is_not_reconciled(self):
|
||||
v = {'inv-1': {'invoice_date': '2026-04-01', 'void': False, 'paid': False,
|
||||
'paid_at': None, 'amount_paid': 0.0}}
|
||||
self.W._ingest_invoices(_inv_fixture(), post=True, verified=v)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertEqual(str(mv.invoice_date), '2026-04-01')
|
||||
self.assertEqual(mv.payment_state, 'not_paid')
|
||||
|
||||
def test_cron_skips_void_draft_unverified_posts_only_finalized(self):
|
||||
base = _inv_fixture()[0]
|
||||
fixtures = [
|
||||
dict(base, id='inv-paid', invoice_number='NEX-P', stripe_invoice_id='in_paid'),
|
||||
dict(base, id='inv-void', invoice_number='NEX-V', stripe_invoice_id='in_void'),
|
||||
dict(base, id='inv-draft', invoice_number=None, stripe_invoice_id='in_draft'),
|
||||
dict(base, id='inv-unver', invoice_number='NEX-U', stripe_invoice_id='weird'),
|
||||
]
|
||||
verdicts = {
|
||||
'inv-paid': {'invoice_date': '2026-03-01', 'void': False, 'draft': False,
|
||||
'paid': True, 'paid_at': '2026-03-02', 'amount_paid': 113.0},
|
||||
'inv-void': {'invoice_date': '2026-03-01', 'void': True, 'draft': False,
|
||||
'paid': False, 'paid_at': None, 'amount_paid': 0.0},
|
||||
'inv-draft': {'invoice_date': '2026-03-01', 'void': False, 'draft': True,
|
||||
'paid': False, 'paid_at': None, 'amount_paid': 0.0},
|
||||
}
|
||||
cls = type(self.W)
|
||||
with patch.object(cls, '_read_nexacloud_invoices', return_value=fixtures), \
|
||||
patch.object(cls, '_fc_verify',
|
||||
side_effect=lambda inv: verdicts.get(str(inv.get('id')))):
|
||||
summary = self.W._cron_sync_verified()
|
||||
self.assertEqual(summary['skipped_void'], 1)
|
||||
self.assertEqual(summary['skipped_draft'], 1)
|
||||
self.assertEqual(summary['unverified'], ['inv-unver'])
|
||||
self.assertEqual(summary['posted'], 1)
|
||||
self.assertEqual(summary['reconciled'], 1)
|
||||
paid = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-paid')])
|
||||
self.assertEqual(paid.state, 'posted')
|
||||
self.assertEqual(str(paid.invoice_date), '2026-03-01')
|
||||
self.assertIn(paid.payment_state, ('paid', 'in_payment'))
|
||||
for skipped in ('inv-void', 'inv-draft', 'inv-unver'):
|
||||
self.assertFalse(self.Move.search([('x_fc_nexacloud_invoice_id', '=', skipped)]))
|
||||
|
||||
def test_cron_leaves_already_posted_untouched(self):
|
||||
# first run posts inv-paid; second run must not re-touch it (idempotent)
|
||||
base = _inv_fixture()[0]
|
||||
fixtures = [dict(base, id='inv-x', invoice_number='NEX-X', stripe_invoice_id='in_x')]
|
||||
verdict = {'invoice_date': '2026-03-01', 'void': False, 'paid': True,
|
||||
'paid_at': '2026-03-02', 'amount_paid': 113.0}
|
||||
cls = type(self.W)
|
||||
with patch.object(cls, '_read_nexacloud_invoices', return_value=fixtures), \
|
||||
patch.object(cls, '_fc_verify', side_effect=lambda inv: verdict):
|
||||
self.W._cron_sync_verified()
|
||||
summary2 = self.W._cron_sync_verified()
|
||||
self.assertEqual(summary2['already_posted'], 1)
|
||||
self.assertEqual(summary2['posted'], 0)
|
||||
self.assertEqual(self.Move.search_count(
|
||||
[('x_fc_nexacloud_invoice_id', '=', 'inv-x')]), 1)
|
||||
111
fusion_centralize_billing/tests/test_reconciliation.py
Normal file
111
fusion_centralize_billing/tests/test_reconciliation.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
from .test_importer import _fixture
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconciliationMath(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'CPU', 'plan_code': 'p-1', 'metric_id': self.metric.id,
|
||||
'included_quota': 18000.0, 'price_per_unit': 0.0075,
|
||||
'unit_batch': 3600.0, 'charge_model': 'standard'})
|
||||
|
||||
def test_match_within_tolerance(self):
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 10000.0, 20.0, 0.01) # under quota, no overage
|
||||
self.assertAlmostEqual(odoo_amt, 20.0)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
def test_overage_match(self):
|
||||
# flat 20 + 2 core-hours overage (7200s -> $0.015) = 20.015; external 20.02 (cent)
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 18000.0 + 7200.0, 20.02, 0.01)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
def test_delta_flags_mismatch(self):
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 18000.0, 25.0, 0.01) # external 25 vs odoo 20
|
||||
self.assertAlmostEqual(delta, -5.0, places=2)
|
||||
self.assertEqual(status, 'delta')
|
||||
|
||||
def test_no_charge_is_flat_only(self):
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.env['fusion.billing.charge'], 999999.0, 20.0, 0.01)
|
||||
self.assertAlmostEqual(odoo_amt, 20.0)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileRows(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
self.Wizard._import_rows(_fixture()) # shadow subs s-1/s-2 + p-1 charge
|
||||
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||
self.SaleOrder = self.env['sale.order']
|
||||
|
||||
def _partner_of(self, sub_ext):
|
||||
return self.SaleOrder.search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', sub_ext)]).partner_id
|
||||
|
||||
def test_creates_one_row_per_subscription_with_status(self):
|
||||
summary = self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0}, # flat 20 == 20 -> match
|
||||
{'subscription_external_id': 's-2', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 250.0}, # flat 200 vs 250 -> delta
|
||||
])
|
||||
rows = self.Recon.search([('period', '=', '2026-05')])
|
||||
self.assertEqual(len(rows), 2)
|
||||
s1 = rows.filtered(lambda r: r.odoo_amount == 20.0)
|
||||
self.assertEqual(s1.status, 'match')
|
||||
s2 = rows.filtered(lambda r: r.odoo_amount == 200.0)
|
||||
self.assertEqual(s2.status, 'delta')
|
||||
self.assertAlmostEqual(s2.delta, -50.0, places=2)
|
||||
self.assertEqual(summary['match'], 1)
|
||||
self.assertEqual(summary['delta'], 1)
|
||||
|
||||
def test_rerun_upserts(self):
|
||||
row = [{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0}]
|
||||
self.Recon._reconcile_rows(row)
|
||||
self.Recon._reconcile_rows(row)
|
||||
self.assertEqual(self.Recon.search_count([
|
||||
('period', '=', '2026-05'),
|
||||
('partner_id', '=', self._partner_of('s-1').id)]), 1)
|
||||
|
||||
def test_unknown_subscription_is_skipped(self):
|
||||
summary = self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 'nope', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 1.0}])
|
||||
self.assertTrue(any(s['id'] == 'nope' for s in summary['skipped']))
|
||||
|
||||
def test_two_subscriptions_same_partner_period_do_not_collide(self):
|
||||
# A customer with two deployments -> two subscriptions in the same period.
|
||||
data = _fixture()
|
||||
data['subscriptions'].append(
|
||||
{"id": "s-1b", "user_id": "u-1", "deployment_id": "d-1b", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "monthly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"})
|
||||
self.env['fusion.billing.import.wizard'].sudo()._import_rows(data)
|
||||
self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0},
|
||||
{'subscription_external_id': 's-1b', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 99.0},
|
||||
])
|
||||
partner = self._partner_of('s-1')
|
||||
rows = self.Recon.search(
|
||||
[('partner_id', '=', partner.id), ('period', '=', '2026-05')])
|
||||
self.assertEqual(len(rows), 2, "two subs for one partner must keep two rows")
|
||||
self.assertEqual(set(rows.mapped('external_subscription_id')), {'s-1', 's-1b'})
|
||||
54
fusion_centralize_billing/tests/test_subscription_cancel.py
Normal file
54
fusion_centralize_billing/tests/test_subscription_cancel.py
Normal file
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSubscriptionCancel(TransactionCase):
|
||||
|
||||
def _service(self, code, name):
|
||||
Svc = self.env['fusion.billing.service'].sudo()
|
||||
return Svc.search([('code', '=', code)], limit=1) or Svc.create(
|
||||
{'name': name, 'code': code})
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.product = self.env['product.product'].sudo().create(
|
||||
{'name': 'NexaCloud Plan', 'type': 'service',
|
||||
'recurring_invoice': True, 'list_price': 49.0})
|
||||
self.svc_a = self._service('nexacloud', 'NexaCloud')
|
||||
self.svc_b = self._service('other_app', 'Other App')
|
||||
self.svc_a._api_upsert_customer({'external_id': 'user-1', 'name': 'Acme'})
|
||||
res = self.svc_a._api_create_subscription({
|
||||
'external_customer_id': 'user-1', 'plan_id': self.plan.id,
|
||||
'lines': [{'product_id': self.product.id, 'quantity': 1}]})
|
||||
self.sub = self.env['sale.order'].browse(res['subscription_id'])
|
||||
|
||||
def test_cancel_closes_subscription(self):
|
||||
self.assertEqual(self.sub.subscription_state, '3_progress')
|
||||
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(self.sub.subscription_state, '6_churn')
|
||||
|
||||
def test_cancel_is_idempotent(self):
|
||||
self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
res = self.svc_a._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(self.sub.subscription_state, '6_churn')
|
||||
|
||||
def test_cancel_unknown_subscription_rejected(self):
|
||||
res = self.svc_a._api_cancel_subscription('999999999')
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
|
||||
def test_cancel_cross_service_rejected(self):
|
||||
# svc_b is not linked to the customer that owns self.sub
|
||||
res = self.svc_b._api_cancel_subscription(str(self.sub.id))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
self.assertEqual(self.sub.subscription_state, '3_progress')
|
||||
|
||||
def test_cancel_missing_id_rejected(self):
|
||||
res = self.svc_a._api_cancel_subscription('')
|
||||
self.assertEqual(res['status'], 'error')
|
||||
173
fusion_centralize_billing/tests/test_usage.py
Normal file
173
fusion_centralize_billing/tests/test_usage.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestRatingCron(TransactionCase):
|
||||
"""The rating cron must only rate a subscription against charges on its OWN plan
|
||||
(items 1/C1/H4) and over the subscription's real open billing period (item 5/H1)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Metric = self.env['fusion.billing.metric'].sudo()
|
||||
self.metric = Metric.search([('code', '=', 'cpu_seconds')], limit=1) or Metric.create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.plan_b = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Plan B', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
|
||||
self.recurring_product = self.env['product.product'].sudo().create(
|
||||
{'name': 'Plan seat', 'type': 'service', 'recurring_invoice': True,
|
||||
'list_price': 10.0})
|
||||
self.overage_product = self.env['product.product'].sudo().create(
|
||||
{'name': 'CPU overage', 'type': 'service', 'list_price': 0.0})
|
||||
self.Usage = self.env['fusion.billing.usage'].sudo()
|
||||
|
||||
def _confirmed_sub(self, plan):
|
||||
sub = self.env['sale.order'].sudo().create({
|
||||
'partner_id': self.partner.id, 'plan_id': plan.id,
|
||||
'order_line': [(0, 0, {'product_id': self.recurring_product.id,
|
||||
'product_uom_qty': 1})],
|
||||
})
|
||||
sub.action_confirm()
|
||||
# widen the computed billing window so usage in May is in-period
|
||||
sub.write({'start_date': '2026-05-01', 'next_invoice_date': '2026-06-01'})
|
||||
return sub
|
||||
|
||||
def test_cron_rates_only_matching_plan(self):
|
||||
sub_a = self._confirmed_sub(self.plan_a)
|
||||
sub_b = self._confirmed_sub(self.plan_b)
|
||||
self.assertEqual(sub_a.subscription_state, '3_progress')
|
||||
self.assertEqual(sub_b.subscription_state, '3_progress')
|
||||
# one charge, linked to Plan A only
|
||||
charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'CPU overage', 'plan_code': 'plan-a', 'plan_id': self.plan_a.id,
|
||||
'metric_id': self.metric.id, 'product_id': self.overage_product.id,
|
||||
'included_quota': 100.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0,
|
||||
'charge_model': 'standard'})
|
||||
# usage recorded on BOTH subs, in the open period
|
||||
self.Usage._record_usage(sub_a, 'cpu_seconds', 1100.0,
|
||||
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='a1')
|
||||
self.Usage._record_usage(sub_b, 'cpu_seconds', 1100.0,
|
||||
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='b1')
|
||||
|
||||
self.Usage._cron_rate_open_periods()
|
||||
|
||||
# Plan A sub IS rated (window captured the usage → overage line present)
|
||||
line_a = sub_a.order_line.filtered(lambda l: l.product_id == self.overage_product)
|
||||
self.assertTrue(line_a, "Plan A subscription should be rated by the Plan A charge")
|
||||
self.assertAlmostEqual(line_a.price_unit, 0.10, places=2)
|
||||
# Plan B sub is NOT rated by the Plan A charge
|
||||
line_b = sub_b.order_line.filtered(lambda l: l.product_id == self.overage_product)
|
||||
self.assertFalse(line_b, "Plan B subscription must NOT be rated by the Plan A charge")
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUsageIngestion(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Metric = self.env['fusion.billing.metric'].sudo()
|
||||
self.metric = Metric.search([('code', '=', 'cpu_seconds')], limit=1) or Metric.create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
|
||||
self.sub = self.env['sale.order'].sudo().create({
|
||||
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
|
||||
})
|
||||
self.Usage = self.env['fusion.billing.usage'].sudo()
|
||||
|
||||
def test_record_usage_creates_row(self):
|
||||
u = self.Usage._record_usage(
|
||||
self.sub, 'cpu_seconds', 120.0,
|
||||
'2026-05-01 00:00:00', '2026-06-01 00:00:00', idem='nexacloud:cpu:sub1:2026-05-01')
|
||||
self.assertEqual(u.quantity, 120.0)
|
||||
self.assertEqual(u.metric_id, self.metric)
|
||||
|
||||
def test_idempotent_key_updates_not_duplicates(self):
|
||||
k = 'nexacloud:cpu:sub1:2026-05-01'
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 100.0, '2026-05-01', '2026-06-01', idem=k)
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 175.0, '2026-05-01', '2026-06-01', idem=k)
|
||||
rows = self.Usage.search([('idempotency_key', '=', k)])
|
||||
self.assertEqual(len(rows), 1) # no duplicate
|
||||
self.assertEqual(rows.quantity, 175.0) # last value wins for the same key
|
||||
|
||||
def test_aggregate_sum(self):
|
||||
for i, q in enumerate([10.0, 20.0, 30.0]):
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', q,
|
||||
'2026-05-01', '2026-06-01', idem='cpu-%d' % i)
|
||||
total = self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01')
|
||||
self.assertEqual(total, 60.0)
|
||||
|
||||
def test_aggregate_max(self):
|
||||
self.metric.aggregation = 'max'
|
||||
for i, q in enumerate([10.0, 55.0, 30.0]):
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', q,
|
||||
'2026-05-01', '2026-06-01', idem='m-%d' % i)
|
||||
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 55.0)
|
||||
|
||||
def test_aggregate_excludes_other_periods(self):
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr')
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may')
|
||||
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0)
|
||||
|
||||
def test_rate_open_period_creates_overage_line(self):
|
||||
product = self.env['product.product'].sudo().create(
|
||||
{'name': 'API overage', 'type': 'service', 'list_price': 0.0})
|
||||
charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id,
|
||||
'product_id': product.id, 'included_quota': 100.0,
|
||||
'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'})
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 1100.0,
|
||||
'2026-05-01', '2026-06-01', idem='r1')
|
||||
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
|
||||
# 1100 - 100 = 1000 overage = 1 batch * $0.10 = $0.10
|
||||
self.assertAlmostEqual(amount, 0.10, places=2)
|
||||
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
|
||||
self.assertTrue(line)
|
||||
|
||||
# ── item 6 (H2): half-open aggregation window anchored on period_start ──
|
||||
def test_aggregate_daily_rollups_in_window(self):
|
||||
"""Three DAILY rollups (period_start 05-01/-08/-15, each period_end +1 day)
|
||||
sum correctly for the half-open window ['2026-05-01', '2026-06-01')."""
|
||||
rollups = [
|
||||
('2026-05-01 00:00:00', '2026-05-02 00:00:00', 3.0),
|
||||
('2026-05-08 00:00:00', '2026-05-09 00:00:00', 5.0),
|
||||
('2026-05-15 00:00:00', '2026-05-16 00:00:00', 7.0),
|
||||
]
|
||||
for i, (ps, pe, q) in enumerate(rollups):
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', q, ps, pe, idem='daily-%d' % i)
|
||||
total = self.Usage._aggregate(
|
||||
self.sub, self.metric, '2026-05-01 00:00:00', '2026-06-01 00:00:00')
|
||||
self.assertEqual(total, 15.0) # 3 + 5 + 7
|
||||
|
||||
# ── item 7 (H3): idempotency key is scoped per (subscription, metric) ──
|
||||
def test_same_idempotency_key_distinct_subscriptions(self):
|
||||
"""The SAME idempotency key on two DIFFERENT subscriptions creates TWO rows."""
|
||||
sub2 = self.env['sale.order'].sudo().create({
|
||||
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
|
||||
})
|
||||
key = 'shared-idem-key'
|
||||
a = self.Usage._record_usage(self.sub, 'cpu_seconds', 10.0, '2026-05-01', '2026-06-01', idem=key)
|
||||
b = self.Usage._record_usage(sub2, 'cpu_seconds', 20.0, '2026-05-01', '2026-06-01', idem=key)
|
||||
self.assertNotEqual(a, b) # distinct rows, no collision
|
||||
rows = self.Usage.search([('idempotency_key', '=', key)])
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(a.quantity, 10.0)
|
||||
self.assertEqual(b.quantity, 20.0)
|
||||
|
||||
# ── item 2 (C1): zero aggregated usage creates no overage line ──
|
||||
def test_zero_usage_creates_no_line(self):
|
||||
product = self.env['product.product'].sudo().create(
|
||||
{'name': 'API overage', 'type': 'service', 'list_price': 0.0})
|
||||
charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id,
|
||||
'product_id': product.id, 'included_quota': 100.0,
|
||||
'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'})
|
||||
# no usage recorded → aggregate is 0 → amount 0 → no line created
|
||||
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
|
||||
self.assertEqual(amount, 0.0)
|
||||
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
|
||||
self.assertFalse(line)
|
||||
105
fusion_centralize_billing/tests/test_webhook.py
Normal file
105
fusion_centralize_billing/tests/test_webhook.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestWebhookEngine(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
Service = self.env['fusion.billing.service'].sudo()
|
||||
vals = {
|
||||
'name': 'NexaCloud', 'code': 'nexacloud',
|
||||
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook',
|
||||
'webhook_secret': 'whsec_test',
|
||||
}
|
||||
self.service = Service.search([('code', '=', 'nexacloud')], limit=1)
|
||||
if self.service:
|
||||
self.service.write(vals)
|
||||
else:
|
||||
self.service = Service.create(vals)
|
||||
self.Webhook = self.env['fusion.billing.webhook'].sudo()
|
||||
|
||||
def test_enqueue_signs_payload(self):
|
||||
wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-1'})
|
||||
self.assertEqual(wh.state, 'pending')
|
||||
body = json.dumps({'invoice': 'INV-1'}, sort_keys=True, separators=(',', ':'))
|
||||
expected = hmac.new(b'whsec_test', body.encode(), hashlib.sha256).hexdigest()
|
||||
self.assertEqual(wh.signature, expected)
|
||||
|
||||
def test_dispatch_marks_sent_on_2xx(self):
|
||||
wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-2'})
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = 'ok'
|
||||
|
||||
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
|
||||
return_value=_Resp()) as mock_post:
|
||||
self.Webhook._cron_dispatch()
|
||||
self.assertTrue(mock_post.called)
|
||||
self.assertEqual(wh.state, 'sent')
|
||||
|
||||
def test_dispatch_retries_then_deadletters(self):
|
||||
wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-3'})
|
||||
wh.write({'attempts': 7}) # already past max
|
||||
|
||||
class _Resp:
|
||||
status_code = 500
|
||||
text = 'err'
|
||||
|
||||
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
|
||||
return_value=_Resp()):
|
||||
self.Webhook._cron_dispatch()
|
||||
self.assertEqual(wh.state, 'dead')
|
||||
|
||||
# ── item 8 (H5): dispatch POSTs the stored body verbatim + event-id header ──
|
||||
def test_dispatch_posts_stored_body_and_event_id(self):
|
||||
wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-9'})
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = 'ok'
|
||||
|
||||
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
|
||||
return_value=_Resp()) as mock_post:
|
||||
self.Webhook._cron_dispatch()
|
||||
self.assertTrue(mock_post.called)
|
||||
_args, kwargs = mock_post.call_args
|
||||
# the exact stored body is POSTed (not a re-serialized payload)
|
||||
self.assertEqual(kwargs['data'], wh.body)
|
||||
self.assertEqual(wh.body, json.dumps(
|
||||
{'invoice': 'INV-9'}, sort_keys=True, separators=(',', ':')))
|
||||
# signature matches the bytes on the wire
|
||||
expected = hmac.new(b'whsec_test', wh.body.encode(), hashlib.sha256).hexdigest()
|
||||
self.assertEqual(kwargs['headers']['X-Fusion-Signature'], expected)
|
||||
# event id header present and correct
|
||||
self.assertEqual(kwargs['headers']['X-Fusion-Event-Id'], str(wh.id))
|
||||
|
||||
# ── item 9 (H6): SSRF guard on webhook_url ──
|
||||
def test_webhook_url_rejects_loopback(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['fusion.billing.service'].sudo().create({
|
||||
'name': 'Evil', 'code': 'evil', 'webhook_url': 'http://127.0.0.1/x'})
|
||||
|
||||
def test_webhook_url_rejects_private_and_http(self):
|
||||
for bad in ('http://10.0.0.5/hook', # private + non-https
|
||||
'https://192.168.1.10/hook', # private
|
||||
'https://localhost/hook', # localhost host
|
||||
'https://169.254.169.254/latest', # link-local metadata
|
||||
'http://api.example.com/hook'): # non-https
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['fusion.billing.service'].sudo().create({
|
||||
'name': 'Bad', 'code': 'bad-%s' % bad, 'webhook_url': bad})
|
||||
|
||||
def test_webhook_url_allows_public_https(self):
|
||||
svc = self.env['fusion.billing.service'].sudo().create({
|
||||
'name': 'Good', 'code': 'good',
|
||||
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook'})
|
||||
self.assertTrue(svc.id)
|
||||
47
fusion_centralize_billing/views/import_wizard_views.xml
Normal file
47
fusion_centralize_billing/views/import_wizard_views.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_billing_import_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.import.wizard.form</field>
|
||||
<field name="model">fusion.billing.import.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Import from NexaCloud">
|
||||
<div class="alert alert-danger" role="alert" invisible="failed_count == 0">
|
||||
<strong>Import completed with errors: </strong>
|
||||
<field name="failed_count" class="oe_inline" readonly="1"/> row(s) failed — see Result below.
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert" invisible="skipped_count == 0">
|
||||
<field name="skipped_count" class="oe_inline" readonly="1"/> row(s) skipped (unresolved customer/plan) — see Result below.
|
||||
</div>
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_test_connection" type="object"
|
||||
string="Test Connection" class="btn-secondary"/>
|
||||
<button name="action_run_import" type="object" string="Run Import"
|
||||
class="btn-primary"/>
|
||||
<button name="action_run_reconciliation" type="object"
|
||||
string="Run Reconciliation" class="btn-secondary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_billing_import_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Import from NexaCloud</field>
|
||||
<field name="res_model">fusion.billing.import.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_billing_root" name="Fusion Billing"
|
||||
parent="account.menu_finance" sequence="90"/>
|
||||
<menuitem id="menu_fusion_billing_import" name="Import from NexaCloud"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fusion_billing_import_wizard" sequence="10"
|
||||
groups="base.group_system"/>
|
||||
</odoo>
|
||||
44
fusion_centralize_billing/views/invoice_ledger_views.xml
Normal file
44
fusion_centralize_billing/views/invoice_ledger_views.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fc_invoice_ledger_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.invoice.ledger.wizard.form</field>
|
||||
<field name="model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Ingest NexaCloud Invoices">
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
<field name="auto_post"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_run" type="object" string="Run" class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fc_invoice_ledger_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Ingest NexaCloud Invoices</field>
|
||||
<field name="res_model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fc_invoice_ledger" name="Ingest NexaCloud Invoices"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fc_invoice_ledger_wizard" sequence="20"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Ingest NexaCloud invoices (daily)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
fusion_centralize_billing/wizards/__init__.py
Normal file
2
fusion_centralize_billing/wizards/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import import_wizard
|
||||
from . import invoice_ledger
|
||||
449
fusion_centralize_billing/wizards/import_wizard.py
Normal file
449
fusion_centralize_billing/wizards/import_wizard.py
Normal file
@@ -0,0 +1,449 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""NexaCloud → Odoo billing importer (sub-project #2a).
|
||||
|
||||
One-time, re-runnable, read-only backfill: read the NexaCloud Postgres and create the
|
||||
equivalent Odoo records (partners + links, a cpu_seconds charge catalog, one DRAFT
|
||||
shadow ``sale.order`` per deployment). Shadow-safe by construction — see the design spec
|
||||
``docs/superpowers/specs/2026-05-27-nexacloud-billing-importer-design.md``.
|
||||
|
||||
Logic lives in model methods so it is unit-testable headless; the wizard button only
|
||||
calls ``_read_nexacloud_rows()`` → ``_import_rows()``.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
NEXACLOUD_CODE = "nexacloud"
|
||||
CPU_METRIC_CODE = "cpu_seconds"
|
||||
CPU_RATE_PER_CORE_HOUR = 0.0075 # NexaCloud CPU rate, CAD per core-hour
|
||||
CPU_SECONDS_PER_CORE_HOUR = 3600.0 # one core-hour = 3600 cpu-seconds
|
||||
|
||||
|
||||
class FusionBillingImportWizard(models.TransientModel):
|
||||
_name = "fusion.billing.import.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Importer"
|
||||
|
||||
dry_run = fields.Boolean(
|
||||
default=True,
|
||||
help="Read and report what would be imported, without writing anything.")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
failed_count = fields.Integer(readonly=True)
|
||||
skipped_count = fields.Integer(readonly=True)
|
||||
|
||||
def action_run_import(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_rows()
|
||||
summary = self._import_rows(data, dry_run=self.dry_run)
|
||||
failed = summary.get("failed") or []
|
||||
skipped = summary.get("skipped") or []
|
||||
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||
self.failed_count = len(failed)
|
||||
self.skipped_count = len(skipped)
|
||||
# A partial billing import must be loud, not buried in the JSON. Log at ERROR
|
||||
# so it survives nexa's log_level=warn (INFO is suppressed there).
|
||||
if failed:
|
||||
_logger.error("NexaCloud import: %s row(s) FAILED%s: %s",
|
||||
len(failed), " (dry-run)" if self.dry_run else "", failed)
|
||||
if skipped:
|
||||
_logger.warning("NexaCloud import: %s row(s) skipped: %s", len(skipped), skipped)
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": self._name,
|
||||
"res_id": self.id,
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
def action_test_connection(self):
|
||||
"""Read-only connectivity + schema check: connect, read the source tables, and
|
||||
report row counts WITHOUT importing anything. The safe first step before a
|
||||
dry-run — surfaces a bad DSN, no network route, or a schema drift up front."""
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_rows()
|
||||
msg = "Connected. Read %s user(s), %s plan(s), %s subscription(s)." % (
|
||||
len(data.get("users", [])), len(data.get("plans", [])),
|
||||
len(data.get("subscriptions", [])))
|
||||
return {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "display_notification",
|
||||
"params": {"title": "NexaCloud connection OK", "message": msg,
|
||||
"type": "success", "sticky": False},
|
||||
}
|
||||
|
||||
def action_run_reconciliation(self):
|
||||
"""Read NexaCloud usage + invoice actuals and record per-subscription/period
|
||||
Odoo-vs-NexaCloud deltas in fusion.billing.reconciliation. Read-only on
|
||||
NexaCloud; writes only reconciliation rows (shadow-safe)."""
|
||||
self.ensure_one()
|
||||
rows = self._read_reconciliation_rows()
|
||||
summary = self.env["fusion.billing.reconciliation"]._reconcile_rows(rows)
|
||||
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||
self.failed_count = len(summary.get("failed") or [])
|
||||
self.skipped_count = len(summary.get("skipped") or [])
|
||||
if summary.get("delta") or summary.get("failed"):
|
||||
_logger.error(
|
||||
"NexaCloud reconciliation: %s delta, %s failed, %s skipped row(s): %s",
|
||||
summary.get("delta"), len(summary.get("failed") or []),
|
||||
len(summary.get("skipped") or []), summary)
|
||||
return {
|
||||
"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new",
|
||||
}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_rows(self):
|
||||
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
|
||||
ir.config_parameter 'fusion_billing.nexacloud_dsn') and return rows as dicts.
|
||||
Raises UserError on a missing DSN or a failed connection."""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param(
|
||||
"fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError(
|
||||
"NexaCloud DSN not configured. Set the 'fusion_billing.nexacloud_dsn' "
|
||||
"system parameter to a read-only Postgres connection string.")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001 - surface as a user error
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
conn.set_client_encoding('UTF8')
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
data = {}
|
||||
cur.execute(
|
||||
"SELECT id, email, full_name, company, billing_email, billing_address, "
|
||||
"billing_city, billing_state, billing_postal_code, billing_country, "
|
||||
"tax_id, stripe_customer_id FROM users")
|
||||
data["users"] = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT id, name, price_monthly, price_yearly, cpu_seconds_quota, "
|
||||
"is_active FROM plans")
|
||||
data["plans"] = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT id, user_id, deployment_id, plan_id, status, billing_cycle, "
|
||||
"current_period_start, current_period_end FROM subscriptions")
|
||||
data["subscriptions"] = [dict(r) for r in cur.fetchall()]
|
||||
return data
|
||||
except psycopg2.Error as e:
|
||||
# A query/schema error (e.g. a renamed/missing column) gets the same clean
|
||||
# operator message as a connection failure — not a raw SQL traceback. We
|
||||
# never return a partial `data` (the return is the last statement in `try`).
|
||||
raise UserError(
|
||||
"Failed reading from the NexaCloud database — the source schema may "
|
||||
"have changed. Underlying error:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _read_reconciliation_rows(self):
|
||||
"""Read-only: per (subscription, YYYY-MM period), NexaCloud's CPU usage
|
||||
(cpu_hours*3600 = cpu_seconds) and its actual pre-tax invoice amount. Shaped for
|
||||
fusion.billing.reconciliation._reconcile_rows. Reuses the 2a DSN + guards.
|
||||
(Integration glue — validate the SQL against the live schema, like the importer
|
||||
reader; the reconciliation math itself is unit-tested.)"""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param(
|
||||
"fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
conn.set_client_encoding('UTF8')
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"SELECT subscription_id::text AS sub, "
|
||||
"to_char(period_start, 'YYYY-MM') AS period, "
|
||||
"COALESCE(SUM(cpu_hours), 0) * 3600.0 AS cpu_seconds "
|
||||
"FROM usage_records "
|
||||
"GROUP BY subscription_id, to_char(period_start, 'YYYY-MM')")
|
||||
usage = {(r["sub"], r["period"]): float(r["cpu_seconds"] or 0.0)
|
||||
for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"SELECT i.subscription_id::text AS sub, "
|
||||
"to_char(ii.period_start, 'YYYY-MM') AS period, "
|
||||
"COALESCE(SUM(ii.amount), 0) AS external_amount "
|
||||
"FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id "
|
||||
"GROUP BY i.subscription_id, to_char(ii.period_start, 'YYYY-MM')")
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
key = (r["sub"], r["period"])
|
||||
rows.append({
|
||||
"subscription_external_id": r["sub"], "period": r["period"],
|
||||
"cpu_seconds": usage.get(key, 0.0),
|
||||
"external_amount": float(r["external_amount"] or 0.0)})
|
||||
return rows
|
||||
except psycopg2.Error as e:
|
||||
raise UserError(
|
||||
"Failed reading NexaCloud actuals — the source schema may have changed. "
|
||||
"Underlying error:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ----- import side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _import_rows(self, data, dry_run=False):
|
||||
"""Upsert NexaCloud rows into Odoo. Idempotent. With dry_run=True the writes
|
||||
happen inside a savepoint that is rolled back, so nothing persists (the summary
|
||||
is still returned)."""
|
||||
if not dry_run:
|
||||
return self._do_import(data)
|
||||
result = {}
|
||||
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
result.update(self._do_import(data))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
result["dry_run"] = True
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _do_import(self, data):
|
||||
service = self._fc_service()
|
||||
metric = self._fc_cpu_metric()
|
||||
recurrence_plans = {
|
||||
"monthly": self._fc_recurrence_plan("month"),
|
||||
"yearly": self._fc_recurrence_plan("year"),
|
||||
}
|
||||
summary = {"created": {}, "updated": {}, "skipped": [], "failed": []}
|
||||
partner_by_user = {}
|
||||
plan_ctx_by_id = {}
|
||||
|
||||
for u in data.get("users", []):
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
link, created = self._import_user(service, u)
|
||||
partner_by_user[str(u["id"])] = link.partner_id
|
||||
self._bump(summary, created, "partners")
|
||||
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||
_logger.exception("NexaCloud import: user row %s failed", u.get("id"))
|
||||
summary["failed"].append(
|
||||
{"kind": "user", "id": str(u.get("id")),
|
||||
"error": "%s: %s" % (type(e).__name__, e)})
|
||||
|
||||
for p in data.get("plans", []):
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
ctx, created = self._import_plan(metric, p)
|
||||
plan_ctx_by_id[str(p["id"])] = ctx
|
||||
self._bump(summary, created, "plans")
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.exception("NexaCloud import: plan row %s failed", p.get("id"))
|
||||
summary["failed"].append(
|
||||
{"kind": "plan", "id": str(p.get("id")),
|
||||
"error": "%s: %s" % (type(e).__name__, e)})
|
||||
|
||||
for s in data.get("subscriptions", []):
|
||||
partner = partner_by_user.get(str(s.get("user_id") or ""))
|
||||
ctx = plan_ctx_by_id.get(str(s.get("plan_id") or ""))
|
||||
if not partner or not ctx:
|
||||
summary["skipped"].append({
|
||||
"kind": "subscription", "id": str(s.get("id")),
|
||||
"reason": "unresolved %s" % ("user" if not partner else "plan")})
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
_order, created = self._import_subscription(
|
||||
service, partner, ctx, recurrence_plans, s)
|
||||
self._bump(summary, created, "subscriptions")
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.exception("NexaCloud import: subscription row %s failed", s.get("id"))
|
||||
summary["failed"].append(
|
||||
{"kind": "subscription", "id": str(s.get("id")),
|
||||
"error": "%s: %s" % (type(e).__name__, e)})
|
||||
|
||||
_logger.info("NexaCloud import summary: %s", summary)
|
||||
return summary
|
||||
|
||||
# ----- find-or-create helpers --------------------------------------------
|
||||
@api.model
|
||||
def _fc_service(self):
|
||||
Service = self.env["fusion.billing.service"]
|
||||
svc = Service.search([("code", "=", NEXACLOUD_CODE)], limit=1)
|
||||
return svc or Service.create({"name": "NexaCloud", "code": NEXACLOUD_CODE})
|
||||
|
||||
@api.model
|
||||
def _fc_cpu_metric(self):
|
||||
Metric = self.env["fusion.billing.metric"]
|
||||
m = Metric.search([("code", "=", CPU_METRIC_CODE)], limit=1)
|
||||
return m or Metric.create({
|
||||
"name": "CPU seconds", "code": CPU_METRIC_CODE,
|
||||
"aggregation": "sum", "unit_label": "CPU-seconds"})
|
||||
|
||||
@api.model
|
||||
def _fc_recurrence_plan(self, unit):
|
||||
Plan = self.env["sale.subscription.plan"]
|
||||
plan = Plan.search(
|
||||
[("billing_period_value", "=", 1), ("billing_period_unit", "=", unit)], limit=1)
|
||||
if plan:
|
||||
return plan
|
||||
label = "Monthly" if unit == "month" else "Yearly"
|
||||
return Plan.create(
|
||||
{"name": label, "billing_period_value": 1, "billing_period_unit": unit})
|
||||
|
||||
@api.model
|
||||
def _fc_resolve_country(self, value):
|
||||
Country = self.env["res.country"]
|
||||
if not value:
|
||||
return Country.browse()
|
||||
v = value.strip()
|
||||
return Country.search(
|
||||
["|", ("code", "=ilike", v), ("name", "=ilike", v)], limit=1)
|
||||
|
||||
@staticmethod
|
||||
def _bump(summary, created, key):
|
||||
bucket = "created" if created else "updated"
|
||||
summary[bucket][key] = summary[bucket].get(key, 0) + 1
|
||||
|
||||
# ----- per-entity import --------------------------------------------------
|
||||
@api.model
|
||||
def _import_user(self, service, urow):
|
||||
Link = self.env["fusion.billing.account.link"]
|
||||
ext = str(urow["id"])
|
||||
email = (urow.get("billing_email") or urow.get("email") or "").strip().lower() or None
|
||||
name = urow.get("full_name") or urow.get("company") or email or ext
|
||||
existed = bool(Link.search(
|
||||
[("service_id", "=", service.id), ("external_id", "=", ext)], limit=1))
|
||||
link = Link._resolve_or_create_partner(service, ext, name=name, email=email)
|
||||
vals = {}
|
||||
if urow.get("billing_address"):
|
||||
vals["street"] = urow["billing_address"]
|
||||
if urow.get("billing_city"):
|
||||
vals["city"] = urow["billing_city"]
|
||||
if urow.get("billing_postal_code"):
|
||||
vals["zip"] = urow["billing_postal_code"]
|
||||
if urow.get("tax_id"):
|
||||
vals["vat"] = urow["tax_id"]
|
||||
if urow.get("stripe_customer_id"):
|
||||
vals["x_fc_stripe_customer_id"] = urow["stripe_customer_id"]
|
||||
country = self._fc_resolve_country(urow.get("billing_country"))
|
||||
if country:
|
||||
vals["country_id"] = country.id
|
||||
if vals:
|
||||
link.partner_id.write(vals)
|
||||
return link, not existed
|
||||
|
||||
@api.model
|
||||
def _import_plan(self, metric, prow):
|
||||
Product = self.env["product.product"]
|
||||
Charge = self.env["fusion.billing.charge"]
|
||||
plan_code = str(prow["id"])
|
||||
name = prow.get("name") or plan_code
|
||||
# Preserve NULL vs 0.0: a missing price must NOT silently become a $0 line.
|
||||
# The subscription import raises on a missing price for its cycle (-> failed[]).
|
||||
raw_monthly = prow.get("price_monthly")
|
||||
raw_yearly = prow.get("price_yearly")
|
||||
price_monthly = float(raw_monthly) if raw_monthly is not None else None
|
||||
price_yearly = float(raw_yearly) if raw_yearly is not None else None
|
||||
created = False
|
||||
|
||||
sub_code = "NC-PLAN-%s" % plan_code
|
||||
sub_product = Product.search([("default_code", "=", sub_code)], limit=1)
|
||||
if not sub_product:
|
||||
sub_product = Product.create({
|
||||
"name": "NexaCloud %s" % name, "default_code": sub_code,
|
||||
"type": "service", "recurring_invoice": True,
|
||||
"list_price": price_monthly or 0.0})
|
||||
created = True
|
||||
|
||||
ov_code = "NC-CPU-OVG-%s" % plan_code
|
||||
ov_product = Product.search([("default_code", "=", ov_code)], limit=1)
|
||||
if not ov_product:
|
||||
ov_product = Product.create({
|
||||
"name": "NexaCloud CPU overage (%s)" % name, "default_code": ov_code,
|
||||
"type": "service", "list_price": 0.0})
|
||||
|
||||
charge_vals = {
|
||||
"name": "NexaCloud CPU overage — %s" % name,
|
||||
"plan_code": plan_code, "metric_id": metric.id, "product_id": ov_product.id,
|
||||
"included_quota": float(prow.get("cpu_seconds_quota") or 0.0),
|
||||
"price_per_unit": CPU_RATE_PER_CORE_HOUR,
|
||||
"unit_batch": CPU_SECONDS_PER_CORE_HOUR,
|
||||
"charge_model": "standard",
|
||||
# Shadow safety guarantee #3: plan_id MUST stay NULL so the rating cron
|
||||
# never auto-mutates order lines. Set it explicitly (not just omitted) so a
|
||||
# re-run re-asserts NULL even if someone set it on the charge between runs.
|
||||
"plan_id": False,
|
||||
}
|
||||
charge = Charge.search(
|
||||
[("plan_code", "=", plan_code), ("metric_id", "=", metric.id)], limit=1)
|
||||
if charge:
|
||||
charge.write(charge_vals)
|
||||
else:
|
||||
charge = Charge.create(charge_vals)
|
||||
created = True
|
||||
return {
|
||||
"sub_product": sub_product, "overage_product": ov_product, "charge": charge,
|
||||
"price_monthly": price_monthly, "price_yearly": price_yearly,
|
||||
}, created
|
||||
|
||||
@api.model
|
||||
def _import_subscription(self, service, partner, plan_ctx, recurrence_plans, srow):
|
||||
SaleOrder = self.env["sale.order"]
|
||||
SaleOrderLine = self.env["sale.order.line"]
|
||||
sub_ext = str(srow["id"])
|
||||
cycle = (srow.get("billing_cycle") or "").strip().lower()
|
||||
if cycle not in ("monthly", "yearly"):
|
||||
raise UserError(
|
||||
"Subscription %s has an unrecognized billing_cycle %r — cannot pick a "
|
||||
"plan/price." % (sub_ext, srow.get("billing_cycle")))
|
||||
rec_plan = recurrence_plans["yearly"] if cycle == "yearly" else recurrence_plans["monthly"]
|
||||
price = plan_ctx["price_yearly"] if cycle == "yearly" else plan_ctx["price_monthly"]
|
||||
if price is None:
|
||||
raise UserError(
|
||||
"Subscription %s is billed %s but its plan has no %s price." % (
|
||||
sub_ext, cycle, cycle))
|
||||
product = plan_ctx["sub_product"]
|
||||
# x_fc_* are always (re-)written; identity fields (partner_id/plan_id/order_line)
|
||||
# are set ONLY at creation, so a re-run never rewrites immutable fields on an
|
||||
# order that may since have been confirmed.
|
||||
shadow_vals = {
|
||||
"x_fc_nexacloud_deployment_id": str(srow.get("deployment_id") or ""),
|
||||
"x_fc_nexacloud_plan_id": str(srow.get("plan_id") or ""),
|
||||
"x_fc_billing_service_id": service.id, "x_fc_shadow": True,
|
||||
}
|
||||
existing = SaleOrder.search(
|
||||
[("x_fc_nexacloud_subscription_id", "=", sub_ext)], limit=1)
|
||||
if existing:
|
||||
existing.write(shadow_vals)
|
||||
line = existing.order_line.filtered(lambda l: l.product_id == product)
|
||||
line_vals = {"product_uom_qty": 1, "price_unit": price}
|
||||
if line:
|
||||
line.write(line_vals)
|
||||
else:
|
||||
SaleOrderLine.create(
|
||||
dict(order_id=existing.id, product_id=product.id, **line_vals))
|
||||
order = existing
|
||||
created = False
|
||||
else:
|
||||
order = SaleOrder.create({
|
||||
"partner_id": partner.id, "plan_id": rec_plan.id,
|
||||
"x_fc_nexacloud_subscription_id": sub_ext,
|
||||
"order_line": [(0, 0, {
|
||||
"product_id": product.id, "product_uom_qty": 1, "price_unit": price})],
|
||||
**shadow_vals,
|
||||
})
|
||||
created = True
|
||||
# guarantee the explicit price stuck (a pricelist compute may have overwritten it)
|
||||
line = order.order_line.filtered(lambda l: l.product_id == product)
|
||||
if line and line.price_unit != price:
|
||||
line.price_unit = price
|
||||
return order, created
|
||||
498
fusion_centralize_billing/wizards/invoice_ledger.py
Normal file
498
fusion_centralize_billing/wizards/invoice_ledger.py
Normal file
@@ -0,0 +1,498 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""NexaCloud → Odoo invoice ledger ingester.
|
||||
|
||||
Reads NexaCloud's real (Stripe-billed) invoices and creates native Odoo
|
||||
``account.move`` customer invoices — posted, with the Stripe payments reconciled and
|
||||
HST modelled — so Odoo is the accounting system of record. Revenue is split by service
|
||||
family into distinct income accounts. NexaCloud/Stripe keep doing the billing; Odoo
|
||||
ingests its output. See docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
_name = "fusion.billing.invoice.ledger.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Invoice Ledger Ingester"
|
||||
|
||||
dry_run = fields.Boolean(default=True)
|
||||
auto_post = fields.Boolean(
|
||||
default=False, help="Post invoices immediately (else leave draft for review).")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
|
||||
# description keyword -> service family (checked in order; hosting before managed)
|
||||
_FAMILY_KEYWORDS = [
|
||||
("hosting", ["odoo erp hosting", "wordpress website hosting"]),
|
||||
("managed", ["managed"]),
|
||||
("addons", ["daily backup", "whatsapp", "forms builder", "white label"]),
|
||||
]
|
||||
|
||||
def action_run(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_invoices()
|
||||
if self.dry_run:
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
|
||||
res = {}
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
res.update(self._ingest_invoices(data, post=False))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
res["dry_run"] = True
|
||||
else:
|
||||
res = self._ingest_invoices(data, post=self.auto_post)
|
||||
self.result_summary = json.dumps(res, indent=2, default=str)
|
||||
if res.get("failed"):
|
||||
_logger.error("Ledger ingest: %s failed: %s", len(res["failed"]), res["failed"])
|
||||
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_invoices(self, since=None):
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
conn.set_client_encoding('UTF8') # invoice descriptions contain non-ASCII (e.g. "×")
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
where = "WHERE i.created_at >= %(since)s" if since else ""
|
||||
cur.execute(
|
||||
"SELECT i.id, i.stripe_invoice_id, i.invoice_number, "
|
||||
"i.user_id AS user_external_id, u.full_name AS partner_name, "
|
||||
"u.company AS partner_company, "
|
||||
"COALESCE(u.billing_email, u.email) AS partner_email, "
|
||||
"i.created_at AS invoice_date, i.currency, i.status, i.subtotal, i.tax, "
|
||||
"i.amount_paid, i.paid_at "
|
||||
"FROM invoices i JOIN users u ON u.id = i.user_id " + where +
|
||||
" ORDER BY i.created_at", {"since": since})
|
||||
invoices = {str(r["id"]): dict(r, items=[]) for r in cur.fetchall()}
|
||||
if invoices:
|
||||
cur.execute(
|
||||
"SELECT ii.invoice_id, ii.description, ii.quantity, ii.unit_price, ii.amount "
|
||||
"FROM invoice_items ii WHERE ii.invoice_id::text = ANY(%(ids)s)",
|
||||
{"ids": list(invoices.keys())})
|
||||
for r in cur.fetchall():
|
||||
inv = invoices.get(str(r["invoice_id"]))
|
||||
if inv:
|
||||
inv["items"].append({
|
||||
"description": r["description"], "quantity": r["quantity"],
|
||||
"unit_price": r["unit_price"], "amount": r["amount"]})
|
||||
out = []
|
||||
for inv in invoices.values():
|
||||
inv["id"] = str(inv["id"])
|
||||
inv["user_external_id"] = str(inv["user_external_id"])
|
||||
out.append(inv)
|
||||
return out
|
||||
except psycopg2.Error as e:
|
||||
raise UserError(
|
||||
"Failed reading NexaCloud invoices — the source schema may have changed. "
|
||||
"Underlying error:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ----- ingest side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _ingest_invoices(self, data, post=False, verified=None):
|
||||
"""Upsert one account.move per NexaCloud invoice.
|
||||
|
||||
``verified`` (optional) maps nc_id -> the dict returned by ``_fc_verify``
|
||||
(date + paid status taken from the SOURCE billing system). When present for
|
||||
an invoice, the source invoice_date and paid status win over NexaCloud's own
|
||||
(unreliable) fields. Without it, the raw NexaCloud fields are used (manual
|
||||
backfill / dry-run path)."""
|
||||
verified = verified or {}
|
||||
Move = self.env["account.move"]
|
||||
cad = self.env.ref("base.CAD", raise_if_not_found=False) or self.env.company.currency_id
|
||||
summary = {"created": 0, "updated": 0, "posted": 0, "reconciled": 0,
|
||||
"skipped": [], "failed": [], "by_family": {}}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
v = verified.get(nc_id)
|
||||
inv_date = (v or {}).get("invoice_date") or inv.get("invoice_date")
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
existing = Move.search(
|
||||
[("x_fc_nexacloud_invoice_id", "=", nc_id)], limit=1)
|
||||
if existing and existing.state != "draft":
|
||||
summary["skipped"].append({"id": nc_id, "reason": "already posted"})
|
||||
continue
|
||||
partner = self._fc_partner_for(inv)
|
||||
if existing:
|
||||
existing.invoice_line_ids.unlink() # draft: replace lines
|
||||
if existing.partner_id != partner:
|
||||
existing.partner_id = partner.id
|
||||
if inv_date and str(existing.invoice_date) != str(inv_date):
|
||||
existing.invoice_date = inv_date
|
||||
move = existing
|
||||
else:
|
||||
move = Move.create({
|
||||
"move_type": "out_invoice",
|
||||
"partner_id": partner.id,
|
||||
"invoice_date": inv_date,
|
||||
"ref": inv.get("invoice_number"),
|
||||
"currency_id": cad.id,
|
||||
"x_fc_nexacloud_invoice_id": nc_id,
|
||||
"x_fc_stripe_invoice_id": inv.get("stripe_invoice_id"),
|
||||
})
|
||||
tax = self._fc_tax_for(inv.get("subtotal"), inv.get("tax"))
|
||||
line_vals = []
|
||||
for it in inv.get("items", []):
|
||||
fam = self._fc_family_for(it.get("description"))
|
||||
summary["by_family"][fam] = round(
|
||||
summary["by_family"].get(fam, 0.0) + float(it.get("amount") or 0.0), 2)
|
||||
line_vals.append((0, 0, {
|
||||
"name": it.get("description") or "NexaCloud",
|
||||
"quantity": float(it.get("quantity") or 1.0),
|
||||
"price_unit": float(it.get("unit_price") or it.get("amount") or 0.0),
|
||||
"account_id": self._fc_income_account(fam).id,
|
||||
"tax_ids": [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||
}))
|
||||
# Many NexaCloud base-plan invoices store the charge in `subtotal` with
|
||||
# NO invoice_items. Add a balancing line for any gap so the Odoo invoice
|
||||
# total matches what Stripe actually billed (captures un-itemized revenue
|
||||
# and absorbs proration credits where items exceed subtotal).
|
||||
items_total = round(sum(float(it.get("amount") or 0.0)
|
||||
for it in inv.get("items", [])), 2)
|
||||
gap = round(float(inv.get("subtotal") or 0.0) - items_total, 2)
|
||||
if abs(gap) > 0.01:
|
||||
summary["by_family"]["base"] = round(
|
||||
summary["by_family"].get("base", 0.0) + gap, 2)
|
||||
line_vals.append((0, 0, {
|
||||
"name": "NexaCloud base/unitemized charge",
|
||||
"quantity": 1.0, "price_unit": gap,
|
||||
"account_id": self._fc_income_account("base").id,
|
||||
"tax_ids": [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||
}))
|
||||
if not line_vals:
|
||||
# zero-amount invoice (no items, $0 subtotal) — nothing to record;
|
||||
# drop the empty move (whether just-created or a pre-existing draft).
|
||||
move.unlink()
|
||||
summary["skipped"].append({"id": nc_id, "reason": "zero-amount invoice"})
|
||||
continue
|
||||
move.write({"invoice_line_ids": line_vals})
|
||||
summary["updated" if existing else "created"] += 1
|
||||
if post:
|
||||
if v and inv_date:
|
||||
# accounting date = source invoice date (else Odoo stamps today)
|
||||
move.write({"date": inv_date})
|
||||
move.action_post()
|
||||
summary["posted"] += 1
|
||||
if self._fc_reconcile_payment(move, inv, verified=v):
|
||||
summary["reconciled"] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Ledger ingest: invoice %s failed", nc_id)
|
||||
summary["failed"].append({"id": nc_id, "error": "%s: %s" % (type(e).__name__, e)})
|
||||
return summary
|
||||
|
||||
@api.model
|
||||
def _post_ingested(self):
|
||||
moves = self.env["account.move"].search([
|
||||
("x_fc_nexacloud_invoice_id", "!=", False),
|
||||
("state", "=", "draft"), ("move_type", "=", "out_invoice")])
|
||||
posted = 0
|
||||
for mv in moves:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
mv.action_post()
|
||||
posted += 1
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("Ledger post: move %s failed", mv.id)
|
||||
return posted
|
||||
|
||||
@api.model
|
||||
def _post_and_reconcile_paid(self, data):
|
||||
"""Post + reconcile ONLY the invoices NexaCloud marks paid, dating the ledger entry
|
||||
to the ORIGINAL invoice date and the payment to the actual paid_at. Leaves unpaid
|
||||
invoices as draft. Per-invoice isolated."""
|
||||
Move = self.env["account.move"]
|
||||
summary = {"posted": 0, "reconciled": 0, "skipped_unpaid": 0,
|
||||
"skipped_missing": 0, "failed": []}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
paid = float(inv.get("amount_paid") or 0.0)
|
||||
if inv.get("status") != "paid" and paid <= 0:
|
||||
summary["skipped_unpaid"] += 1
|
||||
continue
|
||||
mv = Move.search([("x_fc_nexacloud_invoice_id", "=", nc_id),
|
||||
("move_type", "=", "out_invoice")], limit=1)
|
||||
if not mv or not mv.invoice_line_ids:
|
||||
summary["skipped_missing"] += 1
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
if mv.state == "draft":
|
||||
inv_date = inv.get("invoice_date")
|
||||
# keep the original invoice + accounting date (not today)
|
||||
mv.write({"invoice_date": inv_date, "date": inv_date})
|
||||
mv.action_post()
|
||||
summary["posted"] += 1
|
||||
if mv.payment_state not in ("paid", "in_payment", "reversed"):
|
||||
if self._fc_reconcile_payment(mv, inv):
|
||||
summary["reconciled"] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Post+pay: invoice %s failed", nc_id)
|
||||
summary["failed"].append({"id": nc_id, "error": "%s: %s" % (type(e).__name__, e)})
|
||||
return summary
|
||||
|
||||
def _cron_sync_verified(self):
|
||||
"""Daily go-forward sync (the only safe automatic path).
|
||||
|
||||
Reads NexaCloud invoices, then for each one not already in the ledger verifies
|
||||
it against its SOURCE billing system (Stripe / Lago) and ingests + posts only
|
||||
verified data: the real invoice date, and a reconciled payment ONLY when the
|
||||
source confirms it is paid. Voids are skipped; anything that cannot be verified
|
||||
is logged and left for the next run (never posted on NexaCloud's own unreliable
|
||||
created_at / status / paid_at). Idempotent — already-posted invoices are left
|
||||
untouched."""
|
||||
Move = self.env["account.move"]
|
||||
data = self._read_nexacloud_invoices()
|
||||
to_ingest, verified = [], {}
|
||||
summary = {"verified": 0, "skipped_void": 0, "skipped_draft": 0,
|
||||
"already_posted": 0, "unverified": []}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
existing = Move.search(
|
||||
[("x_fc_nexacloud_invoice_id", "=", nc_id),
|
||||
("move_type", "=", "out_invoice")], limit=1)
|
||||
if existing and existing.state == "posted":
|
||||
summary["already_posted"] += 1
|
||||
continue
|
||||
v = self._fc_verify(inv)
|
||||
if v is None:
|
||||
summary["unverified"].append(nc_id)
|
||||
continue
|
||||
if v.get("void"):
|
||||
summary["skipped_void"] += 1
|
||||
continue
|
||||
if v.get("draft"):
|
||||
# not finalized at the source yet — will be picked up once it finalizes
|
||||
summary["skipped_draft"] += 1
|
||||
continue
|
||||
verified[nc_id] = v
|
||||
to_ingest.append(inv)
|
||||
summary["verified"] += 1
|
||||
res = self._ingest_invoices(to_ingest, post=True, verified=verified)
|
||||
for k in ("created", "updated", "posted", "reconciled", "failed"):
|
||||
summary[k] = res.get(k)
|
||||
if summary["unverified"]:
|
||||
_logger.warning("Ledger sync: %s invoice(s) unverified, will retry next run: %s",
|
||||
len(summary["unverified"]), summary["unverified"])
|
||||
_logger.info("Ledger sync summary: %s", summary)
|
||||
return summary
|
||||
|
||||
# ----- source-of-truth verification (Stripe / Lago) ----------------------
|
||||
@api.model
|
||||
def _fc_ts_to_date(self, ts):
|
||||
"""Unix timestamp (Stripe) -> 'YYYY-MM-DD' (UTC). None/blank-safe (0 = epoch)."""
|
||||
if ts is None or ts == "":
|
||||
return None
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc).date().isoformat()
|
||||
|
||||
@api.model
|
||||
def _fc_verify(self, inv):
|
||||
"""Route an invoice to its source billing system for verification.
|
||||
Returns a dict {invoice_date, void, paid, paid_at, amount_paid} or None if the
|
||||
source can't be determined / reached (caller then leaves it for the next run)."""
|
||||
sid = (inv.get("stripe_invoice_id") or "").strip()
|
||||
if sid.startswith("in_"):
|
||||
return self._fc_verify_stripe(sid)
|
||||
if sid.startswith("lago:"):
|
||||
return self._fc_verify_lago(sid[len("lago:"):])
|
||||
return None
|
||||
|
||||
@api.model
|
||||
def _fc_verify_stripe(self, stripe_invoice_id):
|
||||
key = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.stripe_api_key")
|
||||
if not key:
|
||||
return None
|
||||
import requests
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://api.stripe.com/v1/invoices/%s" % stripe_invoice_id,
|
||||
auth=(key, ""), timeout=20)
|
||||
except Exception: # noqa: BLE001 - network failure: treat as unverifiable
|
||||
_logger.exception("Stripe verify failed for %s", stripe_invoice_id)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning("Stripe verify %s -> HTTP %s", stripe_invoice_id, resp.status_code)
|
||||
return None
|
||||
d = resp.json()
|
||||
status = d.get("status")
|
||||
paid_ts = (d.get("status_transitions") or {}).get("paid_at")
|
||||
return {
|
||||
"invoice_date": self._fc_ts_to_date(d.get("created")),
|
||||
"void": status == "void",
|
||||
"draft": status == "draft", # not finalized in Stripe -> not a real invoice yet
|
||||
"paid": status == "paid" or float(d.get("amount_paid") or 0) > 0,
|
||||
"paid_at": self._fc_ts_to_date(paid_ts),
|
||||
"amount_paid": float(d.get("amount_paid") or 0) / 100.0,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _fc_verify_lago(self, lago_invoice_id):
|
||||
cp = self.env["ir.config_parameter"].sudo()
|
||||
url = cp.get_param("fusion_billing.lago_api_url")
|
||||
key = cp.get_param("fusion_billing.lago_api_key")
|
||||
if not url or not key:
|
||||
return None
|
||||
import requests
|
||||
try:
|
||||
resp = requests.get(
|
||||
"%s/v1/invoices/%s" % (url.rstrip("/"), lago_invoice_id),
|
||||
headers={"Authorization": "Bearer %s" % key}, timeout=20)
|
||||
except Exception: # noqa: BLE001 - network failure: treat as unverifiable
|
||||
_logger.exception("Lago verify failed for %s", lago_invoice_id)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning("Lago verify %s -> HTTP %s", lago_invoice_id, resp.status_code)
|
||||
return None
|
||||
d = (resp.json() or {}).get("invoice") or {}
|
||||
issuing = d.get("issuing_date") # already 'YYYY-MM-DD'
|
||||
return {
|
||||
"invoice_date": issuing,
|
||||
"void": d.get("status") == "voided",
|
||||
"draft": d.get("status") == "draft", # not finalized in Lago yet
|
||||
"paid": d.get("payment_status") == "succeeded",
|
||||
"paid_at": issuing, # Lago exposes no clean paid-at; issuing date is the proxy
|
||||
"amount_paid": float(d.get("total_paid_amount_cents") or 0) / 100.0,
|
||||
}
|
||||
|
||||
# ----- helpers ------------------------------------------------------------
|
||||
@api.model
|
||||
def _fc_family_for(self, description):
|
||||
d = (description or "").lower()
|
||||
m = re.match(r"remaining time on (.+?)(?: after| from |\s*\()", d)
|
||||
if m:
|
||||
d = m.group(1) # classify proration by the prorated item
|
||||
for fam, kws in self._FAMILY_KEYWORDS:
|
||||
if any(k in d for k in kws):
|
||||
return fam
|
||||
return "other"
|
||||
|
||||
@api.model
|
||||
def _fc_income_account(self, family):
|
||||
Account = self.env["account.account"]
|
||||
# Odoo 19 account codes allow only alphanumerics + dots (no hyphen).
|
||||
code = "NCR." + family.upper()
|
||||
acc = Account.search([("code", "=", code)], limit=1)
|
||||
if not acc:
|
||||
acc = Account.create({
|
||||
"code": code, "name": "NexaCloud %s Revenue" % family.title(),
|
||||
"account_type": "income"})
|
||||
return acc
|
||||
|
||||
@api.model
|
||||
def _fc_tax_for(self, subtotal, tax_amount):
|
||||
"""Map a NexaCloud invoice's (subtotal, tax) to the Odoo sale tax whose computed
|
||||
tax equals it. Picks by effective percent; falls back to a 0% sale tax."""
|
||||
Tax = self.env["account.tax"]
|
||||
sub = float(subtotal or 0.0)
|
||||
amt = float(tax_amount or 0.0)
|
||||
if sub <= 0 or amt <= 0:
|
||||
return Tax.search([("type_tax_use", "=", "sale"), ("amount", "=", 0.0)], limit=1)
|
||||
rate = round(100.0 * amt / sub)
|
||||
tax = Tax.search([("type_tax_use", "=", "sale"), ("amount_type", "=", "percent"),
|
||||
("amount", "=", float(rate))], limit=1)
|
||||
if not tax:
|
||||
tax = Tax.search([("type_tax_use", "=", "sale"), ("name", "ilike", "%s" % rate)], limit=1)
|
||||
return tax
|
||||
|
||||
@api.model
|
||||
def _fc_partner_for(self, inv):
|
||||
"""Resolve the unified partner via the nexacloud account.link (by user id);
|
||||
create partner+link if missing (covers NULL-subscription invoices)."""
|
||||
service = self.env["fusion.billing.service"].search([("code", "=", "nexacloud")], limit=1)
|
||||
if not service:
|
||||
service = self.env["fusion.billing.service"].create(
|
||||
{"name": "NexaCloud", "code": "nexacloud"})
|
||||
company = (inv.get("partner_company") or "").strip()
|
||||
name = company or inv.get("partner_name") or str(inv.get("user_external_id"))
|
||||
link = self.env["fusion.billing.account.link"]._resolve_or_create_partner(
|
||||
service, str(inv.get("user_external_id")), name=name, email=inv.get("partner_email"))
|
||||
partner = link.partner_id
|
||||
# Name the partner for the BUSINESS (company), not the NexaCloud user's full_name —
|
||||
# one person (e.g. "Gurpreet Singh") can manage several distinct customer businesses.
|
||||
# Rewrite an existing partner so earlier full_name-based names get corrected.
|
||||
if company and (partner.name != company or not partner.is_company):
|
||||
partner.write({"name": company, "is_company": True})
|
||||
return partner
|
||||
|
||||
@api.model
|
||||
def _fc_stripe_journal(self):
|
||||
Journal = self.env["account.journal"]
|
||||
j = Journal.search([("code", "=", "NCSTR")], limit=1)
|
||||
if not j:
|
||||
j = Journal.create({"name": "NexaCloud Stripe", "code": "NCSTR", "type": "bank"})
|
||||
return j
|
||||
|
||||
@api.model
|
||||
def _fc_reconcile_payment(self, move, inv, verified=None):
|
||||
"""Register + reconcile a Stripe payment against a posted invoice.
|
||||
|
||||
When ``verified`` is given, paid status / amount / date come from the SOURCE
|
||||
system (Stripe/Lago); a payment is created ONLY if the source confirms paid.
|
||||
Without it, NexaCloud's own (unreliable) fields are used (manual/backfill path)."""
|
||||
if move.state != "posted":
|
||||
return False
|
||||
if verified is not None:
|
||||
if not verified.get("paid"):
|
||||
return False
|
||||
amount = verified.get("amount_paid") or move.amount_total
|
||||
payment_date = verified.get("paid_at") or move.invoice_date or fields.Date.today()
|
||||
else:
|
||||
paid = float(inv.get("amount_paid") or 0.0)
|
||||
if inv.get("status") != "paid" and paid <= 0:
|
||||
return False
|
||||
amount = paid or move.amount_total
|
||||
payment_date = inv.get("paid_at") or move.invoice_date or fields.Date.today()
|
||||
reg = self.env["account.payment.register"].with_context(
|
||||
active_model="account.move", active_ids=move.ids).create({
|
||||
"journal_id": self._fc_stripe_journal().id,
|
||||
"payment_date": payment_date,
|
||||
"amount": amount,
|
||||
})
|
||||
reg._create_payments()
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _fc_prune_metered_shadow(self):
|
||||
"""Delete the superseded metered shadow data (shadow sale.orders, NC-* products,
|
||||
NexaCloud charges, reconciliation rows)."""
|
||||
counts = {}
|
||||
subs = self.env["sale.order"].search([("x_fc_shadow", "=", True)])
|
||||
counts["subscriptions"] = len(subs)
|
||||
subs.unlink()
|
||||
ch = self.env["fusion.billing.charge"].search([]) # before products (charge -> product)
|
||||
counts["charges"] = len(ch)
|
||||
ch.unlink()
|
||||
rec = self.env["fusion.billing.reconciliation"].search([])
|
||||
counts["reconciliations"] = len(rec)
|
||||
rec.unlink()
|
||||
prods = self.env["product.product"].search([("default_code", "=like", "NC-%")])
|
||||
counts["products"] = len(prods)
|
||||
try:
|
||||
prods.unlink()
|
||||
except Exception: # noqa: BLE001 - undeletable (referenced) products: archive instead
|
||||
prods.write({"active": False})
|
||||
counts["products_archived"] = len(prods)
|
||||
return counts
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user