Compare commits
525 Commits
bd2c037a97
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
27e12dd544 | ||
|
|
5f03080374 | ||
|
|
efaf16dffb | ||
|
|
e4000374ca | ||
|
|
fee4219703 | ||
|
|
6ca9a58a8c | ||
|
|
d86c120969 | ||
|
|
85609f99cd | ||
|
|
29821bd541 | ||
|
|
1fdafd34d1 | ||
|
|
9584953467 | ||
|
|
52097ca59b | ||
|
|
1d6184dd2f | ||
|
|
88a473e7eb | ||
|
|
08ababc2c7 | ||
|
|
59ad77839a | ||
|
|
a594431eb6 | ||
|
|
58d02598da | ||
|
|
395bd4949e | ||
|
|
a6546ac858 | ||
|
|
233e5e6e72 | ||
|
|
b06a5b2d12 | ||
|
|
3ef67c6beb | ||
|
|
4a304e02f3 | ||
|
|
0d08d2d135 | ||
|
|
f9cb1b11ce | ||
|
|
1122f84007 | ||
|
|
2cdb2e3d0b | ||
|
|
f00dda2abd | ||
|
|
3b7b2477cf | ||
|
|
e762ee4b68 | ||
|
|
5d086c7f27 | ||
|
|
3eba80bb31 | ||
|
|
2a0d1862df | ||
|
|
7f70785b79 | ||
|
|
9dcd00d9b2 | ||
|
|
5a28c7e90f | ||
|
|
3c2efae951 | ||
|
|
c06d3d442a | ||
|
|
c76eb94724 | ||
|
|
06dc6a62b9 | ||
|
|
5463efcfc2 | ||
|
|
3fdbeed813 | ||
|
|
a18ef6c405 | ||
|
|
eae6a471e8 | ||
|
|
a61bd05a5c | ||
|
|
8109b3ec76 | ||
|
|
9d78bc4317 | ||
|
|
5c3c979f77 | ||
|
|
b52fe01d07 | ||
|
|
81da9bf71c | ||
|
|
1d04ac8cb7 | ||
|
|
27465cfeac | ||
|
|
fb5da1e3cd | ||
|
|
f661724c72 | ||
|
|
d127e19b45 | ||
|
|
d022e529d9 | ||
|
|
894eea7ce2 | ||
|
|
b395600a1c | ||
|
|
612394c987 | ||
|
|
d6d6249857 | ||
|
|
3440e4b7c6 | ||
|
|
5295aefd8f | ||
|
|
4025789ba0 | ||
|
|
5b6e53c863 | ||
|
|
b70fff01e1 | ||
|
|
07f9bcf79b | ||
|
|
1420a5c445 | ||
|
|
2bfb1015ea | ||
|
|
ace82de88c | ||
|
|
1b1e9fdb9e | ||
|
|
95e0e2d9bd | ||
|
|
cdc9f864b2 | ||
|
|
a00c891277 | ||
|
|
f45883233c | ||
|
|
d5e79cdc10 | ||
|
|
1a8a96d94e | ||
|
|
53fd6114e7 | ||
|
|
1314f4581d | ||
|
|
b2f483d67c | ||
|
|
48dd7718e2 | ||
|
|
ecca8e357f | ||
|
|
f41426c5b9 | ||
|
|
ebbadb3002 | ||
|
|
4f1b7c2df6 | ||
|
|
b4b59cc3c9 | ||
|
|
638b223d3b | ||
|
|
f463600585 | ||
|
|
bf4464ba37 | ||
|
|
65c4d8801c | ||
|
|
ef0c096e48 | ||
|
|
c506b53dec | ||
|
|
d93b500901 | ||
|
|
5c8768c556 | ||
|
|
3a15164605 | ||
|
|
194850e3cf | ||
|
|
f1cea2fb35 | ||
|
|
d15d9e4303 | ||
|
|
7f8a80fecb | ||
|
|
38a79a4b04 | ||
|
|
5a5e310a83 | ||
|
|
cb56a38680 | ||
|
|
750c7068e2 | ||
|
|
44e5b391f9 | ||
|
|
8ef57a4bb1 | ||
|
|
c86f1bbbe5 | ||
|
|
afe19f2105 | ||
|
|
73ee48e7c9 | ||
|
|
7727745b73 | ||
|
|
ad553b1082 | ||
|
|
429084e0bf | ||
|
|
79fbfec61f | ||
|
|
d4fb1eebbf | ||
|
|
2e4d957a47 | ||
|
|
e5928b965f | ||
|
|
0600b87a29 | ||
|
|
3d1b6e7ec5 | ||
|
|
d7bee9e854 | ||
|
|
6343386488 | ||
|
|
afe0fd1206 | ||
|
|
ac1db177e1 | ||
|
|
7c31269691 | ||
|
|
2142a66bc0 | ||
|
|
821e768b7e | ||
|
|
2645db40a2 | ||
|
|
60eb2adef3 | ||
|
|
e3bec557b6 | ||
|
|
6a1640ff6d | ||
|
|
10f5d44965 | ||
|
|
a4d615d74e | ||
|
|
f5ac8d07d7 | ||
|
|
50539741ce | ||
|
|
7a891c5aaa | ||
|
|
3bef640979 | ||
|
|
1f20eb3d2a | ||
|
|
df53ab956f | ||
|
|
5ff271a7b1 | ||
|
|
8831176ec4 | ||
|
|
d77cc252bb | ||
|
|
091f98e1f9 | ||
|
|
25f568f225 | ||
|
|
4e54ecc32f | ||
|
|
ab7ff3eea5 | ||
|
|
f8fc6be370 | ||
|
|
b27f68b8d5 | ||
|
|
d9bdbd8e18 | ||
|
|
281941c7ee | ||
|
|
7eb9dd02a7 | ||
|
|
3a520564a7 | ||
|
|
6f2bea9773 | ||
|
|
e50631c46a | ||
|
|
76c68e0311 | ||
|
|
04862e8a28 | ||
|
|
cdc47554ed | ||
|
|
77b84ac11b | ||
|
|
b92a396934 | ||
|
|
8225061dfa | ||
|
|
fe4cceeffa | ||
|
|
a99f9aa5ee | ||
|
|
ca60500c07 | ||
|
|
d17cadabf0 | ||
|
|
df74d702af | ||
|
|
ada22a583f | ||
|
|
009562913c | ||
|
|
0593b70354 | ||
|
|
26fe41e7d4 | ||
|
|
2802fcf738 | ||
|
|
153b980e2b | ||
|
|
6cad69cb86 | ||
|
|
27badff570 | ||
|
|
a63fbe1558 | ||
|
|
49013c64fb | ||
|
|
ba6f39375a | ||
|
|
cbed74e5eb | ||
|
|
2730c455f5 | ||
|
|
669ba0fd8a | ||
|
|
8e172132e7 | ||
|
|
d3c5c25865 | ||
|
|
f8586611c9 | ||
|
|
28220f0732 | ||
|
|
edcc325483 | ||
|
|
37f1f7e8a3 | ||
|
|
0f10c490cd | ||
|
|
e166fae57b | ||
|
|
488243cd75 | ||
|
|
6cf826268b | ||
|
|
c8deef1482 | ||
|
|
55ac05667c | ||
|
|
4da123c2d3 | ||
|
|
8c6718e352 | ||
|
|
9d58f5f61e | ||
|
|
06df9745a0 | ||
|
|
3aa11eaffc | ||
|
|
c2590a99ff | ||
|
|
215e393bdb | ||
|
|
1780b383b9 | ||
|
|
a6ff3054bc | ||
|
|
b3a86cd4b9 | ||
|
|
23ac3284cb | ||
|
|
83c2b42aad | ||
|
|
22e217a16c | ||
|
|
3310b12754 | ||
|
|
eac337c058 | ||
|
|
655b767127 | ||
|
|
9ebf89bde2 | ||
|
|
191a9c82be | ||
|
|
00981a502a | ||
|
|
d75198be9f | ||
|
|
d009a1ef50 | ||
|
|
9001b6fc51 | ||
|
|
a24ef15a02 | ||
|
|
7fdab094fc | ||
|
|
c2646f59c4 | ||
|
|
152ed86c3a | ||
|
|
21754c1660 | ||
|
|
145b424760 | ||
|
|
a68bf2eae7 | ||
|
|
bc7c771f20 | ||
|
|
1ed414c6fb | ||
|
|
7d27db69c6 | ||
|
|
d891002c84 | ||
|
|
e0eacc2530 | ||
|
|
c637f82ae2 | ||
|
|
7cafab1b9f | ||
|
|
c96f27b96c | ||
|
|
406cac1362 | ||
|
|
13fd0712d9 | ||
|
|
1414ef2c1c | ||
|
|
42e8fe3d21 | ||
|
|
bad73fcea8 | ||
|
|
94249ba67d | ||
|
|
2abd859a29 | ||
|
|
98cb42d2e5 | ||
|
|
878d05685c |
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# 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
|
||||||
@@ -77,6 +77,7 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
|||||||
|
|
||||||
## Cursor-Managed Modules
|
## 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_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
|
## Workflow
|
||||||
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||||
|
|||||||
142
CLAUDE.md
142
CLAUDE.md
@@ -12,9 +12,26 @@
|
|||||||
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
|
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).
|
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
||||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||||
|
**`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.
|
6. **res.groups**: NO `users` field, NO `category_id` field.
|
||||||
|
**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.
|
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.
|
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%'`.
|
||||||
|
|
||||||
## Card Styling — Copy Odoo's Kanban Pattern
|
## 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:
|
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:
|
||||||
@@ -77,12 +94,38 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
|||||||
|
|
||||||
## Cursor-Managed Modules
|
## 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_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||||
|
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 1–11 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_authorizer_portal`.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||||
- Local URL: http://localhost:8069
|
- 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.
|
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
||||||
|
|
||||||
|
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
|
||||||
|
When a Python action opens an attachment, route it through `fusion_pdf_preview` instead of returning `ir.actions.act_url` with `download=true` or `target=new`. The preview dialog gives operators preview + print + download in one place and writes an audit log; non-PDF attachments fall back to the legacy download path automatically.
|
||||||
|
|
||||||
|
The drop-in replacement is the new helper on `ir.attachment`:
|
||||||
|
```python
|
||||||
|
return att.action_fusion_preview(title='My Doc')
|
||||||
|
# vs. the old pattern:
|
||||||
|
# return {'type': 'ir.actions.act_url',
|
||||||
|
# 'url': '/web/content/%s?download=true' % att.id,
|
||||||
|
# 'target': 'new'}
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper auto-detects mimetype: PDFs go to the dialog, everything else (ZPL, CSV, XML, images) stays on download. So a callsite that today serves CSV today and a PDF tomorrow doesn't need a code change — same call, different routing.
|
||||||
|
|
||||||
|
If you need to invoke the client action directly (rare — only when you don't have a recordset handy), the tag is `fusion_pdf_preview.open_attachment` and the params are `{attachment_id, title, model_name, record_ids, report_name}`. See `fusion_pdf_preview/static/src/js/open_attachment_action.js`.
|
||||||
|
|
||||||
|
Existing reports (`ir.actions.report` of type `qweb-pdf`) are intercepted automatically by `fusion_pdf_preview/static/src/js/pdf_preview.js`; the helper above is for the *other* pattern — attachments opened by custom buttons.
|
||||||
|
|
||||||
## Supabase Knowledge Base
|
## Supabase Knowledge Base
|
||||||
Before starting unfamiliar work, check Supabase for context:
|
Before starting unfamiliar work, check Supabase for context:
|
||||||
```bash
|
```bash
|
||||||
@@ -92,3 +135,98 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
|
|||||||
- `fusionapps.issues` — known issues and fixes
|
- `fusionapps.issues` — known issues and fixes
|
||||||
- `fusionapps.code_snippets` — reference code
|
- `fusionapps.code_snippets` — reference code
|
||||||
- `fusionapps.quick_commands` — deployment and admin commands
|
- `fusionapps.quick_commands` — deployment and admin commands
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|||||||
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.
|
||||||
File diff suppressed because it is too large
Load Diff
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,284 @@
|
|||||||
|
# ADP Application Received — Bundled Pages 11 & 12 (Design)
|
||||||
|
|
||||||
|
**Date:** 2026-05-19
|
||||||
|
**Module:** `fusion_claims`
|
||||||
|
**Owner:** Gurpreet
|
||||||
|
**Status:** Approved (ready for implementation plan)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
When marking an ADP application as Received, the `Application Received` wizard requires two separate PDF uploads:
|
||||||
|
|
||||||
|
1. **Original ADP Application** (`x_fc_original_application`)
|
||||||
|
2. **Signed Pages 11 & 12** (`x_fc_signed_pages_11_12`)
|
||||||
|
|
||||||
|
In day-to-day operations the office or the client often scans (or emails) the **entire** ADP application as a single PDF — already including signed pages 11 & 12. Today, staff have to manually split pages 11 & 12 out of the bundled PDF and upload them again as a separate file, even though the same signatures are already present in the original PDF.
|
||||||
|
|
||||||
|
The wizard must continue to support the existing flows (separate signed-pages file, remote signing via Page 11 signing request), but it should also accept the bundled case without manual splitting.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Allow staff to mark Application Received with **one** PDF when pages 11 & 12 are inside it.
|
||||||
|
- Preserve the two existing modes (separate file, remote signing).
|
||||||
|
- Keep downstream audit/case-close checks correct without rewriting every consumer.
|
||||||
|
- Make the wizard easier to use and slightly safer (real PDF detection, friendlier messages).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- PDF page extraction or splitting (explicitly rejected by user — "no split").
|
||||||
|
- Capturing Page 11 signer identity in the bundled / separate-file modes (existing gap; out of scope).
|
||||||
|
- Re-architecting the document-attachment model to de-duplicate identical binaries (out of scope).
|
||||||
|
- Changes to the remote signing wizard or `fusion.page11.sign.request` model.
|
||||||
|
|
||||||
|
## High-Level Approach
|
||||||
|
|
||||||
|
Add a **single boolean flag** on `sale.order` that records whether pages 11 & 12 are inside the original application PDF. Introduce a **computed helper field** that downstream consumers read instead of `x_fc_signed_pages_11_12` directly. Add a **three-mode radio** at the top of the Application Received wizard.
|
||||||
|
|
||||||
|
Minimal blast radius:
|
||||||
|
- One new boolean, one new computed field on `sale.order`.
|
||||||
|
- Wizard view + Python rewritten to drive logic off the radio mode.
|
||||||
|
- Four downstream call sites change which field they read (no logic change).
|
||||||
|
- Three small complementary fixes folded in (status-gate text, PDF magic-bytes check, page-count indicator).
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### `sale.order` — new fields
|
||||||
|
|
||||||
|
```python
|
||||||
|
x_fc_pages_11_12_in_original = fields.Boolean(
|
||||||
|
string='Pages 11 & 12 in Original Application',
|
||||||
|
default=False,
|
||||||
|
tracking=True,
|
||||||
|
help='True when the original application PDF already contains the signed pages 11 & 12.',
|
||||||
|
)
|
||||||
|
|
||||||
|
x_fc_has_signed_pages_11_12 = fields.Boolean(
|
||||||
|
string='Has Signed Pages 11 & 12',
|
||||||
|
compute='_compute_has_signed_pages_11_12',
|
||||||
|
store=True,
|
||||||
|
help='True if pages 11 & 12 are satisfied — either bundled, uploaded separately, '
|
||||||
|
'or signed via remote signing request.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends(
|
||||||
|
'x_fc_signed_pages_11_12',
|
||||||
|
'x_fc_pages_11_12_in_original',
|
||||||
|
'page11_sign_request_ids.state',
|
||||||
|
)
|
||||||
|
def _compute_has_signed_pages_11_12(self):
|
||||||
|
for order in self:
|
||||||
|
order.x_fc_has_signed_pages_11_12 = bool(
|
||||||
|
order.x_fc_pages_11_12_in_original
|
||||||
|
or order.x_fc_signed_pages_11_12
|
||||||
|
or order.page11_sign_request_ids.filtered(lambda r: r.state == 'signed')
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Existing fields — unchanged meaning
|
||||||
|
|
||||||
|
- `x_fc_original_application` — original (or bundled) PDF.
|
||||||
|
- `x_fc_signed_pages_11_12` — separate signed-pages file when one exists. Stays optional.
|
||||||
|
- `page11_sign_request_ids` — remote signing requests. Unchanged.
|
||||||
|
|
||||||
|
### Audit trail field
|
||||||
|
|
||||||
|
`x_fc_trail_has_signed_pages` already exists at [models/sale_order.py:3248](../../fusion_claims/models/sale_order.py:3248). Its compute body changes from `bool(order.x_fc_signed_pages_11_12)` to `order.x_fc_has_signed_pages_11_12`.
|
||||||
|
|
||||||
|
### Migration
|
||||||
|
|
||||||
|
None. Existing records get `x_fc_pages_11_12_in_original = False` by default; their existing `x_fc_signed_pages_11_12` binary continues to satisfy the new computed gate. Stored compute will populate `x_fc_has_signed_pages_11_12` for legacy rows on first read or recompute.
|
||||||
|
|
||||||
|
## Wizard Changes — `fusion_claims.application.received.wizard`
|
||||||
|
|
||||||
|
### New fields
|
||||||
|
|
||||||
|
```python
|
||||||
|
intake_mode = fields.Selection(
|
||||||
|
[
|
||||||
|
('bundled', 'Pages 11 & 12 are INCLUDED in the original application'),
|
||||||
|
('separate', 'Pages 11 & 12 are a SEPARATE file'),
|
||||||
|
('remote', 'Pages 11 & 12 will be SIGNED REMOTELY'),
|
||||||
|
],
|
||||||
|
string='Intake Mode',
|
||||||
|
required=True,
|
||||||
|
default='bundled',
|
||||||
|
)
|
||||||
|
|
||||||
|
original_page_count = fields.Integer(
|
||||||
|
string='Original PDF Page Count',
|
||||||
|
compute='_compute_original_page_count',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`signed_pages_11_12` and `signed_pages_filename` keep their current definitions — they're only required in `separate` mode now.
|
||||||
|
|
||||||
|
The existing computed fields `has_pending_page11_request` and `has_signed_page11` ([wizard/application_received_wizard.py:44-49](../../fusion_claims/wizard/application_received_wizard.py:44)) **stay** — they drive the "request pending" / "remote signature complete" banners now only shown when `intake_mode == 'remote'`.
|
||||||
|
|
||||||
|
### `default_get` — pick an initial mode from existing state
|
||||||
|
|
||||||
|
```python
|
||||||
|
# When re-opening the wizard on an order that already has some data:
|
||||||
|
if order.x_fc_pages_11_12_in_original:
|
||||||
|
res['intake_mode'] = 'bundled'
|
||||||
|
elif order.x_fc_signed_pages_11_12:
|
||||||
|
res['intake_mode'] = 'separate'
|
||||||
|
elif order.page11_sign_request_ids.filtered(lambda r: r.state in ('sent', 'signed')):
|
||||||
|
res['intake_mode'] = 'remote'
|
||||||
|
else:
|
||||||
|
res['intake_mode'] = 'bundled' # new default for fresh records
|
||||||
|
```
|
||||||
|
|
||||||
|
### View behaviour (declarative `invisible` on group containers)
|
||||||
|
|
||||||
|
| Mode | Original upload | Signed Pages 11 & 12 upload | Remote-sign banner / button |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bundled` | shown, required | hidden | hidden |
|
||||||
|
| `separate` | shown, required | shown, required | hidden |
|
||||||
|
| `remote` | shown, required | hidden | shown (existing `action_request_page11_signature` button) |
|
||||||
|
|
||||||
|
Page count is displayed read-only next to the original-application filename once a PDF is loaded. If `pdfrw` fails to parse, show *"(could not read PDF)"* — does not block confirmation.
|
||||||
|
|
||||||
|
### `action_confirm` (new shape)
|
||||||
|
|
||||||
|
```python
|
||||||
|
def action_confirm(self):
|
||||||
|
self.ensure_one()
|
||||||
|
order = self.sale_order_id
|
||||||
|
|
||||||
|
if order.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'):
|
||||||
|
raise UserError(
|
||||||
|
"Can only mark application received from 'Assessment Completed' "
|
||||||
|
"or 'Waiting for Application' status."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self.original_application:
|
||||||
|
raise UserError("Please upload the Original ADP Application.")
|
||||||
|
|
||||||
|
self._validate_pdf_bytes(self.original_application, 'Original ADP Application')
|
||||||
|
|
||||||
|
vals = {
|
||||||
|
'x_fc_adp_application_status': 'application_received',
|
||||||
|
'x_fc_original_application': self.original_application,
|
||||||
|
'x_fc_original_application_filename': self.original_application_filename,
|
||||||
|
'x_fc_pages_11_12_in_original': (self.intake_mode == 'bundled'),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.intake_mode == 'separate':
|
||||||
|
if not (self.signed_pages_11_12 or order.x_fc_signed_pages_11_12):
|
||||||
|
raise UserError("Pages 11 & 12 file is required for Separate-file mode.")
|
||||||
|
if self.signed_pages_11_12:
|
||||||
|
self._validate_pdf_bytes(self.signed_pages_11_12, 'Signed Pages 11 & 12')
|
||||||
|
vals['x_fc_signed_pages_11_12'] = self.signed_pages_11_12
|
||||||
|
vals['x_fc_signed_pages_filename'] = self.signed_pages_filename
|
||||||
|
|
||||||
|
elif self.intake_mode == 'remote':
|
||||||
|
has_request = order.page11_sign_request_ids.filtered(
|
||||||
|
lambda r: r.state in ('sent', 'signed')
|
||||||
|
)
|
||||||
|
if not has_request:
|
||||||
|
raise UserError(
|
||||||
|
"Remote-signing request not found. Click 'Request Remote Signature' "
|
||||||
|
"first, or pick a different mode."
|
||||||
|
)
|
||||||
|
# bundled flag stays False — signature lives in the request's signed_pdf
|
||||||
|
|
||||||
|
order.with_context(skip_status_validation=True).write(vals)
|
||||||
|
self._post_chatter(order)
|
||||||
|
return {'type': 'ir.actions.act_window_close'}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `intake_mode == 'bundled'`, any pre-existing `x_fc_signed_pages_11_12` from a prior wizard run is left alone (we don't clear it). The bundled flag plus the existing separate file together are harmless — the computed gate is `OR`.
|
||||||
|
|
||||||
|
### PDF magic-bytes check
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _validate_pdf_bytes(self, b64_data, label):
|
||||||
|
import base64
|
||||||
|
if not b64_data:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
head = base64.b64decode(b64_data)[:5]
|
||||||
|
except Exception:
|
||||||
|
raise UserError(f"{label}: could not decode uploaded file.")
|
||||||
|
if head != b'%PDF-':
|
||||||
|
raise UserError(f"{label} must be a PDF file (content check failed).")
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing filename `.pdf` check stays in place as a defence-in-depth `@api.constrains`.
|
||||||
|
|
||||||
|
### Chatter message — mode-aware
|
||||||
|
|
||||||
|
| Mode | Headline | Detail line |
|
||||||
|
|---|---|---|
|
||||||
|
| `bundled` | *Application Received — bundled* | "Pages 11 & 12 included in original PDF" |
|
||||||
|
| `separate` | *Application Received — separate files* | "Original + separate signed pages uploaded" |
|
||||||
|
| `remote` | *Application Received — remote signature pending* | "Page 11 sent for remote signature (`N` request(s) outstanding)" where `N` is the count of `page11_sign_request_ids` in state `sent` or `signed`. |
|
||||||
|
|
||||||
|
Notes from the wizard, if any, are appended below as today.
|
||||||
|
|
||||||
|
## Downstream Consumer Changes
|
||||||
|
|
||||||
|
These are mechanical: change which field they read. **No logic changes.**
|
||||||
|
|
||||||
|
| File | Line | Old | New |
|
||||||
|
|---|---|---|---|
|
||||||
|
| [wizard/ready_for_submission_wizard.py:95](../../fusion_claims/wizard/ready_for_submission_wizard.py:95) | `_compute_field_status` | `bool(order.x_fc_original_application and order.x_fc_signed_pages_11_12)` | `bool(order.x_fc_original_application and order.x_fc_has_signed_pages_11_12)` |
|
||||||
|
| [wizard/ready_for_submission_wizard.py:148](../../fusion_claims/wizard/ready_for_submission_wizard.py:148) | gate check | `if not order.x_fc_signed_pages_11_12` | `if not order.x_fc_has_signed_pages_11_12` |
|
||||||
|
| [wizard/case_close_verification_wizard.py](../../fusion_claims/wizard/case_close_verification_wizard.py) | wherever pages-11-12 gate is checked | `x_fc_signed_pages_11_12` | `x_fc_has_signed_pages_11_12` |
|
||||||
|
| [models/sale_order.py:3248](../../fusion_claims/models/sale_order.py:3248) | `x_fc_trail_has_signed_pages` compute | `bool(order.x_fc_signed_pages_11_12)` | `order.x_fc_has_signed_pages_11_12` |
|
||||||
|
|
||||||
|
The `x_fc_signed_pages_11_12` field stays in the data model. Any download / preview / "open document" button that points at the literal binary stays as-is — bundled-mode orders simply won't have this field populated, and the UI should hide the "Open signed pages" button when the field is empty (it already does — Odoo hides empty binary widgets by default).
|
||||||
|
|
||||||
|
## Error / Edge Cases
|
||||||
|
|
||||||
|
| Scenario | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| User toggles from `separate` to `bundled` after uploading a separate file | Wizard does not clear the upload field. On confirm, only the original application is written; bundled flag goes to True. The separate-file binary in the wizard is discarded (it was never written). |
|
||||||
|
| User picks `remote` but has no sent/signed request | Block with the message above; user must click *Request Remote Signature* first. |
|
||||||
|
| User picks `bundled` but the PDF is short (e.g. 4 pages) | Page-count indicator shows *"(4 pages)"* as a visual hint, but **does not block**. The 14-page ADP form is the norm but the system can't reliably enforce it across form versions. |
|
||||||
|
| Legacy record without `x_fc_pages_11_12_in_original` set | Defaults to False. As long as `x_fc_signed_pages_11_12` is present, `x_fc_has_signed_pages_11_12` is True — gate still passes. |
|
||||||
|
| Stored compute not populated for legacy rows | Triggered on first read or via a one-line `_recompute` on module load is **not** required — Odoo computes on first access. If users hit issues, a one-off psql `UPDATE` can be run manually. |
|
||||||
|
| Remote signing completes after `bundled` mode was used | `_compute_has_signed_pages_11_12` already ORs in `page11_sign_request_ids.state == 'signed'` — harmless overlap; trail stays correct. |
|
||||||
|
| Uploaded file is not really a PDF (wrong content) | Magic-byte check raises a UserError; record is not changed. |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit tests — wizard (`tests/test_application_received_wizard.py`, new)
|
||||||
|
|
||||||
|
- `test_bundled_mode_marks_received_with_only_original`
|
||||||
|
- `test_separate_mode_requires_signed_pages`
|
||||||
|
- `test_remote_mode_requires_sent_or_signed_request`
|
||||||
|
- `test_invalid_pdf_bytes_rejected`
|
||||||
|
- `test_chatter_message_mentions_intake_mode`
|
||||||
|
|
||||||
|
### Unit tests — downstream gates
|
||||||
|
|
||||||
|
- `test_ready_for_submission_passes_with_bundled_flag` (no `x_fc_signed_pages_11_12` set)
|
||||||
|
- `test_case_close_audit_accepts_bundled_flag`
|
||||||
|
- `test_trail_has_signed_pages_true_when_bundled`
|
||||||
|
|
||||||
|
### Manual smoke test on local dev DB
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims --stop-after-init
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the UI:
|
||||||
|
1. Take an order in *Waiting for Application*.
|
||||||
|
2. Click *Mark Application Received* → pick **Bundled** → upload a single PDF → confirm.
|
||||||
|
3. Confirm chatter shows the bundled message and `x_fc_pages_11_12_in_original = True`.
|
||||||
|
4. Click *Mark Ready for Submission* — the document gate should pass.
|
||||||
|
5. Repeat on another order with **Separate** mode to confirm the old flow still works.
|
||||||
|
6. Repeat on a third order with **Remote** mode after triggering a signing request.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
- Bump `version` in [fusion_claims/__manifest__.py](../../fusion_claims/__manifest__.py).
|
||||||
|
- `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims --stop-after-init`.
|
||||||
|
- Reload browser with cache clear (per CLAUDE.md asset-bundle-cache rule).
|
||||||
|
- No production deploy steps unique to this change.
|
||||||
|
|
||||||
|
## Open Questions (none blocking implementation)
|
||||||
|
|
||||||
|
- Should bundled-mode capture Page 11 signer identity (signer name, relationship) the way the remote flow does? Currently neither bundled nor separate-file modes do — existing gap, deferred.
|
||||||
|
- Should the bundled-mode chatter automatically attach a one-line note like *"Operator confirms pages 11 & 12 are within the original application"* with the user's name? The default chatter post already records the user. Leaving as-is.
|
||||||
1351
docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
Normal file
1351
docs/superpowers/specs/2026-05-20-fusion-repairs-design.md
Normal file
File diff suppressed because it is too large
Load Diff
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.
|
||||||
File diff suppressed because it is too large
Load Diff
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\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
fusion_accounting/.DS_Store
vendored
Normal file
BIN
fusion_accounting/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
fusion_accounting/fusion_accounting/.DS_Store
vendored
Normal file
BIN
fusion_accounting/fusion_accounting/.DS_Store
vendored
Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
BIN
fusion_accounting/fusion_accounting_ai/.DS_Store
vendored
Normal file
BIN
fusion_accounting/fusion_accounting_ai/.DS_Store
vendored
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user