Compare commits
665 Commits
d7bbeb49b7
...
feat/asses
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21cfd55419 | ||
|
|
89467432a7 | ||
|
|
e0ddd9ef40 | ||
|
|
b17bd615bf | ||
|
|
e36aaab306 | ||
|
|
37efc5b858 | ||
|
|
aafc2db8a8 | ||
|
|
747c814249 | ||
|
|
c527c7cade | ||
|
|
f7ec1e28f9 | ||
|
|
96b3f124f8 | ||
|
|
2c32e7bcd0 | ||
|
|
aa9b95bd5d | ||
|
|
493f01827e | ||
|
|
2ab59bccde | ||
|
|
914c96a09a | ||
|
|
b015958edc | ||
|
|
ca94a4c42a | ||
|
|
a5ec79013a | ||
|
|
b61e159e6f | ||
|
|
13a892c7ab | ||
|
|
2ee01fd1f2 | ||
|
|
d6d6bbe161 | ||
|
|
31098c4d14 | ||
|
|
1a1ab2da4f | ||
|
|
3f78f652e7 | ||
|
|
e230e42d81 | ||
|
|
06346cfa6b | ||
|
|
a858693d9c | ||
|
|
68b10e1199 | ||
|
|
e260f030d1 | ||
|
|
8d6fcb75a7 | ||
|
|
fef99809e5 | ||
|
|
ea4f216c1a | ||
|
|
db48029e61 | ||
|
|
be721f82ae | ||
|
|
806ec5a5a6 | ||
|
|
0acd2251e6 | ||
|
|
defa7250e1 | ||
|
|
719853c251 | ||
|
|
6a9c7c74ea | ||
|
|
87639a12b5 | ||
|
|
360370db15 | ||
|
|
85bbd8a20e | ||
|
|
136a64ea21 | ||
|
|
a479052b72 | ||
|
|
11108dfea3 | ||
|
|
85cdecddea | ||
|
|
2aaa1a57e7 | ||
|
|
b5d5a9acba | ||
|
|
0d94af6532 | ||
|
|
95abd2e337 | ||
|
|
b1db851e29 | ||
|
|
f18c59fe89 | ||
|
|
2fb774e4fa | ||
|
|
60c25f8241 | ||
|
|
47a6523e24 | ||
|
|
4a9f31cef5 | ||
|
|
dd908c3861 | ||
|
|
5c1f60b3b8 | ||
|
|
55da42e91f | ||
|
|
ab3e6fa1e2 | ||
|
|
e2f7fa6d19 | ||
|
|
2c8ad83d43 | ||
|
|
3fd074ff6d | ||
|
|
e26a7cd9e8 | ||
|
|
09cea73e50 | ||
|
|
3235d4ceca | ||
|
|
5a488ae86e | ||
|
|
55898dd1d4 | ||
|
|
2a16f80d8d | ||
|
|
cecc699a70 | ||
|
|
4949856336 | ||
|
|
9826e03b4e | ||
|
|
69aa6b050b | ||
|
|
5675784916 | ||
|
|
0d4a871d0c | ||
|
|
aac95ee16b | ||
|
|
028814b292 | ||
|
|
2bd0672b52 | ||
|
|
dc1dacddc2 | ||
|
|
6dde3ec2b1 | ||
|
|
a2ac804238 | ||
|
|
f8929eb686 | ||
|
|
a07a5f931a | ||
|
|
c6022c70f9 | ||
|
|
7efaadc1c1 | ||
|
|
21300db8e8 | ||
|
|
1e9ffccd6b | ||
|
|
b2186ab032 | ||
|
|
855b160752 | ||
|
|
da7ec59474 | ||
|
|
2ed3dcee58 | ||
|
|
9b18f77e06 | ||
|
|
1ae83e187e | ||
|
|
1b0657bd76 | ||
|
|
f75e082e67 | ||
|
|
f1273798cd | ||
|
|
bb814a46ff | ||
|
|
be7256ce4c | ||
|
|
d37f10f1c3 | ||
|
|
b98ee8a6fb | ||
|
|
df0de97a68 | ||
|
|
49a0a953e5 | ||
|
|
64eb34cdff | ||
|
|
cd0c08f348 | ||
|
|
6a5364e053 | ||
|
|
ec78fc148d | ||
|
|
9d9be17542 | ||
|
|
1d1bbfe612 | ||
|
|
b1257b6983 | ||
|
|
687decca28 | ||
|
|
307afbf3c0 | ||
|
|
fecd2415f6 | ||
|
|
e36318f7a5 | ||
|
|
feddca19d6 | ||
|
|
95378ff1da | ||
|
|
c8529b8a99 | ||
|
|
7a66d7849d | ||
|
|
9ad09c32b0 | ||
|
|
6b63df8c3d | ||
|
|
72d3130c88 | ||
|
|
f6518b4d7e | ||
|
|
bf6ee2bb2c | ||
|
|
077f898283 | ||
|
|
779539d1b5 | ||
|
|
34a65f9c4a | ||
|
|
97cce8c755 | ||
|
|
fe98fadf61 | ||
|
|
32c7026558 | ||
|
|
76866a7c76 | ||
|
|
f19ca02e05 | ||
|
|
1f5eaf0386 | ||
|
|
a82f09ea50 | ||
|
|
a5144a925c | ||
|
|
2bdf4ef6a0 | ||
|
|
3ba9f2821e | ||
|
|
0104e87750 | ||
|
|
1f818096db | ||
|
|
bb873e8a7a | ||
|
|
d4ef4d55e0 | ||
|
|
fc8963da99 | ||
|
|
c520803c84 | ||
|
|
7349f3180d | ||
|
|
2414b6328e | ||
|
|
5605012245 | ||
|
|
52849777dd | ||
|
|
6f060896bf | ||
|
|
3e0b531110 | ||
|
|
8cc02759b8 | ||
|
|
40b3205274 | ||
|
|
15470426eb | ||
|
|
b22bb11b31 | ||
|
|
134c94fc6c | ||
|
|
f1a2b300f7 | ||
|
|
396170b438 | ||
|
|
eb186cac3c | ||
|
|
4acf9d7f85 | ||
|
|
e596723ba5 | ||
|
|
d7ec91b0f1 | ||
|
|
3e5ced1655 | ||
|
|
aabfc1afe7 | ||
|
|
45b698beb5 | ||
|
|
de6336ba42 | ||
|
|
c876767755 | ||
|
|
d1fc3d8720 | ||
|
|
a78ceaba51 | ||
|
|
6c15a7b1cf | ||
|
|
45ddb444a7 | ||
|
|
9df3262d30 | ||
|
|
5d9609b5ee | ||
|
|
622f133f05 | ||
|
|
482f12256e | ||
|
|
86b8e59c95 | ||
|
|
1b8038d8e8 | ||
|
|
a2d13cf83b | ||
|
|
6f6aa6e90a | ||
|
|
0513ea23a4 | ||
|
|
72aa28e6c4 | ||
|
|
a7cf44249d | ||
|
|
0e6ebe7bc6 | ||
|
|
dced0c66a4 | ||
|
|
2ced576204 | ||
|
|
61a0cb244f | ||
|
|
aeea670064 | ||
|
|
b0836e1c93 | ||
|
|
a32946be44 | ||
|
|
01a85c475c | ||
|
|
43b2edcbb5 | ||
|
|
d770c0c3a9 | ||
|
|
a5db0fe71e | ||
|
|
c44fd89ed1 | ||
|
|
6c395709cf | ||
|
|
0754d0b101 | ||
|
|
2435096f32 | ||
|
|
25952cf226 | ||
|
|
eb1ee85d24 | ||
|
|
1e34a67384 | ||
|
|
a1cfab6fe9 | ||
|
|
a46e31e710 | ||
|
|
032b10752e | ||
|
|
e7d63a3859 | ||
|
|
2b47bd8b10 | ||
|
|
2f74d5ecb9 | ||
|
|
f8abadfc18 | ||
|
|
164b775206 | ||
|
|
b7211468b2 | ||
|
|
fb6cccc8b1 | ||
|
|
ae02164b78 | ||
|
|
a5063cc816 | ||
|
|
89267a9f41 | ||
|
|
e599daf4d9 | ||
|
|
e09913af5a | ||
|
|
416daa36d2 | ||
|
|
b7f280141f | ||
|
|
2b8d99f69d | ||
|
|
18072c9c60 | ||
|
|
1d0d4afdbf | ||
|
|
f5cee25299 | ||
|
|
6351aa6054 | ||
|
|
a7cbd1a6f7 | ||
|
|
9c7b7c54e5 | ||
|
|
48c2a4bfe1 | ||
|
|
4c5ee6143c | ||
|
|
faffdca592 | ||
|
|
15e25ca50b | ||
|
|
c71e61da77 | ||
|
|
0f2ed5cc16 | ||
|
|
1d674e587c | ||
|
|
713ba17e37 | ||
|
|
43abb8ef25 | ||
|
|
27af984f28 | ||
|
|
aab842d6d3 | ||
|
|
a9256dbed7 | ||
|
|
200a2efeb8 | ||
|
|
76a80badff | ||
|
|
095db7375c | ||
|
|
299cae8a4e | ||
|
|
baf5c4158f | ||
|
|
01df46f79f | ||
|
|
92b690aef1 | ||
|
|
08bc2b6a89 | ||
|
|
ad3d6261af | ||
|
|
f04b31cec7 | ||
|
|
5f898d4209 | ||
|
|
807ed86ef6 | ||
|
|
525ed6a61d | ||
|
|
b308380201 | ||
|
|
7da51b4ec8 | ||
|
|
5764d439c3 | ||
|
|
5f372b462a | ||
|
|
67af54b46e | ||
|
|
5a699de1ca | ||
|
|
1b473a7873 | ||
|
|
9223f8da7c | ||
|
|
8c9b645196 | ||
|
|
2aa4bce089 | ||
|
|
46c62ebefa | ||
|
|
152e6d4328 | ||
|
|
33fff5acba | ||
|
|
2ae1c867b5 | ||
|
|
c990110646 | ||
|
|
5872583fbb | ||
|
|
c8db3915ea | ||
|
|
547e7d66a9 | ||
|
|
bfeca0ac32 | ||
|
|
40d563801a | ||
|
|
e271908109 | ||
|
|
72f75fe754 | ||
|
|
6cb352629a | ||
|
|
d53bb73055 | ||
|
|
ff51035494 | ||
|
|
0ed4f88da2 | ||
|
|
caeba27846 | ||
|
|
a2e254b934 | ||
|
|
8b14466da2 | ||
|
|
5a039ae369 | ||
|
|
aab6b9275b | ||
|
|
26a1086623 | ||
|
|
c00831a72a | ||
|
|
3a120dd400 | ||
|
|
4dc0a7cca5 | ||
|
|
4930a89970 | ||
|
|
72f0f182a6 | ||
|
|
5173554281 | ||
|
|
c2b693c97e | ||
|
|
051094813e | ||
|
|
edf3f95854 | ||
|
|
80887d6098 | ||
|
|
5d5964a327 | ||
|
|
80f80fb707 | ||
|
|
bfc138251a | ||
|
|
7dab5fb9c6 | ||
|
|
8d4c85cc52 | ||
|
|
fc17754996 | ||
|
|
0371624afb | ||
|
|
eed1c4619d | ||
|
|
170398ab6f | ||
|
|
d4e95dcd47 | ||
|
|
e1fedf7231 | ||
|
|
9a2975b154 | ||
|
|
271a995455 | ||
|
|
056178b433 | ||
|
|
2285c9def1 | ||
|
|
6afc9e3c0d | ||
|
|
b06d28e7f6 | ||
|
|
7b90f210b9 | ||
|
|
c75d2bde5a | ||
|
|
9e6b88f60e | ||
|
|
dc6afdd021 | ||
|
|
978cd5953e | ||
|
|
b869c31ba3 | ||
|
|
67fc22237b | ||
|
|
d9f2983ea7 | ||
|
|
3120612e35 | ||
|
|
2a93ece4ba | ||
|
|
b26fa13044 | ||
|
|
7ff46af192 | ||
|
|
6d4b6284ad | ||
|
|
d8456fb9a3 | ||
|
|
b41d9629e1 | ||
|
|
10477a7c8f | ||
|
|
8f6302b446 | ||
|
|
87e924d1d8 | ||
|
|
7fab01e5cb | ||
|
|
4911088dc1 | ||
|
|
086ff512b6 | ||
|
|
96e33834bd | ||
|
|
765b095035 | ||
|
|
358b90516b | ||
|
|
dd0dc26232 | ||
|
|
1dea752a29 | ||
|
|
9f3edd60ae | ||
|
|
0b92294586 | ||
|
|
a52ef29a84 | ||
|
|
97deb93ee7 | ||
|
|
b67186a25b | ||
|
|
258782e3c3 | ||
|
|
acc95d8ee0 | ||
|
|
e9b82fbe9d | ||
|
|
c3bcb4b99d | ||
|
|
cfaf4657ce | ||
|
|
7966f8d505 | ||
|
|
839a7f0abc | ||
|
|
0f751d82cc | ||
|
|
aa8161f764 | ||
|
|
31740b3949 | ||
|
|
e99cf20887 | ||
|
|
cc5542833f | ||
|
|
0568d8ae87 | ||
|
|
c2180d3691 | ||
|
|
42036c23ab | ||
|
|
7bcbcb4008 | ||
|
|
0047f49d2c | ||
|
|
5cc1117f75 | ||
|
|
de3ec7d97a | ||
|
|
89a937fb32 | ||
|
|
830b29ce49 | ||
|
|
269f9984ef | ||
|
|
9e5c23f37d | ||
|
|
36cd4341a7 | ||
|
|
c34dfce6c3 | ||
|
|
84ed406c8e | ||
|
|
f4e1f9d218 | ||
|
|
8eb2c2de95 | ||
|
|
bdf676e05a | ||
|
|
6c7e11db4d | ||
|
|
a53b03265d | ||
|
|
560ffa2cdf | ||
|
|
d89546bec7 | ||
|
|
818dfa3882 | ||
|
|
772107d25b | ||
|
|
c61371005a | ||
|
|
7a0bd67fc0 | ||
|
|
efc420b4ce | ||
|
|
fd2b2908f3 | ||
|
|
eb1fd50add | ||
|
|
a60506a645 | ||
|
|
8b9b4d60ad | ||
|
|
a90eace4d0 | ||
|
|
7c2ae84e32 | ||
|
|
63d692b322 | ||
|
|
1a3ca8704e | ||
|
|
d6ebcb6233 | ||
|
|
48805b5988 | ||
|
|
005daade55 | ||
|
|
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 | ||
|
|
bd2c037a97 | ||
|
|
44636e47fb | ||
|
|
06c49ecec6 | ||
|
|
37deaedf0d | ||
|
|
30f7f18472 | ||
|
|
66e9749853 | ||
|
|
c9be68a575 | ||
|
|
19d692afe7 | ||
|
|
0351dcd497 | ||
|
|
03fd3d7c1c | ||
|
|
f4c9ed3d24 | ||
|
|
ef885c66dc | ||
|
|
148aa5cba8 | ||
|
|
661c8ae227 | ||
|
|
a24a1ddf1a | ||
|
|
f05cacec22 | ||
|
|
9239ee2822 | ||
|
|
4733885211 | ||
|
|
8e708bf2c4 | ||
|
|
caf240daec | ||
|
|
4bed8ab2c5 | ||
|
|
50c209b8d3 | ||
|
|
65a1c4b17e | ||
|
|
91d3a3f9d1 | ||
|
|
70f855d91b | ||
|
|
85eddba546 | ||
|
|
48d3e48e61 | ||
|
|
f07e1bcce1 | ||
|
|
e7c6960de9 | ||
|
|
ad64b0b4c9 | ||
|
|
cd763fa1d7 | ||
|
|
f40f44aafd | ||
|
|
63bf271725 | ||
|
|
974b8a5152 | ||
|
|
0a32ed2da7 | ||
|
|
e4681a58c6 | ||
|
|
135cbd3a5c | ||
|
|
3182ca3c39 | ||
|
|
677e460438 | ||
|
|
c7b794f604 | ||
|
|
64c61dcca8 | ||
|
|
649b75d4a1 | ||
|
|
8aa817b1a0 | ||
|
|
80d1cc5639 | ||
|
|
2db789d7dd |
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Editor / OS noise
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Odoo runtime
|
||||
*.pyc-tmp
|
||||
|
||||
# Local-only diagnostic logs from test runs
|
||||
_test_*.log
|
||||
.superpowers/
|
||||
@@ -77,6 +77,7 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
|
||||
## Cursor-Managed Modules
|
||||
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||
- **fusion_repairs** — status and deferred work: [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) (bundles 1–11 shipped at `19.0.2.2.4`; not production-deployed)
|
||||
|
||||
## Workflow
|
||||
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
|
||||
150
CLAUDE.md
150
CLAUDE.md
@@ -12,9 +12,28 @@
|
||||
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
|
||||
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field.
|
||||
**`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field. **The Odoo 19 replacement for `category_id` is `res.groups.privilege`.** To make a module's groups appear as application-access dropdowns on the user form (Settings → Users → *Application Accesses*) instead of only in developer mode: define an `ir.module.category`, a `res.groups.privilege` (with `category_id` → that category), and set each group's `privilege_id` → that privilege. Groups under one privilege that form an `implied_ids` chain render as a single role dropdown; a standalone group in its own privilege renders as a separate row under the same category header. Verified in `fusion_clock/security/security.xml`; mirrors `fusion_plating`/`fusion_tasks`.
|
||||
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
|
||||
**`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like `<page>`, `<button>`, `<field>` is unrelated and still works.)
|
||||
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
|
||||
7. **Search views**: NO `group expand="0"` syntax.
|
||||
8. **SCSS imports**: `@import "./partial"` is FORBIDDEN in Odoo 19 custom SCSS. It prints a warning and silently falls back to the old cached bundle. Register every SCSS file (including `_partial.scss` tokens) as a separate entry in `web.assets_backend`. Put tokens first; Odoo concatenates bundle files so SCSS variables/mixins from the first file are visible to every later file.
|
||||
9. **SQL constraints & indexes**: Odoo 19 dropped `_sql_constraints = [(name, def, msg), ...]` and the `init()`/raw-SQL pattern. Both still parse but only emit a warning and are silently ignored. Use declarative class attributes instead:
|
||||
```python
|
||||
_check_qty_positive = models.Constraint('CHECK (qty > 0)', 'Quantity must be positive.')
|
||||
_user_time_idx = models.Index('(user_id, event_time DESC)')
|
||||
```
|
||||
The attribute name after the leading underscore becomes the SQL object name suffix (`{table}_{suffix}`). `models.Index` accepts `DESC`, `WHERE` predicates, and `USING btree (...)`. Sources: `odoo/orm/model_classes.py` (warns at registry build), `odoo/orm/table_objects.py` (Constraint + Index classes).
|
||||
10. **`res.users._login` is an instance method in Odoo 19**, not a classmethod as in earlier versions. Signature is `def _login(self, credential, user_agent_env)` — there is no `db` parameter. Override it like any normal instance method (`super()._login(credential, user_agent_env)`). When called via `authenticate()` on an empty recordset, `self` carries the right env. Older recipes that build a separate `api.Environment` from `odoo.modules.registry.Registry(db)` no longer apply. Source: `odoo/addons/base/models/res_users.py:760`.
|
||||
11. **Inherited `ir.ui.view` records cannot have `groups`/`group_ids` on the record itself.** Odoo 19 raises `ParseError: Inherited view cannot have 'groups' defined on the record. Use 'groups' attributes inside the view definition` at install time. Move the gate to the inner XML nodes — every `<button>`, `<page>`, `<field>`, `<xpath>`, `<group>` etc. supports a `groups="base.group_system"` attribute. For an inherited form with a smart button + admin tab, put `groups=` on the button and the page individually; leave the `<record model="ir.ui.view">` clean.
|
||||
12. **`mail.template` QWeb/inline_template `ctx` IS `self.env.context`** — not a nested dict you can pass. `MailRenderMixin._render_eval_context()` sets `ctx = self.env.context`, so `ctx.get('foo')` in subject/body resolves to `env.context.get('foo')`. To pass dynamic data to a template, spread keys directly into the context: `tmpl.with_context(**my_data).send_mail(res_id, ...)`. Calling `tmpl.with_context(ctx=my_data)` puts the dict at `env.context['ctx']`, and the template's `ctx.get('foo')` becomes `env.context.get('foo')` → `None` (looks like a silent rendering bug — subject ends up blank).
|
||||
13. **`ir.cron` dropped `numbercall`** in Odoo 19. Old recipes set `<field name="numbercall">-1</field>` for "run forever"; that now raises `ValueError: Invalid field 'numbercall' in 'ir.cron'` at install time. Just omit the field — recurring crons keep running as long as `active=True`. Source: `odoo/addons/base/models/ir_cron.py` field list.
|
||||
14. **`cr.commit()` / `cr.rollback()` raise AssertionError inside `TransactionCase`** — they are NOT silent no-ops in Odoo 19. The test cursor explicitly refuses both ("Cannot commit or rollback a cursor from inside a test, this will lead to a broken cursor when trying to rollback the test. Please rollback to a specific savepoint instead..."). For cron/worker code that needs per-row isolation so one bad row doesn't roll back the whole batch, use `with self.env.cr.savepoint(): ...` inside the loop instead of `cr.commit()`. Savepoints work in both prod (under the outer cron transaction) and tests (under the outer test transaction). The cron transaction commits the whole batch when the method returns; in tests everything rolls back cleanly. Source: `odoo/sql_db.py::TestCursor.commit` and `Cursor.savepoint()`.
|
||||
|
||||
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
|
||||
|
||||
16. **Renaming a module's technical name needs a DB rename, not just a folder rename.** The technical name is baked into the database: `ir_module_module.name`, every external ID in `ir_model_data.module`, each view's `ir_ui_view.key` prefix, and the `ir_module_module_dependency.name` rows of every module that depends on it. Rename only the folder + in-code references and Odoo treats the new name as a fresh uninstalled module — installing it **duplicates** groups/templates/menus and **orphans** all existing data. On every DB that already has it installed, run an in-place SQL rename (the 4 tables above) **before** `-u <newname>`; a fresh DB needs nothing. Reference script + full rationale: [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql) (written for the `fusion_authorizer_portal` → `fusion_portal` rename). Also update cross-module `depends`, `inherit_id="<old>.view"`, `t-call`, `env.ref('<old>.xmlid')`, asset paths (`<old>/static/...`), and `from odoo.addons.<old>... import`.
|
||||
|
||||
## Card Styling — Copy Odoo's Kanban Pattern
|
||||
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
||||
@@ -75,14 +94,40 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
- Canadian English for all user-facing text
|
||||
- Currency: `$` sign with Monetary fields + currency_id
|
||||
|
||||
## Cursor-Managed Modules
|
||||
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||
## Module-Specific Notes
|
||||
- **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`).
|
||||
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.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_portal`.
|
||||
|
||||
## Workflow
|
||||
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
- Local URL: http://localhost:8069
|
||||
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
- Local URL: http://localhost:8082
|
||||
- **Running module tests requires ephemeral ports.** The dev container's main Odoo process holds 8069 and 8072; a `docker exec ... odoo --test-enable` will die with `Address already in use` unless you also pass `--http-port=0 --gevent-port=0`. This is because Odoo 19 forces `http_spawn()` when `--test-enable` is set, even when `--no-http` is passed. Canonical test invocation:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /<module> \
|
||||
-u <module> --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
- **`fusion_centralize_billing` tests run on odoo-trial (VM 316).** Local dev is Community and cannot install this module. Use `bash scripts/fcb_test_on_trial.sh` from the repo root. The script uses `--http-port 8070` to avoid the port 8069 conflict with the live odoo-trial-app container. Pass = `FCB_EXIT=0`. Takes ~1-2 min.
|
||||
- **Python deps not bundled with `odoo:19` image:** `user_agents` (used by `fusion_login_audit`), and likely others. Install ephemerally with `docker exec -u 0 odoo-modsdev-app pip install <pkg> --break-system-packages`. The install is LOST when the container is recreated (e.g. `docker compose up -d` after a compose edit). When this happens, the symptom is `ModuleNotFoundError` deep in the auth or report code. Re-run the pip install. A persistent fix would be a custom Dockerfile or a startup hook on the compose service — not done yet.
|
||||
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
||||
|
||||
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
|
||||
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
|
||||
Before starting unfamiliar work, check Supabase for context:
|
||||
```bash
|
||||
@@ -92,3 +137,98 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
|
||||
- `fusionapps.issues` — known issues and fixes
|
||||
- `fusionapps.code_snippets` — reference code
|
||||
- `fusionapps.quick_commands` — deployment and admin commands
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
'website',
|
||||
'mail',
|
||||
'fusion_claims',
|
||||
'fusion_authorizer_portal',
|
||||
'fusion_portal',
|
||||
],
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
|
||||
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.
|
||||
2801
docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
Normal file
2801
docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
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,864 @@
|
||||
# Fusion Clock — Province-Aware Automatic Unpaid Break Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the unpaid meal break deduct automatically from worked hours on every path (portal, kiosk, NFC, cron, **and manual backend entry**), using a 2-tier per-province rule table (Ontario: 5h→30min, 10h→+30min), with no duplicated logic.
|
||||
|
||||
**Architecture:** A new `fusion.clock.break.rule` table holds the per-province thresholds. `hr.employee._get_fclk_break_rule()` resolves an employee's rule from its company's province (global default fallback). `hr.attendance.x_fclk_break_minutes` becomes a single stored **computed** field — `statutory_break(worked_hours) + Σ penalty_minutes` — that recomputes on every save and replaces the four scattered write sites (controller `_apply_break_deduction` ×3 call sites, the auto-clock-out cron, and the penalty code's manual write).
|
||||
|
||||
**Tech Stack:** Odoo 19, Python, QWeb/XML views, Odoo test framework (`TransactionCase`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Dev environment & sync (READ FIRST — applies to every task)
|
||||
|
||||
**Two working copies (per project memory `feedback_dual_path_fusion_clock`):**
|
||||
- **Git/source tree (edit + commit here):** `K:\Github\Odoo-Modules\fusion_clock`
|
||||
- **Docker/active tree (what the container loads):** `K:\Github\odoo-modsdev\addons\fusion_clock`
|
||||
|
||||
Edit in the **git tree**, then **mirror to the Docker tree before every test run**:
|
||||
|
||||
```powershell
|
||||
robocopy "K:\Github\Odoo-Modules\fusion_clock" "K:\Github\odoo-modsdev\addons\fusion_clock" /MIR /XD ".git" "__pycache__" /XF "*.pyc" /NFL /NDL /NJH /NJS; if ($LASTEXITCODE -lt 8) { "sync ok" } else { "sync FAILED" }
|
||||
```
|
||||
(robocopy exit codes < 8 = success.) **Preflight:** if `K:\Github\odoo-modsdev\addons\fusion_clock` does not exist, the dual-tree setup changed — STOP and confirm the active copy with the user before continuing.
|
||||
|
||||
**Container/DB:** `odoo-modsdev-app` / db `modsdev` (per memory `reference_docker_env_names`).
|
||||
|
||||
**Canonical commands** (note the ephemeral ports — `--test-enable` forces `http_spawn()` so 8069/8072 collide without them; per repo CLAUDE.md):
|
||||
|
||||
- Run this module's tests:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
|
||||
```
|
||||
- Plain upgrade (no tests):
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -50
|
||||
```
|
||||
- Pyflakes a changed Python file (catches undefined names instantly):
|
||||
```bash
|
||||
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/<relpath>.py
|
||||
```
|
||||
|
||||
**Commit:** only from the git tree (`git -C "K:/Github/Odoo-Modules" ...`). Per memory `feedback_always_push_to_main`, push after each commit on `main`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Created:**
|
||||
- `fusion_clock/models/clock_break_rule.py` — the `fusion.clock.break.rule` model + tier engine + constraints.
|
||||
- `fusion_clock/data/clock_break_rule_data.xml` — seed Ontario rule (`is_default`).
|
||||
- `fusion_clock/views/clock_break_rule_views.xml` — list/form/action for the rule.
|
||||
- `fusion_clock/migrations/19.0.4.1.0/post-migrate.py` — drop retired param + recompute break.
|
||||
- `fusion_clock/tests/test_break_rules.py` — all new tests.
|
||||
|
||||
**Modified:**
|
||||
- `fusion_clock/models/__init__.py` — import the new model.
|
||||
- `fusion_clock/models/hr_employee.py` — add `_get_fclk_break_rule()`.
|
||||
- `fusion_clock/models/hr_attendance.py` — `x_fclk_break_minutes` → stored compute; drop cron break-write.
|
||||
- `fusion_clock/controllers/clock_api.py` — delete `_apply_break_deduction`, its clock-out call, and the penalty break-write.
|
||||
- `fusion_clock/controllers/clock_kiosk.py` — delete the `_apply_break_deduction` call.
|
||||
- `fusion_clock/controllers/clock_nfc_kiosk.py` — delete the `_apply_break_deduction` call.
|
||||
- `fusion_clock/models/res_config_settings.py` — remove `fclk_break_threshold_hours`.
|
||||
- `fusion_clock/views/res_config_settings_views.xml` — remove threshold row; relabel default-break as scheduling-only; point to Break Rules.
|
||||
- `fusion_clock/data/ir_config_parameter_data.xml` — remove the `break_threshold_hours` seed record.
|
||||
- `fusion_clock/security/ir.model.access.csv` — manager access for the new model.
|
||||
- `fusion_clock/views/clock_menus.xml` — "Break Rules" config menu.
|
||||
- `fusion_clock/__manifest__.py` — version bump + new data/view files.
|
||||
- `fusion_clock/tests/__init__.py` — import the new test module.
|
||||
- `fusion_clock/tests/test_settings.py` — assert the retired field is gone.
|
||||
- `fusion_clock/CLAUDE.md` — model map, settings keys, break gotcha (Task 5).
|
||||
|
||||
**Behaviour-change note (intentional, approved by spec §4.3):** today a *late-in* penalty written at clock-in (e.g. +15) is silently swallowed at clock-out because `_apply_break_deduction` does `max(break, current)`. The new compute makes **all** penalty minutes strictly additive (`statutory + Σ penalties`), so a late-in penalty on a long shift is no longer lost. Net hours for such shifts will be correctly lower than before.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: New model `fusion.clock.break.rule`
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_clock/models/clock_break_rule.py`
|
||||
- Create: `fusion_clock/data/clock_break_rule_data.xml`
|
||||
- Create: `fusion_clock/views/clock_break_rule_views.xml`
|
||||
- Create: `fusion_clock/tests/test_break_rules.py`
|
||||
- Modify: `fusion_clock/models/__init__.py`
|
||||
- Modify: `fusion_clock/tests/__init__.py`
|
||||
- Modify: `fusion_clock/security/ir.model.access.csv`
|
||||
- Modify: `fusion_clock/views/clock_menus.xml`
|
||||
- Modify: `fusion_clock/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — create `fusion_clock/tests/test_break_rules.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from odoo.tests import tagged, TransactionCase
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestBreakRules(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True')
|
||||
cls.Rule = cls.env['fusion.clock.break.rule']
|
||||
cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1)
|
||||
cls.employee = cls.env['hr.employee'].create({'name': 'FCLK Break Test'})
|
||||
|
||||
def _mk_att(self, hours):
|
||||
check_in = datetime(2026, 1, 5, 9, 0, 0)
|
||||
return self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': check_in,
|
||||
'check_out': check_in + timedelta(hours=hours),
|
||||
})
|
||||
|
||||
# ---- Task 1: tier engine + constraints ----
|
||||
def test_break_minutes_for_tiers(self):
|
||||
rule = self.Rule.create({
|
||||
'name': 'Tier Test', 'is_default': False,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
self.assertEqual(rule.break_minutes_for(4.99), 0.0)
|
||||
self.assertEqual(rule.break_minutes_for(5.0), 30.0)
|
||||
self.assertEqual(rule.break_minutes_for(9.99), 30.0)
|
||||
self.assertEqual(rule.break_minutes_for(10.0), 60.0)
|
||||
self.assertEqual(rule.break_minutes_for(12.0), 60.0)
|
||||
|
||||
def test_second_tier_must_exceed_first(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.Rule.create({
|
||||
'name': 'Bad', 'is_default': False,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 5.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
|
||||
def test_single_default_enforced(self):
|
||||
self.assertTrue(self.default_rule, "seed default rule must exist")
|
||||
with self.assertRaises(ValidationError):
|
||||
self.Rule.create({
|
||||
'name': 'Another Default', 'is_default': True, 'active': True,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
```
|
||||
|
||||
Append the import to `fusion_clock/tests/__init__.py` (add the line if not already present):
|
||||
|
||||
```python
|
||||
from . import test_break_rules
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the model** — `fusion_clock/models/clock_break_rule.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class FusionClockBreakRule(models.Model):
|
||||
_name = 'fusion.clock.break.rule'
|
||||
_description = 'Statutory Break Rule'
|
||||
_order = 'sequence, name'
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
country_id = fields.Many2one('res.country', string='Country')
|
||||
state_id = fields.Many2one(
|
||||
'res.country.state',
|
||||
string='Province / State',
|
||||
help="Employees whose company is in this province use this rule.",
|
||||
)
|
||||
is_default = fields.Boolean(
|
||||
string='Default Rule',
|
||||
help="Used when an employee's company province matches no other rule. "
|
||||
"Only one active rule may be the default.",
|
||||
)
|
||||
break1_after_hours = fields.Float(
|
||||
string='First Break After (h)', default=5.0,
|
||||
help="Worked hours at or above this trigger the first unpaid break.",
|
||||
)
|
||||
break1_minutes = fields.Float(
|
||||
string='First Break (min)', default=30.0,
|
||||
help="Length of the first unpaid break. 0 disables it.",
|
||||
)
|
||||
break2_after_hours = fields.Float(
|
||||
string='Second Break After (h)', default=10.0,
|
||||
help="Worked hours at or above this add the second unpaid break.",
|
||||
)
|
||||
break2_minutes = fields.Float(
|
||||
string='Second Break (min)', default=30.0,
|
||||
help="Length of the second unpaid break. 0 disables it.",
|
||||
)
|
||||
sequence = fields.Integer(default=10)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
def break_minutes_for(self, worked_hours):
|
||||
"""Total statutory unpaid break (minutes) for the given worked hours.
|
||||
|
||||
Tiers are inclusive (``>=``): a break applies when worked hours are
|
||||
equal to or greater than the threshold. The second tier adds on top of
|
||||
the first.
|
||||
"""
|
||||
self.ensure_one()
|
||||
worked = worked_hours or 0.0
|
||||
total = 0.0
|
||||
if self.break1_minutes and worked >= self.break1_after_hours:
|
||||
total += self.break1_minutes
|
||||
if self.break2_minutes and worked >= self.break2_after_hours:
|
||||
total += self.break2_minutes
|
||||
return total
|
||||
|
||||
@api.constrains('break1_after_hours', 'break1_minutes',
|
||||
'break2_after_hours', 'break2_minutes')
|
||||
def _check_tiers(self):
|
||||
for rule in self:
|
||||
if min(rule.break1_after_hours, rule.break1_minutes,
|
||||
rule.break2_after_hours, rule.break2_minutes) < 0:
|
||||
raise ValidationError(_("Break hours and minutes cannot be negative."))
|
||||
if rule.break2_minutes and rule.break2_after_hours <= rule.break1_after_hours:
|
||||
raise ValidationError(_(
|
||||
"The second break threshold (%(n2)s h) must be greater than "
|
||||
"the first (%(n1)s h).",
|
||||
n2=rule.break2_after_hours, n1=rule.break1_after_hours))
|
||||
|
||||
@api.constrains('is_default', 'active')
|
||||
def _check_single_default(self):
|
||||
for rule in self:
|
||||
if rule.is_default and rule.active:
|
||||
dupe = self.search([
|
||||
('is_default', '=', True), ('active', '=', True),
|
||||
('id', '!=', rule.id),
|
||||
], limit=1)
|
||||
if dupe:
|
||||
raise ValidationError(_(
|
||||
"Only one active break rule can be the default "
|
||||
"(currently: %s).", dupe.name))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register the model** — add to `fusion_clock/models/__init__.py` after the `clock_penalty` import:
|
||||
|
||||
```python
|
||||
from . import clock_break_rule
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Grant access** — append one row to `fusion_clock/security/ir.model.access.csv`:
|
||||
|
||||
```
|
||||
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
|
||||
```
|
||||
|
||||
(No user/portal grant needed — the resolver reads the table via `sudo()`.)
|
||||
|
||||
- [ ] **Step 5: Seed the Ontario rule** — create `fusion_clock/data/clock_break_rule_data.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="break_rule_ontario" model="fusion.clock.break.rule">
|
||||
<field name="name">Ontario</field>
|
||||
<field name="country_id" ref="base.ca"/>
|
||||
<field name="state_id" ref="base.state_ca_on"/>
|
||||
<field name="is_default" eval="True"/>
|
||||
<field name="break1_after_hours">5.0</field>
|
||||
<field name="break1_minutes">30.0</field>
|
||||
<field name="break2_after_hours">10.0</field>
|
||||
<field name="break2_minutes">30.0</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Views + action** — create `fusion_clock/views/clock_break_rule_views.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.break.rule.list</field>
|
||||
<field name="model">fusion.clock.break.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="state_id"/>
|
||||
<field name="country_id" optional="hide"/>
|
||||
<field name="break1_after_hours" widget="float_time"/>
|
||||
<field name="break1_minutes"/>
|
||||
<field name="break2_after_hours" widget="float_time"/>
|
||||
<field name="break2_minutes"/>
|
||||
<field name="is_default"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.break.rule.form</field>
|
||||
<field name="model">fusion.clock.break.rule</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<sheet>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
|
||||
invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<h1><field name="name" placeholder="e.g. Ontario"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Jurisdiction">
|
||||
<field name="country_id"/>
|
||||
<field name="state_id"
|
||||
domain="[('country_id', '=', country_id)]"/>
|
||||
<field name="is_default"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group string="Unpaid Break Tiers">
|
||||
<label for="break1_after_hours" string="First break after"/>
|
||||
<div class="o_row">
|
||||
<field name="break1_after_hours" widget="float_time"/>
|
||||
<span>h →</span>
|
||||
<field name="break1_minutes"/>
|
||||
<span>min</span>
|
||||
</div>
|
||||
<label for="break2_after_hours" string="Second break after"/>
|
||||
<div class="o_row">
|
||||
<field name="break2_after_hours" widget="float_time"/>
|
||||
<span>h →</span>
|
||||
<field name="break2_minutes"/>
|
||||
<span>min</span>
|
||||
</div>
|
||||
</group>
|
||||
</group>
|
||||
<p class="text-muted">
|
||||
Breaks are unpaid and deducted from actual worked hours. A tier with
|
||||
0 minutes is disabled. Triggers are inclusive — a break applies when
|
||||
worked hours are equal to or above the threshold.
|
||||
</p>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
|
||||
<field name="name">Break Rules</field>
|
||||
<field name="res_model">fusion.clock.break.rule</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'active_test': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
|
||||
<p>Define unpaid meal-break thresholds per province/country. Employees inherit
|
||||
the rule matching their company's province, or the default rule.</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Add the menu** — in `fusion_clock/views/clock_menus.xml`, insert after the `menu_fusion_clock_locations_config` menuitem (the Locations config item) and before `menu_fusion_clock_nfc_enrollment`:
|
||||
|
||||
```xml
|
||||
<menuitem id="menu_fusion_clock_break_rules"
|
||||
name="Break Rules"
|
||||
parent="menu_fusion_clock_config"
|
||||
action="action_fusion_clock_break_rule"
|
||||
sequence="25"
|
||||
groups="group_fusion_clock_manager"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Wire the manifest** — in `fusion_clock/__manifest__.py`:
|
||||
|
||||
**Do NOT bump the version yet** — it stays `19.0.4.0.3` until Task 4, so the
|
||||
`19.0.4.1.0` migration actually fires in dev (Odoo only runs a version's migration
|
||||
when the installed version is *lower* than the manifest version).
|
||||
|
||||
Add the seed data file after `'data/ir_config_parameter_data.xml',`:
|
||||
```python
|
||||
'data/clock_break_rule_data.xml',
|
||||
```
|
||||
Add the view file after `'views/clock_schedule_views.xml',`:
|
||||
```python
|
||||
'views/clock_break_rule_views.xml',
|
||||
```
|
||||
(Data and view files reload on every `-u` regardless of the version number, so the
|
||||
new model/menu install without a bump. No assets change in this plan, so the bump's
|
||||
only purpose is the migration trigger — deferred to Task 4.)
|
||||
|
||||
- [ ] **Step 9: Sync, upgrade, run tests**
|
||||
|
||||
Sync (see preamble), then:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
|
||||
```
|
||||
Expected: module upgrades cleanly; `test_break_minutes_for_tiers`, `test_second_tier_must_exceed_first`, `test_single_default_enforced` PASS. (Other tests in the class will error until Tasks 2–3 add their dependencies — that's expected if you scoped the run; otherwise the not-yet-added methods simply don't exist yet.)
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/clock_break_rule.py fusion_clock/models/__init__.py fusion_clock/data/clock_break_rule_data.xml fusion_clock/views/clock_break_rule_views.xml fusion_clock/views/clock_menus.xml fusion_clock/security/ir.model.access.csv fusion_clock/__manifest__.py fusion_clock/tests/test_break_rules.py fusion_clock/tests/__init__.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): add fusion.clock.break.rule per-province break table" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Jurisdiction resolver on `hr.employee`
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/hr_employee.py`
|
||||
- Modify: `fusion_clock/tests/test_break_rules.py`
|
||||
|
||||
- [ ] **Step 1: Add the resolver tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
|
||||
|
||||
```python
|
||||
# ---- Task 2: jurisdiction resolver ----
|
||||
def test_resolver_matches_company_province(self):
|
||||
bc = self.env.ref('base.state_ca_bc')
|
||||
bc_rule = self.Rule.create({
|
||||
'name': 'British Columbia', 'state_id': bc.id, 'is_default': False,
|
||||
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||
})
|
||||
self.employee.company_id.state_id = bc.id
|
||||
self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule)
|
||||
|
||||
def test_resolver_falls_back_to_default(self):
|
||||
self.assertTrue(self.default_rule, "seed default rule must exist")
|
||||
alberta = self.env.ref('base.state_ca_ab') # no rule for AB
|
||||
self.employee.company_id.state_id = alberta.id
|
||||
self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Sync, then:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
Expected: FAIL — `AttributeError: 'hr.employee' object has no attribute '_get_fclk_break_rule'`.
|
||||
|
||||
- [ ] **Step 3: Implement the resolver** — in `fusion_clock/models/hr_employee.py`, add this method immediately after the `_get_fclk_break_minutes` method (after its `return float(...)` block, before `_get_fclk_scheduled_times`):
|
||||
|
||||
```python
|
||||
def _get_fclk_break_rule(self):
|
||||
"""Return the statutory break rule for this employee.
|
||||
|
||||
Resolution: company's province → matching rule; else the global default
|
||||
rule; else an empty recordset (caller treats as zero break). Read via
|
||||
sudo so the portal net-hours compute can resolve it without a direct ACL.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Rule = self.env['fusion.clock.break.rule'].sudo()
|
||||
rule = Rule.browse()
|
||||
state = self.company_id.state_id
|
||||
if state:
|
||||
rule = Rule.search([('state_id', '=', state.id)], limit=1)
|
||||
if not rule:
|
||||
rule = Rule.search([('is_default', '=', True)], limit=1)
|
||||
return rule
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify they pass**
|
||||
|
||||
Sync, then re-run the Step 2 command. Expected: `test_resolver_matches_company_province` and `test_resolver_falls_back_to_default` PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_employee.py fusion_clock/tests/test_break_rules.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): resolve employee break rule from company province" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `x_fclk_break_minutes` → stored compute; remove all manual writes
|
||||
|
||||
This task is atomic: once the field is computed (no inverse), any remaining `write({'x_fclk_break_minutes': ...})` raises at runtime, so the field conversion and the removal of all four write sites must land together.
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/hr_attendance.py`
|
||||
- Modify: `fusion_clock/controllers/clock_api.py`
|
||||
- Modify: `fusion_clock/controllers/clock_kiosk.py`
|
||||
- Modify: `fusion_clock/controllers/clock_nfc_kiosk.py`
|
||||
- Modify: `fusion_clock/tests/test_break_rules.py`
|
||||
|
||||
- [ ] **Step 1: Add the attendance tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
|
||||
|
||||
```python
|
||||
# ---- Task 3: automatic deduction on every path ----
|
||||
def test_manual_attendance_applies_statutory_break(self):
|
||||
att = self._mk_att(6) # 6h >= 5 -> first break
|
||||
self.assertEqual(att.x_fclk_break_minutes, 30.0)
|
||||
self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2)
|
||||
|
||||
def test_manual_edit_extends_break(self):
|
||||
att = self._mk_att(6)
|
||||
self.assertEqual(att.x_fclk_break_minutes, 30.0)
|
||||
att.check_out = att.check_in + timedelta(hours=10) # now >= 10
|
||||
self.assertEqual(att.x_fclk_break_minutes, 60.0)
|
||||
self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2)
|
||||
|
||||
def test_under_first_threshold_no_break(self):
|
||||
att = self._mk_att(4) # 4h < 5 -> nothing
|
||||
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||
self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2)
|
||||
|
||||
def test_penalty_minutes_are_additive(self):
|
||||
att = self._mk_att(6) # statutory 30
|
||||
self.env['fusion.clock.penalty'].create({
|
||||
'attendance_id': att.id,
|
||||
'employee_id': self.employee.id,
|
||||
'penalty_type': 'early_out',
|
||||
'penalty_minutes': 15.0,
|
||||
'date': att.check_in.date(),
|
||||
})
|
||||
self.assertEqual(att.x_fclk_break_minutes, 45.0)
|
||||
|
||||
def test_master_toggle_off_zero_statutory(self):
|
||||
self.ICP.set_param('fusion_clock.auto_deduct_break', 'False')
|
||||
att = self._mk_att(6)
|
||||
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||
|
||||
def test_open_attendance_zero_break(self):
|
||||
att = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': datetime(2026, 1, 5, 9, 0, 0),
|
||||
})
|
||||
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify they fail**
|
||||
|
||||
Sync, then run the module tests. Expected: the new tests FAIL — e.g. `test_manual_attendance_applies_statutory_break` asserts 30 but gets 0 (no write override exists yet).
|
||||
|
||||
- [ ] **Step 3: Convert the field to a stored compute** — in `fusion_clock/models/hr_attendance.py`, replace the field definition:
|
||||
|
||||
OLD:
|
||||
```python
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=0.0,
|
||||
tracking=True,
|
||||
help="Break duration in minutes to deduct from worked hours.",
|
||||
)
|
||||
```
|
||||
NEW:
|
||||
```python
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
compute='_compute_fclk_break_minutes',
|
||||
store=True,
|
||||
tracking=True,
|
||||
help="Unpaid break deducted from worked hours: statutory break (per the "
|
||||
"employee's province rule, from actual hours worked) plus any penalty "
|
||||
"minutes. Computed automatically on every save.",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the compute method** — in the same file, insert this method immediately before the `_compute_net_hours` method (just above its `@api.depends('worked_hours', 'x_fclk_break_minutes')` decorator):
|
||||
|
||||
```python
|
||||
@api.depends('worked_hours', 'check_out',
|
||||
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
|
||||
def _compute_fclk_break_minutes(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
|
||||
for att in self:
|
||||
statutory = 0.0
|
||||
if auto and att.check_out and att.employee_id:
|
||||
rule = att.employee_id._get_fclk_break_rule()
|
||||
if rule:
|
||||
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
|
||||
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
|
||||
att.x_fclk_break_minutes = statutory + penalties
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove the cron's break write** — in the same file, inside `_cron_fusion_auto_clock_out`:
|
||||
|
||||
Remove the now-unused threshold read (the line near the top of the method):
|
||||
```python
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
```
|
||||
Remove the two now-unused locals in the per-attendance loop:
|
||||
```python
|
||||
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
||||
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
||||
```
|
||||
Remove the break-write block (the compute now applies the break when `check_out` is set):
|
||||
```python
|
||||
if (att.worked_hours or 0) >= threshold:
|
||||
att.sudo().write(
|
||||
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
|
||||
)
|
||||
```
|
||||
(Leave the surrounding `employee = att.employee_id` and `clock_out_time = effective_deadline` lines intact.)
|
||||
|
||||
- [ ] **Step 6: Delete the controller helper and its call sites** — in `fusion_clock/controllers/clock_api.py`:
|
||||
|
||||
Delete the entire `_apply_break_deduction` method:
|
||||
```python
|
||||
def _apply_break_deduction(self, attendance, employee):
|
||||
"""Apply automatic break deduction if configured."""
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
|
||||
return
|
||||
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
worked = attendance.worked_hours or 0.0
|
||||
|
||||
if worked >= threshold:
|
||||
local_date = get_local_today(request.env, employee)
|
||||
if attendance.check_in:
|
||||
tz_name = (
|
||||
employee.resource_id.tz
|
||||
or (employee.user_id.partner_id.tz if employee.user_id else False)
|
||||
or employee.company_id.partner_id.tz
|
||||
or 'UTC'
|
||||
)
|
||||
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
|
||||
break_min = employee._get_fclk_break_minutes(local_date)
|
||||
current = attendance.x_fclk_break_minutes or 0.0
|
||||
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
||||
new_val = max(break_min, current)
|
||||
if new_val != current:
|
||||
attendance.sudo().write({'x_fclk_break_minutes': new_val})
|
||||
|
||||
```
|
||||
Delete its clock-out call (in the CLOCK OUT branch):
|
||||
```python
|
||||
# Apply break deduction
|
||||
self._apply_break_deduction(attendance, employee)
|
||||
|
||||
```
|
||||
Delete the penalty break-write in `_check_and_create_penalty` (keep the penalty-record `create` above it and the activity log below it):
|
||||
```python
|
||||
# Deduct penalty minutes from attendance (adds to break deduction)
|
||||
current_break = attendance.x_fclk_break_minutes or 0.0
|
||||
attendance.sudo().write({
|
||||
'x_fclk_break_minutes': current_break + deduction,
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Delete the kiosk call sites**
|
||||
|
||||
In `fusion_clock/controllers/clock_kiosk.py`, delete the line:
|
||||
```python
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
```
|
||||
In `fusion_clock/controllers/clock_nfc_kiosk.py`, delete the line:
|
||||
```python
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Pyflakes the touched controllers/models** (catches a missed `pytz`/var reference instantly)
|
||||
|
||||
```bash
|
||||
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/controllers/clock_api.py /mnt/extra-addons/fusion_clock/controllers/clock_kiosk.py /mnt/extra-addons/fusion_clock/controllers/clock_nfc_kiosk.py /mnt/extra-addons/fusion_clock/models/hr_attendance.py
|
||||
```
|
||||
Expected: no output (clean). If it flags `pytz` as unused in `hr_attendance.py`, that's fine only if no other code uses it — verify before removing the import (the absence/overtime crons still use `pytz`, so leave the import).
|
||||
|
||||
- [ ] **Step 9: Run to verify all Task 3 tests pass**
|
||||
|
||||
Sync, then run the module tests. Expected: all `test_manual_*`, `test_under_first_threshold_no_break`, `test_penalty_minutes_are_additive`, `test_master_toggle_off_zero_statutory`, `test_open_attendance_zero_break` PASS, and the existing NFC/kiosk/dashboard tests still PASS.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_attendance.py fusion_clock/controllers/clock_api.py fusion_clock/controllers/clock_kiosk.py fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_break_rules.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): auto-apply statutory break via one stored compute" -m "x_fclk_break_minutes is now statutory(worked_hours) + penalties, recomputed on every path including manual backend entry. Removes the four duplicated write sites (controller _apply_break_deduction + 3 call sites, auto-clock-out cron, penalty write)." -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Retire `break_threshold_hours`; clean settings & migrate
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/res_config_settings.py`
|
||||
- Modify: `fusion_clock/views/res_config_settings_views.xml`
|
||||
- Modify: `fusion_clock/data/ir_config_parameter_data.xml`
|
||||
- Create: `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`
|
||||
- Modify: `fusion_clock/tests/test_settings.py`
|
||||
|
||||
- [ ] **Step 1: Add the dead-setting assertion** — in `fusion_clock/tests/test_settings.py`, add one line to `test_dead_settings_removed`:
|
||||
|
||||
```python
|
||||
self.assertNotIn('fclk_break_threshold_hours', fields)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Remove the settings field** — in `fusion_clock/models/res_config_settings.py`, delete:
|
||||
|
||||
```python
|
||||
fclk_break_threshold_hours = fields.Float(
|
||||
string='Break Threshold (hours)',
|
||||
config_parameter='fusion_clock.break_threshold_hours',
|
||||
default=4.0,
|
||||
help="Only deduct break if shift is longer than this many hours.",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Fix the settings view** — in `fusion_clock/views/res_config_settings_views.xml`, replace the whole `fclk_auto_break` setting block:
|
||||
|
||||
OLD:
|
||||
```xml
|
||||
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
||||
help="Automatically deduct unpaid break from worked hours on clock-out.">
|
||||
<field name="fclk_auto_deduct_break"/>
|
||||
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_default_break_minutes"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_break_threshold_hours" widget="float_time"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
```
|
||||
NEW:
|
||||
```xml
|
||||
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
||||
help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
|
||||
<field name="fclk_auto_deduct_break"/>
|
||||
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_default_break_minutes"/>
|
||||
</div>
|
||||
<div class="text-muted small mt4">
|
||||
Used as the default break when building shifts/schedules
|
||||
(planned hours). Actual deductions follow the province Break Rules.
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Remove the seed param** — in `fusion_clock/data/ir_config_parameter_data.xml`, delete:
|
||||
|
||||
```xml
|
||||
<record id="config_break_threshold_hours" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.break_threshold_hours</field>
|
||||
<field name="value">4.0</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Bump the version + create the migration**
|
||||
|
||||
First bump the manifest so the migration fires (installed `19.0.4.0.3` < manifest
|
||||
`19.0.4.1.0`). In `fusion_clock/__manifest__.py`:
|
||||
```python
|
||||
'version': '19.0.4.1.0',
|
||||
```
|
||||
Then create `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""Retire the single-threshold break param (superseded by per-rule
|
||||
break1_after_hours), and force-recompute the now-computed break field so
|
||||
existing closed attendances reflect the province rule + their penalties."""
|
||||
cr.execute(
|
||||
"DELETE FROM ir_config_parameter WHERE key = %s",
|
||||
('fusion_clock.break_threshold_hours',),
|
||||
)
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
Attendance = env['hr.attendance']
|
||||
field = Attendance._fields['x_fclk_break_minutes']
|
||||
closed = Attendance.search([('check_out', '!=', False)])
|
||||
if closed:
|
||||
env.add_to_compute(field, closed)
|
||||
closed.flush_recordset(['x_fclk_break_minutes'])
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Sync, upgrade, run tests**
|
||||
|
||||
Sync, then run the module tests. Expected: module upgrades cleanly and the `19.0.4.1.0` migration executes (installed `19.0.4.0.3` < manifest `19.0.4.1.0`; modsdev shows the INFO line, nexa/entech run `log_level=warn`), `test_dead_settings_removed` PASS, full `fusion_clock` suite green.
|
||||
|
||||
- [ ] **Step 7: Verify the param is gone and historical rows recomputed** (sanity)
|
||||
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo shell -d modsdev --no-http 2>/dev/null <<'PY'
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
print('threshold param:', ICP.get_param('fusion_clock.break_threshold_hours', 'ABSENT'))
|
||||
print('default rule:', env['fusion.clock.break.rule'].search([('is_default','=',True)]).mapped('name'))
|
||||
PY
|
||||
```
|
||||
Expected: `threshold param: ABSENT`; `default rule: ['Ontario']`.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/migrations/19.0.4.1.0/post-migrate.py fusion_clock/tests/test_settings.py fusion_clock/__manifest__.py
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "refactor(fusion_clock): retire break_threshold_hours; breaks now driven by Break Rules" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Full verification, docs, manual smoke
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Full test run (whole module)**
|
||||
|
||||
Sync, then:
|
||||
```bash
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -120
|
||||
```
|
||||
Expected: all `fusion_clock` tests PASS, zero tracebacks. If anything fails, fix before continuing.
|
||||
|
||||
- [ ] **Step 2: Manual smoke (manager UI)** at http://localhost:8082
|
||||
|
||||
- Configuration → **Break Rules** exists; the **Ontario** row shows 5h→30 / 10h→30, Default ticked.
|
||||
- Attendances → create a manual attendance, check-in 09:00 check-out 15:00 (6h) → **Break = 30**, Net = 5.5h, with no clock action.
|
||||
- Edit that record's check-out to 19:00 (10h) → **Break = 60**, Net = 9.0h.
|
||||
- Create a 4h attendance → **Break = 0**.
|
||||
- Settings → the old "Min. Shift" threshold field is gone; the Auto-Deduct Break help points to Break Rules.
|
||||
|
||||
- [ ] **Step 3: Update the module CLAUDE.md** — in `fusion_clock/CLAUDE.md`:
|
||||
|
||||
- §4 Model Map: add a row — `fusion.clock.break.rule | models/clock_break_rule.py | Per-province statutory unpaid-break thresholds (2-tier).`
|
||||
- §5 Clocking Flow: note that the break deduction is no longer a controller step — `x_fclk_break_minutes` is a stored compute (`statutory(worked_hours) + Σ penalties`) that fires on every path including manual backend entry; resolved rule via `hr.employee._get_fclk_break_rule()` (company province → default).
|
||||
- §11 Settings Keys: remove `fusion_clock.break_threshold_hours`.
|
||||
- §13 Gotchas: add — "Unpaid break is computed, not written: never `write({'x_fclk_break_minutes': ...})`; change the province rule (`fusion.clock.break.rule`) or `auto_deduct_break` instead. Penalty minutes are now strictly additive (the old `max()` that swallowed late-in penalties is gone)."
|
||||
- Bump the version line in §1 to `19.0.4.1.0`.
|
||||
|
||||
- [ ] **Step 4: Commit the docs**
|
||||
|
||||
```bash
|
||||
git -C "K:/Github/Odoo-Modules" add fusion_clock/CLAUDE.md
|
||||
git -C "K:/Github/Odoo-Modules" commit -m "docs(fusion_clock): document province break rules + computed break field" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||
git -C "K:/Github/Odoo-Modules" push
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Report** — summarize what changed, the behaviour-change note (penalties now additive), and that live deployment to entech (`odoo-entech`) is a separate step pending user sign-off.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (performed against the spec)
|
||||
|
||||
**1. Spec coverage**
|
||||
- §4.1 model → Task 1. §4.2 resolver → Task 2. §4.3 stored compute → Task 3. §4.4 removals → Task 3 (writes) + Task 4 (setting/param/view). §4.5 UI/security/data → Task 1 (+ settings view in Task 4). §5 edge cases → tests in Tasks 1 & 3. §6 migration → Task 4. §7 tests → all six+ cases present across Tasks 1–3. §8 rollout → preamble + Task 5. ✓ No gaps.
|
||||
|
||||
**2. Placeholder scan** — every step has full code/commands; no TBD/TODO/"similar to". ✓
|
||||
|
||||
**3. Type/name consistency** — `break_minutes_for`, `_get_fclk_break_rule`, `_compute_fclk_break_minutes`, fields `break1_after_hours/break1_minutes/break2_after_hours/break2_minutes/is_default`, model `fusion.clock.break.rule`, access id `model_fusion_clock_break_rule`, action `action_fusion_clock_break_rule`, menu `menu_fusion_clock_break_rules` — all used identically across tasks. The compute folds `Σ penalty_minutes` (field `penalty_minutes` on `fusion.clock.penalty`, confirmed). ✓
|
||||
@@ -0,0 +1,43 @@
|
||||
# Accessibility Funding-Source Selector — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline) — this is a 3-file change. Steps use `- [ ]` checkboxes.
|
||||
|
||||
**Goal:** Let the rep mark an accessibility assessment's funding source (Private / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other) on the web form, so the generated sale order routes to the correct funding pipeline instead of always defaulting to private pay.
|
||||
|
||||
**Architecture:** The model (`fusion.accessibility.assessment.x_fc_funding_source`) and the SO routing (`_create_draft_sale_order` → `sale_type_map` → `x_fc_sale_type`) already exist (the "2026-04 portal audit fix"). The only gaps: (1) the form has no funding field, (2) the save controller never reads `funding_source` from the POST, (3) `hardship` is missing from the selectable funding sources. The submit JS already serialises every named form field via `FormData`, so no JS change is needed.
|
||||
|
||||
**Tech Stack:** Odoo 19, QWeb portal template, JSON-RPC controller. Module `fusion_portal` (worktree `K:\Github\Odoo-Modules-wt-portal`, branch `feat/assessment-visit`).
|
||||
|
||||
**Verification constraint:** `fusion_portal` depends on Enterprise `knowledge`, so it can NOT be installed on the local Community Docker. Syntax-check with host Python; functional verification is on westin (or a clone): pick "March of Dimes" on a form → the draft SO gets `x_fc_sale_type='march_of_dimes'` and lands in the MOD pipeline.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Hardship to the funding source + route it
|
||||
|
||||
**Files:** Modify `fusion_portal/models/accessibility_assessment.py` (selection ~:71-87, `sale_type_map` ~:771-779)
|
||||
|
||||
- [ ] **Step 1:** Add `('hardship', 'Hardship Funding')` to the `x_fc_funding_source` selection list (after `'wsib'`).
|
||||
- [ ] **Step 2:** Add `'hardship': 'hardship',` to `sale_type_map` in `_create_draft_sale_order` (the target `x_fc_sale_type='hardship'` already exists in `fusion_claims` `sale_order.py:332`).
|
||||
- [ ] **Step 3:** `python -m py_compile fusion_portal/models/accessibility_assessment.py` → no error.
|
||||
- [ ] **Step 4:** Commit.
|
||||
|
||||
### Task 2: Add the funding select to the shared client-info form
|
||||
|
||||
**Files:** Modify `fusion_portal/views/portal_accessibility_templates.xml` (`accessibility_client_info_section`, ~:366-375)
|
||||
|
||||
- [ ] **Step 1:** Add a new row with a `<select name="funding_source">` (options mirror the model selection; `direct_private` pre-selected so existing private behaviour is unchanged) right after the phone/email row, before the card closes.
|
||||
- [ ] **Step 2:** Validate XML well-formedness (`[xml]` parse).
|
||||
- [ ] **Step 3:** Commit.
|
||||
|
||||
### Task 3: Capture funding_source in the save controller
|
||||
|
||||
**Files:** Modify `fusion_portal/controllers/portal_main.py` (`accessibility_assessment_save` vals, ~:2498-2511)
|
||||
|
||||
- [ ] **Step 1:** Add `'x_fc_funding_source': post.get('funding_source') or 'direct_private',` to the `vals` dict.
|
||||
- [ ] **Step 2:** `python -m pyflakes fusion_portal/controllers/portal_main.py` → no new undefined-name errors.
|
||||
- [ ] **Step 3:** Commit.
|
||||
|
||||
### Task 4: Verify + ship
|
||||
|
||||
- [ ] **Step 1:** Grep confirms `funding_source` flows form → controller → `x_fc_funding_source` → `sale_type_map`.
|
||||
- [ ] **Step 2:** Deploy to westin (backup → scp the 3 files → `-u fusion_portal` → cache-bust → restart) and confirm: open `/my/accessibility/stairlift/straight`, pick "March of Dimes", complete → the new SO shows `x_fc_sale_type = march_of_dimes` and appears in the MOD pipeline.
|
||||
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# NFC Clock Kiosk — Design
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved design — pending implementation plan
|
||||
**Pilot scope:** 1 station per company
|
||||
|
||||
## Problem
|
||||
|
||||
`fusion_clock` already supports shared-device clock-in/out via a PIN kiosk at `/fusion_clock/kiosk`. Shop-floor employees find name search + PIN entry slow, and shared PINs make buddy-punching trivial. The company is rolling out Ubiquiti UniFi Access NFC readers for door entry, so every employee already carries an NFC card. We want a "tap-and-go" kiosk that:
|
||||
|
||||
- Takes ~2 seconds (vs ~10 seconds for name search + PIN)
|
||||
- Reuses the same physical Ubiquiti-issued card the employee uses for doors
|
||||
- Works with gloves, dirty hands, or wet hands (touchscreens fail here)
|
||||
- Captures a silent photo at every tap so managers can spot-check buddy-punching attempts
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Tap-to-clock**: NFC card tap on a wall-mounted Android tablet → attendance state toggles in Odoo within ~1 second of the tap
|
||||
2. **Single-credential**: same card the employee uses for door access also clocks them in
|
||||
3. **Silent photo verification**: front camera snaps a frame on every tap; manager dashboard shows photos for spot-check
|
||||
4. **Self-contained kiosk**: lockable into a single-purpose device, no escape, auto-restart on crash, no Odoo navbar visible
|
||||
5. **Reuses existing fusion_clock backend**: geofencing, penalty rules, activity log, attendance lifecycle — all unchanged
|
||||
6. **One-time setup**: enroll once, then employees never touch a setup flow again
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Multi-station / multi-zone clocking (future — pilot is 1 station per company)
|
||||
- Per-station geolocation (one location per company; tablet is implicitly at the company location)
|
||||
- Offline mode (v1 fails loudly on network loss; offline replay is future work)
|
||||
- Phone-as-credential support (NFC HCE on Android is fragile; iPhone NFC is closed)
|
||||
- QR code alternate credential (deferred to v1.1 if iPhone-only employees push back)
|
||||
- Native Android kiosk app (overkill for a 1-2 station pilot; Web NFC is sufficient)
|
||||
|
||||
## Architecture decision
|
||||
|
||||
**Option B: Separate kiosk page, shared backend.**
|
||||
|
||||
A new route `/fusion_clock/kiosk/nfc` and a new lean template optimized for tap-and-go. The new controller (`controllers/clock_nfc_kiosk.py`) calls into the existing `FusionClockAPI` helpers (`_verify_location`, `_attendance_action_change`, `_log_activity`, `_check_and_create_penalty`, `_apply_break_deduction`) so all geofencing/penalty/activity logic is shared with the PIN kiosk. The existing `/fusion_clock/kiosk` route is untouched.
|
||||
|
||||
**Why not extend the existing kiosk (Option A):** existing PIN kiosk page would get tap-mode JS interleaved with PIN-mode JS, increasing the regression surface for both modes.
|
||||
|
||||
**Why not native Android app (Option C):** maintaining a Kotlin app + Play Console signing/distribution doubles the dev effort for marginal UX gain. Web NFC + Chrome kiosk is production-proven (gyms, warehouses, healthcare check-in).
|
||||
|
||||
## Hardware decision
|
||||
|
||||
**Per company:** 1× Samsung Galaxy Tab Active 5 Pro (10.1") on an official Samsung Pogo charging dock, wall-mounted. Reasoning:
|
||||
|
||||
- Built-in NFC antenna on the back, dead-center
|
||||
- IP68, MIL-STD-810H, drop-resistant (shop-floor durable)
|
||||
- Replaceable battery (avoids battery-swelling failure mode in 24/7-tethered devices)
|
||||
- Knox enables true kiosk lockdown
|
||||
- Pogo dock = magnetic constant power, no cable to yank
|
||||
- 10.1" screen visible from a few feet away (vs 8" on regular Active 5)
|
||||
|
||||
Cards: same Ubiquiti-issued NFC cards employees already carry. Web NFC reads the card's UID via `NDEFReader`'s `serialNumber` field, which works on raw MIFARE access cards even though they have no NDEF data.
|
||||
|
||||
## Data model
|
||||
|
||||
### `hr.employee` — new field
|
||||
- `x_fclk_nfc_card_uid` — `Char`, indexed, unique constraint when not null
|
||||
- Stores card UID as canonical hex (uppercase, colon-separated, MSB first), e.g., `04:A2:B5:62:C1:80`
|
||||
- Editable by HR managers; visible on the employee form in the existing "Clock Settings" section near the existing PIN field
|
||||
|
||||
### `res.company` — new field
|
||||
- `x_fclk_nfc_kiosk_location_id` — `Many2one` to `fusion.clock.location`
|
||||
- Designates which fusion.clock.location is bound to the NFC kiosk for this company
|
||||
- Required when `fusion_clock.enable_nfc_kiosk = True`; the tap endpoint returns `no_location_configured` if it's empty
|
||||
- Editable in the NFC Clock Kiosk settings section (per-company since this is multi-company-aware)
|
||||
|
||||
### `hr.attendance` — new fields
|
||||
- `x_fclk_check_in_photo` — `Binary`, `attachment=True`. Frame captured at clock-in.
|
||||
- `x_fclk_check_out_photo` — `Binary`, `attachment=True`. Frame captured at clock-out.
|
||||
- `x_fclk_clock_source` — extend existing `Selection` field to include `'nfc_kiosk'`.
|
||||
|
||||
### `ir.config_parameter` — new entries
|
||||
- `fusion_clock.enable_nfc_kiosk` — Boolean, default `False`. Master switch.
|
||||
- `fusion_clock.nfc_photo_required` — Boolean, default `True`. If False, photo is best-effort and tap still succeeds without one.
|
||||
- `fusion_clock.nfc_enroll_password` — Char, default empty. Short password the manager types to enter Enroll Mode on the kiosk. If empty, falls back to manager-group membership of the kiosk service user.
|
||||
- `fusion_clock.nfc_kiosk_debug` — Boolean, default `False`. Enables a hidden mock-tap keyboard shortcut for development.
|
||||
|
||||
### `res.config.settings` — new view section
|
||||
"NFC Clock Kiosk" section in the Clock settings page exposing the four `ir.config_parameter` toggles above.
|
||||
|
||||
**No new models.** All data piggybacks on existing `hr.employee`, `hr.attendance`, `fusion.clock.activity.log`.
|
||||
|
||||
## Backend — controller and endpoints
|
||||
|
||||
**New file:** `controllers/clock_nfc_kiosk.py`
|
||||
|
||||
All endpoints under `/fusion_clock/kiosk/nfc/...`. All require `fusion_clock.group_fusion_clock_manager` on the logged-in kiosk service user. All gated on `fusion_clock.enable_nfc_kiosk == 'True'`.
|
||||
|
||||
**Kiosk service user:** an Odoo `res.users` record created per-company specifically for the tablet to log in as. Member of `fusion_clock.group_fusion_clock_manager`. Long random password stored in the tablet's saved-credentials. Distinct from any human user so its session can be revoked independently if the tablet is stolen. Setup is documented in the provisioning script below; no new code creates this user (it's a manual one-time creation in HR Settings).
|
||||
|
||||
### `GET /fusion_clock/kiosk/nfc` — page render
|
||||
- Renders the NFC kiosk QWeb template
|
||||
- Resolves the kiosk's location from `request.env.company.x_fclk_nfc_kiosk_location_id` and passes its name to the template for display ("Clock at: Westin Plant 1")
|
||||
- Returns redirect to `/my` if the kiosk is disabled or the user lacks the manager group
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/tap` — clock toggle
|
||||
- `type='jsonrpc'`, `auth='user'`
|
||||
- Input: `{ card_uid: "04:A2:B5:62:C1:80", photo_b64: "data:image/jpeg;base64,..." (optional) }`
|
||||
- Logic:
|
||||
1. Normalize UID (uppercase, colon-separated, reject malformed input)
|
||||
2. Lookup `hr.employee` by `x_fclk_nfc_card_uid` (sudo). Not found → `{error: "card_unknown", message: "Card not enrolled"}`. Log to `fusion.clock.activity.log` with the unknown UID.
|
||||
3. If `x_fclk_enable_clock` is False → `{error: "clock_disabled"}`
|
||||
4. Resolve location from `request.env.company.x_fclk_nfc_kiosk_location_id`. If empty → `{error: "no_location_configured"}`
|
||||
5. Server-side debounce: if same UID was tapped within the last 5 seconds, return `{error: "debounce"}` silently
|
||||
6. Call `FusionClockAPI._attendance_action_change(geo_info)` with `geo_info = { browser: 'nfc_kiosk', ip_address: <remote_addr>, latitude: 0, longitude: 0 }` to toggle attendance state
|
||||
7. Write `x_fclk_clock_source = 'nfc_kiosk'`, `x_fclk_location_id = <resolved>`, distance fields = 0
|
||||
8. If `photo_b64` present, decode and save to `x_fclk_check_in_photo` (clock-in) or `x_fclk_check_out_photo` (clock-out)
|
||||
9. If `nfc_photo_required = True` and photo is missing/decode-failed → reject the tap with `{error: "photo_required"}`
|
||||
10. Reuse `_check_and_create_penalty`, `_apply_break_deduction`, `_log_activity` calls (same as PIN kiosk)
|
||||
11. Return `{ success: true, action: 'clock_in' | 'clock_out', employee_name, employee_avatar_url, message, net_hours_today }`
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/enroll` — card enrollment
|
||||
- `type='jsonrpc'`, `auth='user'`
|
||||
- Input: `{ employee_id: 42, card_uid: "04:A2:B5:62:C1:80", enroll_password: "1234" }`
|
||||
- Logic:
|
||||
1. Verify `enroll_password` matches `fusion_clock.nfc_enroll_password` (or accept if config is empty AND caller is in manager group)
|
||||
2. Normalize UID
|
||||
3. Check no other employee has this UID → `{error: "card_already_assigned", existing_employee: "<name>"}`
|
||||
4. Write `x_fclk_nfc_card_uid` on the target employee
|
||||
5. Log to `fusion.clock.activity.log` ("Manager X enrolled card UID Y to employee Z")
|
||||
6. Return `{ success: true, employee_name, card_uid }`
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/employee_search` — pick employee for enroll
|
||||
- Reuses the existing `/fusion_clock/kiosk/search` controller method by importing it; does not duplicate logic.
|
||||
|
||||
## Frontend — kiosk page UX
|
||||
|
||||
**Files:**
|
||||
- `views/kiosk_nfc_templates.xml` — QWeb template for the page
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js` — Web NFC + camera + state machine
|
||||
- `static/src/css/nfc_kiosk.css` — high-contrast shop-floor styling (always dark)
|
||||
|
||||
**Visual:** always-dark, high-contrast, no Odoo navbar. Shop-floor lighting washes out light backgrounds.
|
||||
|
||||
### State machine
|
||||
|
||||
```
|
||||
┌─── (3s timeout) ─────────────────────────┐
|
||||
▼ │
|
||||
┌─────────────────────────┐ tap detected ┌────────────────────┐
|
||||
│ IDLE │ ────────────────► │ PROCESSING │
|
||||
│ "Tap card to clock │ │ spinner, "Reading"│
|
||||
│ in or out" │ └────────────────────┘
|
||||
│ big clock, date, │ │
|
||||
│ company name │ success / error
|
||||
└─────────────────────────┘ ▼
|
||||
▲ ┌─────────────────────────┐
|
||||
│ │ RESULT │
|
||||
│ │ green: "Welcome John, │
|
||||
└─── (3s) ──────────────────│ CLOCKED IN, 8:02 AM" │
|
||||
│ red: "Card not │
|
||||
│ enrolled" │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### IDLE state
|
||||
- Top: company name + current time (HH:MM, updates every second) + date
|
||||
- Center: large NFC icon + "Tap your card to clock in or out", subtle pulse animation
|
||||
- Bottom-right corner: tiny "⚙" icon (gateway to Enroll Mode)
|
||||
|
||||
### PROCESSING state
|
||||
- Brief spinner + "Reading card…"
|
||||
- Mostly imperceptible at typical network latency
|
||||
|
||||
### RESULT state — success
|
||||
- Green panel
|
||||
- Large employee avatar on the left
|
||||
- "John Smith" — name in big text
|
||||
- "CLOCKED IN at 8:02 AM" or "CLOCKED OUT — 8.1h today"
|
||||
- Auto-return to IDLE after 3s
|
||||
|
||||
### RESULT state — error
|
||||
- Red panel
|
||||
- `card_unknown` → "Card not recognized. See your manager."
|
||||
- `network_error` → "No connection. Please try again."
|
||||
- `debounce` → silent (no UI change to avoid double-tap confusion)
|
||||
- `photo_required` → "Camera unavailable. Ask IT to check the kiosk."
|
||||
- Auto-return to IDLE after 4s
|
||||
|
||||
### Web NFC implementation
|
||||
- One-time activation button on first page load: "Tap here to enable NFC reader" (Web NFC requires a user gesture before `scan()` is permitted)
|
||||
- After activation, `NDEFReader.scan()` runs continuously
|
||||
- `reading` event fires for any tap; we extract `event.serialNumber` (works for raw MIFARE access cards even with no NDEF data)
|
||||
- UID format: hex bytes joined by colons, uppercased
|
||||
- If `scan()` throws, restart with a 1-second backoff
|
||||
|
||||
### Camera implementation
|
||||
- `getUserMedia({ video: { facingMode: 'user' } })` activated alongside NFC
|
||||
- Hidden `<video>` element streams continuously
|
||||
- On tap, grab one frame to a `<canvas>`, encode as JPEG quality 0.7 (~30–60 KB), POST as base64 in the same JSON payload as the UID
|
||||
- If `nfc_photo_required = True` and camera is unavailable → tap is rejected ("Camera unavailable") rather than silently degrading
|
||||
|
||||
### Enroll Mode
|
||||
- Tap the bottom-right "⚙" → on-screen numpad password entry → match against `fusion_clock.nfc_enroll_password` → enter Enroll Mode
|
||||
- Enroll Mode UI:
|
||||
1. Search input → employee list (uses `/fusion_clock/kiosk/nfc/employee_search`)
|
||||
2. Manager picks employee → "Now tap John Smith's card on the back of the tablet"
|
||||
3. Tap detected → POST to `/enroll` → "✓ Card 04:A2:B5:62:C1:80 enrolled to John Smith. Enroll another?"
|
||||
4. "Done" button → exit Enroll Mode → back to IDLE
|
||||
- 60-second inactivity timeout in Enroll Mode → auto-exit to IDLE (so an unattended kiosk doesn't stay open in admin mode)
|
||||
|
||||
### One-time setup flow (first load on a new tablet)
|
||||
1. "Welcome to Fusion Clock NFC Kiosk." — large tap-to-continue button (this gesture activates Web NFC)
|
||||
2. Browser permission prompts: NFC, then Camera. Page text guides the manager through each.
|
||||
3. Test prompt: "Tap any card to verify reader is working" → shows the UID detected → "Reader OK ✓"
|
||||
4. "Setup complete." → enters IDLE
|
||||
- After setup, page auto-resumes IDLE on every reload (Web NFC permission is sticky per origin, so no re-prompts)
|
||||
|
||||
### Mock-tap debug mode
|
||||
- Gated by `fusion_clock.nfc_kiosk_debug = True`
|
||||
- When enabled, hidden keyboard shortcut `Ctrl+Shift+T` fires a mock tap with a configurable UID stored in localStorage
|
||||
- Off in production; useful for dev iteration on the UI state machine without hardware, and for support troubleshooting
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Card not enrolled | Red screen "Card not recognized. See your manager." Activity logged with the unknown UID. No attendance change. |
|
||||
| Employee disabled (`x_fclk_enable_clock=False`) | "Clock disabled for this account." Activity logged. |
|
||||
| Card lost/damaged | Manager opens employee form, clears `x_fclk_nfc_card_uid`, issues new card, re-enrolls via kiosk Enroll Mode. |
|
||||
| Card already assigned during enroll | "This card is already assigned to Jane Doe. Unenroll first." No silent overwrite. |
|
||||
| Tablet offline / WiFi drops | Fail loudly: "No connection. Use the portal on your phone." No local cache in v1. |
|
||||
| Same card tapped twice within 5s | Server-side debounce. Second tap silently ignored. |
|
||||
| MIFARE clone attack | UIDs can be cloned with cheap hardware. Mitigation = the photo. Manager dashboard surfaces photos for spot-check. Cards alone are not treated as secure. |
|
||||
| Tablet stolen | Knox remote wipe + revoke kiosk service user credentials in Odoo (instantly invalidates that tablet's session). |
|
||||
| Power outage | Tab Active battery covers brief outages. Full reboot → Chrome+Fully Kiosk auto-launch the kiosk URL. Setup is sticky → goes straight to IDLE. |
|
||||
| Tablet clock drift | Irrelevant. All timestamps come from `fields.Datetime.now()` server-side. Tablet clock is for display only. |
|
||||
| UID format mismatch (Ubiquiti vs Web NFC byte order) | Normalize on the server: uppercase, colon-separated, MSB first. Reject malformed UIDs at the endpoint. |
|
||||
| Camera unavailable while `nfc_photo_required=True` | Tap rejected with "Camera unavailable" — forces a real fix instead of silent degradation. |
|
||||
|
||||
## Hardware checklist (per company)
|
||||
|
||||
- Samsung Galaxy Tab Active 5 Pro (10.1") — ~$700 USD
|
||||
- Samsung official Pogo charging dock — ~$100
|
||||
- Wall mount bracket compatible with Tab Active 5 Pro (The Joy Factory, Maclocks, or Heckler) — ~$80
|
||||
- USB-C 30W PSU + cable — ~$25
|
||||
- Fully Kiosk Browser commercial license (~€10 one-time) OR Samsung Knox Configure (~$30/year/device)
|
||||
- "TAP HERE" decal for the back of the tablet — DIY/printed sticker
|
||||
|
||||
**Total**: ~$915 per company, one-time.
|
||||
|
||||
## Provisioning script (one-time per tablet)
|
||||
|
||||
**Prerequisite — Odoo side (one-time per company):**
|
||||
- Create a `res.users` named e.g. `kiosk-westin@<domain>`, member of `fusion_clock.group_fusion_clock_manager`
|
||||
- Generate a long random password; store it in a password manager
|
||||
- Set `res.company.x_fclk_nfc_kiosk_location_id` for that company to the desired `fusion.clock.location`
|
||||
- Toggle `fusion_clock.enable_nfc_kiosk = True` and `fusion_clock.nfc_photo_required` per policy
|
||||
- Set `fusion_clock.nfc_enroll_password` to a 4-digit Enroll Mode password
|
||||
|
||||
**Tablet side:**
|
||||
1. Factory reset
|
||||
2. Sign in with company Google account
|
||||
3. Install Fully Kiosk Browser from Play Store
|
||||
4. In Fully Kiosk: set kiosk URL → `https://<odoo-domain>/fusion_clock/kiosk/nfc`, enable "hide bars", "auto-restart on crash", "keep screen on while charging", "auto-reload daily at 3am"
|
||||
5. Open kiosk URL once in normal Chrome → log in as the kiosk service user (saved credentials) → walk through the one-time setup flow (activate NFC, allow camera, test-tap a card)
|
||||
6. Lock tablet into kiosk mode via Fully Kiosk's "Start Kiosk" button
|
||||
7. Mount on dock
|
||||
|
||||
## Testing plan
|
||||
|
||||
### Python unit tests (`tests/test_clock_nfc_kiosk.py`)
|
||||
- Tap with valid UID → attendance toggled, photo saved, activity logged
|
||||
- Tap with unknown UID → `card_unknown` error, no attendance row
|
||||
- Tap when `x_fclk_enable_clock=False` → `clock_disabled` error
|
||||
- Double-tap same UID within 5s → second is debounced
|
||||
- Enroll with conflicting UID → `card_already_assigned`, no overwrite
|
||||
- Enroll with wrong password → 403
|
||||
- Tap with no `fusion.clock.location` configured for company → `no_location_configured`
|
||||
- UID normalization: lowercase input → stored uppercase
|
||||
|
||||
### Manual smoke tests (real tablet or Android phone for dev)
|
||||
- Cold boot → IDLE within 5s
|
||||
- Tap → RESULT within 1s
|
||||
- Photo attached to attendance record (verify in backend)
|
||||
- Enroll Mode password gate works; 60s timeout exits cleanly
|
||||
- WiFi disconnect → tap shows "No connection"; reconnect → tap works again
|
||||
- Tap own card 5x in fast succession → only one state change (debounce holds)
|
||||
|
||||
### Dev shortcut
|
||||
- Test the entire flow on any Android phone with NFC + Chrome before touching tablet hardware
|
||||
- For pre-card testing: use any contactless credit/debit card or transit pass (Web NFC reads only the UID, not card data — safe)
|
||||
- Mock-tap debug mode (`Ctrl+Shift+T`) lets the UI state machine be tested without any hardware
|
||||
|
||||
### Soak test (before declaring pilot ready)
|
||||
- 24h continuous on the dock
|
||||
- Periodic taps every few hours
|
||||
- Verify Chrome memory stable (DevTools), NFC reader still active, no zombie permissions prompts
|
||||
|
||||
## Future considerations
|
||||
|
||||
- **Offline mode** — local IndexedDB cache + replay queue when network returns. Adds complexity (conflict resolution, clock-skew handling) for marginal benefit at 1 station. Defer until pilot proves it's a real problem.
|
||||
- **Multi-station** — if a single station becomes a bottleneck at shift change, add a second tablet at the same company. No code changes needed; just provision another tablet pointing at the same URL.
|
||||
- **QR-code-on-portal alternate credential** — for iPhone-only employees who don't want to carry a card. Adds `BarcodeDetector` to the kiosk page alongside `NDEFReader`, plus a "My Clock Code" page in the portal that shows a rotating short-lived QR. Defer to v1.1.
|
||||
- **Ubiquiti webhook integration** — subscribe to UniFi Access tap events on a designated "clock door" reader so an entry tap doubles as clock-in. Saves the tablet purchase but loses the photo verification and the screen feedback. Probably not worth it but easy to add later.
|
||||
- **Native Android kiosk app** — only if the pilot scales to 50+ stations and Web NFC's quirks become operationally painful. Today, not worth it.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Schedule-Driven Attendance Automation — Design
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved design → ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Drive every attendance automation (clock-in/out reminders, absence detection,
|
||||
late/early penalties, auto-clock-out) from each employee's **real schedule** —
|
||||
the team lead's **posted** planner entry first, then the employee's **recurring
|
||||
shift** — never the global 9–5 default. Employees who aren't scheduled get no
|
||||
reminders or absence flags. Overtime past the scheduled end is normal and is
|
||||
never cut off.
|
||||
|
||||
## Problem & root cause
|
||||
|
||||
The machinery already exists: `fusion.clock.shift` (recurring templates,
|
||||
assigned via `hr.employee.x_fclk_shift_id`), `fusion.clock.schedule` (dated
|
||||
per-employee entries built in the backend **shift planner** client action), and
|
||||
`hr.employee._get_fclk_day_plan(date)` which resolves per-day times. The crons
|
||||
already call these.
|
||||
|
||||
The bug: in `_get_fclk_day_plan()`, when an employee has **no dated entry and no
|
||||
assigned shift**, it silently falls back to the **global 9–5 default with
|
||||
`is_off = False`**. So everyone is treated as a 9–5 worker, and the reminder /
|
||||
absence crons fire off that global time. The crons also **hardcode-skip Sat/Sun**
|
||||
(`weekday() >= 5`), which is wrong for a production floor that runs weekends.
|
||||
Net effect: reminders are not actually schedule-driven for anyone who isn't on a
|
||||
fixed weekday 9–5 — exactly the spurious-email problem reported.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
1. **"Expected to work" source:** posted planner entry → else recurring shift
|
||||
(if it covers that weekday) → else **not scheduled** (silent). The global
|
||||
default never makes someone "expected."
|
||||
2. **Overtime:** time past the scheduled end is overtime and is never cut off.
|
||||
Auto-clock-out fires **only** at a generous safety cap (forgot-to-clock-out).
|
||||
3. **Posting:** draft → post gate. Team leads build the week in draft;
|
||||
automation ignores draft days. "Post" publishes the week and emails each
|
||||
employee their shifts. Only posted entries drive automation.
|
||||
4. **Employee schedule view:** reuse the **existing "Today's Shift" card** on
|
||||
`/my/clock` — no new portal view. (See Coordination.)
|
||||
|
||||
## Non-goals / constraints
|
||||
|
||||
- **No edits to the employee `/my` portal shell.** A concurrent session
|
||||
("Internal employee portal design", `fusion_plating`) owns `/my` + `/my/home`
|
||||
routing and the `/my/clock` bottom-nav tabs (it is adding a Payslips tab).
|
||||
This feature makes **zero** edits to `controllers/portal_clock.py` routing,
|
||||
`views/portal_clock_templates.xml`, or `/my` routing. The existing "Today's
|
||||
Shift" card already renders `today_schedule.get('label') or 'Not scheduled'`,
|
||||
so once the resolver is schedule-driven the card updates itself. Employees get
|
||||
their full posted week via the Post notification email. A dedicated "My
|
||||
Schedule" nav tab, if ever wanted, belongs to the portal-shell session.
|
||||
- The backend **shift planner** client action (manager/team-lead facing) is
|
||||
*not* the `/my` portal and **is** in scope to edit (Post button, draft/posted
|
||||
visuals).
|
||||
- No change to how attendance hours / overtime are computed.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Schedule resolver — `hr.employee._get_fclk_day_plan(date)`
|
||||
|
||||
Rewrite to return an explicit `scheduled` flag and a precise `source`, keeping
|
||||
all existing keys for backward compatibility (`is_off`, `label`, `hours`,
|
||||
`start_time`, `end_time`, `break_minutes`).
|
||||
|
||||
Return shape:
|
||||
```python
|
||||
{
|
||||
'scheduled': bool, # is the employee expected to work this day?
|
||||
'source': 'schedule' | 'shift' | 'none',
|
||||
'is_off': bool,
|
||||
'start_time': float, 'end_time': float, 'break_minutes': float,
|
||||
'hours': float,
|
||||
'label': str, # '' when not scheduled → card shows 'Not scheduled'
|
||||
'schedule_id': int | False,
|
||||
}
|
||||
```
|
||||
|
||||
Resolution order:
|
||||
1. **Posted planner entry** (`fusion.clock.schedule`, `state == 'posted'`) for
|
||||
(employee, date) — *draft entries are ignored, treated as absent*:
|
||||
- `is_off` → `scheduled=False`, `is_off=True`, `source='schedule'`, `hours=0`,
|
||||
`label='OFF'`.
|
||||
- else → `scheduled=True`, times from entry, `source='schedule'`.
|
||||
2. Else **recurring shift** `x_fclk_shift_id` **and** the shift covers
|
||||
`date`'s weekday → `scheduled=True`, times from shift, `source='shift'`.
|
||||
3. Else → `scheduled=False`, `source='none'`, `is_off=False`, `label=''`,
|
||||
`hours=0`. (Global default may fill `start_time`/`end_time` as a display
|
||||
hint only; it never sets `scheduled=True`.)
|
||||
|
||||
`_get_fclk_scheduled_times()` and `_get_fclk_break_minutes()` keep working off
|
||||
this structure unchanged.
|
||||
|
||||
### 2. Data model changes
|
||||
|
||||
- **`fusion.clock.schedule`**: add
|
||||
- `state = Selection([('draft','Draft'),('posted','Posted')], default='draft')`
|
||||
- `posted_date = Datetime`
|
||||
- Automation reads only `state == 'posted'`.
|
||||
- **`fusion.clock.shift`**: add a weekday pattern —
|
||||
`day_mon … day_sun = Boolean` (default Mon–Fri True, Sat–Sun False) plus a
|
||||
helper `covers_weekday(date) -> bool`. This replaces the hardcoded weekend
|
||||
skip and lets weekend shifts exist. (Judgment call: pattern lives on the
|
||||
shared shift template, e.g. "Mon–Fri Day", "Sat–Sun Weekend"; unique patterns
|
||||
→ own template or a posted planner override.)
|
||||
|
||||
### 3. Posting workflow
|
||||
|
||||
- New jsonrpc route `POST /fusion_clock/shift_planner/post_week` in
|
||||
`controllers/shift_planner.py`:
|
||||
- Gate: manager OR team lead.
|
||||
- Scope: managers → all in-scope employees for the viewed week; team leads →
|
||||
their direct reports (`parent_id` == the team lead's employee). Reuse the
|
||||
existing dashboard scoping helper.
|
||||
- Set `state='posted'`, `posted_date=now` on those week entries.
|
||||
- Queue **one email per affected employee** summarizing their posted shifts
|
||||
for the week (reuse `_fclk_email_wrap`). Failures logged, never block the
|
||||
post.
|
||||
- New planner entries default to `draft`. Re-posting after edits re-publishes
|
||||
(and re-notifies, flagged as an update).
|
||||
- Planner client action (`static/src/js/fusion_clock_shift_planner.js` + its
|
||||
template) gains a **Post** button and a draft-vs-posted visual cue. (Backend
|
||||
client action — not the `/my` portal.)
|
||||
|
||||
### 4. Reminder cron — `hr.attendance._cron_fusion_employee_reminders`
|
||||
|
||||
- Remove the `weekday() >= 5` hardcode.
|
||||
- Per enabled employee: `plan = emp._get_fclk_day_plan(today)`; **if not
|
||||
`plan['scheduled']` → skip** (silent).
|
||||
- Missed clock-in: if scheduled, not checked in, no attendance today, and
|
||||
`now > scheduled_in + reminder_before_shift_minutes` → remind. Uses the
|
||||
employee's real start, so a 14:00 shift is never pinged at 09:30.
|
||||
- Clock-out reminder: **reframed** (judgment call). Drop the "your shift ends at
|
||||
X" nudge (noise when OT is the norm). Instead, if still checked in and
|
||||
approaching the safety cap (`check_in + max_shift_hours -
|
||||
reminder_before_end_minutes`), send "you're still clocked in — remember to
|
||||
clock out."
|
||||
|
||||
### 5. Absence cron — `hr.attendance._cron_fusion_check_absences`
|
||||
|
||||
- Remove the `weekday() >= 5` hardcode.
|
||||
- Per enabled employee: `plan = emp._get_fclk_day_plan(yesterday)`; **only flag
|
||||
absent if `plan['scheduled']`** AND no attendance AND no leave request AND no
|
||||
global holiday. Off/unscheduled → never flagged.
|
||||
|
||||
### 6. Auto-clock-out — `hr.attendance._cron_fusion_auto_clock_out`
|
||||
|
||||
- Stop closing at `scheduled_out + grace`. Close **only** at the safety cap
|
||||
`check_in + max_shift_hours`. Everything between the scheduled end and the cap
|
||||
is captured as overtime by the existing fields.
|
||||
- Bump default `max_shift_hours` **12 → 16** (still configurable).
|
||||
- Keep `x_fclk_pending_reason=True`, break deduction, and office notify on
|
||||
auto-close.
|
||||
|
||||
### 7. Penalties — `controllers/clock_api.py::_check_and_create_penalty`
|
||||
|
||||
- Skip when the day is not scheduled (`not plan['scheduled']`), in addition to
|
||||
the existing posted-OFF skip. Late-in / early-out stay keyed off the resolved
|
||||
scheduled start/end. Overtime is never penalized.
|
||||
|
||||
### 8. Kiosk callers — `clock_kiosk.py`, `clock_nfc_kiosk.py`
|
||||
|
||||
- The existing `is_scheduled_off = source == 'schedule' and is_off` checks keep
|
||||
working for posted-OFF days. Extend the "unscheduled shift" log + penalty-skip
|
||||
to also cover `source == 'none'` (clocked in on a day with no schedule) so a
|
||||
not-scheduled clock-in is logged as `unscheduled_shift` and creates no penalty.
|
||||
|
||||
### 9. Settings
|
||||
|
||||
- `res_config_settings`: change `fclk_max_shift_hours` default 12 → 16 (and the
|
||||
resolver/cron `get_param` fallback). Optionally surface the shift weekday
|
||||
pattern on the shift form. No other new settings required.
|
||||
|
||||
### 10. Frontend
|
||||
|
||||
- **No file edits.** The existing "Today's Shift" card auto-reflects the new
|
||||
resolver: scheduled → times + hours; posted OFF → "OFF"; not scheduled →
|
||||
"Not scheduled" (already coded as `label or 'Not scheduled'`).
|
||||
|
||||
## Data flow
|
||||
|
||||
posted planner entry / recurring shift → `_get_fclk_day_plan(date)` →
|
||||
`scheduled` flag → consumed by: reminder cron, absence cron, penalty helper,
|
||||
kiosk unscheduled-log, and (read-only) the portal "Today's Shift" card. Posting
|
||||
flips `state` to `posted` (making entries visible to the resolver) and emails
|
||||
employees.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Crons: wrap each employee's body in `with self.env.cr.savepoint():` so one bad
|
||||
record can't abort the batch (savepoints, not `cr.commit()` — works in prod and
|
||||
tests).
|
||||
- Posting: state writes + email queueing in one transaction; email creation in
|
||||
try/except with logging so a bad address never blocks the post.
|
||||
- Notifications: `mail.mail` with `auto_delete=True`; send failures logged.
|
||||
|
||||
## Testing (`tests/test_schedule_driven.py`, post_install)
|
||||
|
||||
- **Resolver matrix:** posted-working / posted-off / draft-ignored /
|
||||
recurring-covers-weekday / recurring-skips-weekday / nothing → not-scheduled.
|
||||
Assert `scheduled`, times, and `label`.
|
||||
- **Reminder cron:** scheduled + late + no attendance → reminder; not scheduled →
|
||||
none; 14:00 shift not pinged at 09:30; already clocked in → no clock-in
|
||||
reminder.
|
||||
- **Absence cron:** scheduled no-show → absent logged; not scheduled → not
|
||||
flagged; leave/holiday → not flagged.
|
||||
- **Auto-clock-out:** open past scheduled end but under cap → stays open; past
|
||||
cap → closed + `x_fclk_pending_reason`.
|
||||
- **Posting:** draft entry → resolver `scheduled=False` (ignored by crons); post
|
||||
→ `state='posted'`, resolver picks it up, email queued; team lead can post only
|
||||
direct reports.
|
||||
- **Penalties:** not-scheduled clock-in → no penalty; scheduled late → `late_in`.
|
||||
|
||||
## Files expected to change (for the plan)
|
||||
|
||||
- `models/hr_employee.py` — resolver refactor.
|
||||
- `models/clock_shift.py` — weekday booleans + `covers_weekday`.
|
||||
- `models/clock_schedule.py` — `state` + `posted_date`.
|
||||
- `models/hr_attendance.py` — reminders, absences, auto-clock-out + savepoints.
|
||||
- `controllers/clock_api.py` — penalty skip when not scheduled.
|
||||
- `controllers/clock_kiosk.py`, `controllers/clock_nfc_kiosk.py` — unscheduled
|
||||
log/penalty for `source == 'none'`.
|
||||
- `controllers/shift_planner.py` — `post_week` route + scope + notifications;
|
||||
default new entries to draft.
|
||||
- `static/src/js/fusion_clock_shift_planner.js` + planner template — Post button,
|
||||
draft/posted visuals.
|
||||
- `models/res_config_settings.py` + `views/res_config_settings_views.xml` —
|
||||
`max_shift_hours` default 16; optional weekday-pattern surfacing.
|
||||
- `views/clock_shift_views.xml` — weekday checkboxes on the shift form.
|
||||
- `views/clock_schedule_views.xml` — show `state`.
|
||||
- `tests/test_schedule_driven.py` (+ `tests/__init__.py`).
|
||||
- **Not touched:** `controllers/portal_clock.py` routing,
|
||||
`views/portal_clock_templates.xml`, `/my` routing (owned by the concurrent
|
||||
portal-shell session).
|
||||
|
||||
## Coordination
|
||||
|
||||
Concurrent session "Internal employee portal design" (`fusion_plating`) owns the
|
||||
employee `/my` portal shell: `/my` + `/my/home` redirect to the clock page and
|
||||
new bottom-nav tabs (Payslips). This feature is **backend-only on the frontend
|
||||
side** — it edits no `/my` portal files — so the two land without conflict
|
||||
regardless of order. Shared touchpoint to watch: both evolve the employee
|
||||
experience; if a "My Schedule" nav tab is desired, it is the portal-shell
|
||||
session's responsibility, fed by this feature's resolver.
|
||||
@@ -0,0 +1,256 @@
|
||||
# Fusion Clock — Province-Aware Automatic Unpaid Break (2-tier)
|
||||
|
||||
- **Date:** 2026-05-31
|
||||
- **Module:** `fusion_clock`
|
||||
- **Version bump:** `19.0.4.0.3` → `19.0.4.1.0`
|
||||
- **Status:** Approved design, pending implementation plan
|
||||
- **Author:** Claude Code (brainstormed with user)
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Statutory unpaid meal breaks are jurisdiction-driven: a break is required after N1
|
||||
hours of work, and a second break after a higher N2 threshold. Ontario, for example:
|
||||
a 30-minute eating period after 5 hours of work, and (per the user's policy) another
|
||||
30 minutes after 10 hours. The deduction must be **automatic** and must apply on **every**
|
||||
way an attendance is recorded — including a manager manually adding or editing hours.
|
||||
|
||||
### Audit of current behaviour (what exists today)
|
||||
|
||||
The deduction field is `hr.attendance.x_fclk_break_minutes` (minutes). Net hours are
|
||||
`x_fclk_net_hours = worked_hours − x_fclk_break_minutes/60` (`models/hr_attendance.py:261`).
|
||||
|
||||
Break minutes are written from **four** places, all implementing variations of one rule:
|
||||
|
||||
1. `controllers/clock_api.py::_apply_break_deduction` (line 161) — on **clock-out**;
|
||||
reused by the PIN kiosk (`controllers/clock_kiosk.py:158`) and NFC kiosk
|
||||
(`controllers/clock_nfc_kiosk.py:381`). Logic: `if worked_hours >= break_threshold_hours`
|
||||
(default **4.0h**) → set break to `employee._get_fclk_break_minutes()` (default **30**),
|
||||
using `max(new, current)` so it doesn't wipe penalty minutes.
|
||||
2. Auto-clock-out cron (`models/hr_attendance.py:343`) — same single-threshold write.
|
||||
3. `controllers/clock_api.py::_check_and_create_penalty` (line 140) — **adds** penalty
|
||||
minutes into the same `x_fclk_break_minutes` field.
|
||||
|
||||
### Gaps vs. requirement
|
||||
|
||||
1. **Single tier only** — one threshold (4h), one break (30m). No second break.
|
||||
2. **Not applied on manual entry** — there is **no `create`/`write` override** on
|
||||
`hr.attendance`. A manager-created or manager-edited attendance gets break `= 0`.
|
||||
This is the central gap.
|
||||
3. **No province/country awareness** — no jurisdiction field exists anywhere (location
|
||||
has address/timezone but no province; company has none). Threshold + amount are flat
|
||||
global config params.
|
||||
4. **First-break default is 4h, not 5h** (Ontario is 5h).
|
||||
|
||||
## 2. Goals / Non-goals
|
||||
|
||||
**Goals**
|
||||
- Statutory unpaid break applies automatically based on **actual worked hours**, on every
|
||||
path (portal, systray, PIN kiosk, NFC kiosk, auto-clock-out cron, **and manual backend
|
||||
create/edit**).
|
||||
- Two tiers: first break after N1 hours, second break adds after N2 hours. Trigger is
|
||||
`worked_hours >= N` (inclusive; nothing under N1).
|
||||
- Rules are defined **per province/country** in a table; an employee resolves its rule
|
||||
from its **company's province**, with a single global default fallback.
|
||||
- **Eliminate the duplicated deduction logic** — one calculator, called everywhere.
|
||||
|
||||
**Non-goals (YAGNI)**
|
||||
- Per-employee break-rule override (resolver is structured so this is a cheap add later).
|
||||
- GPS/location-based jurisdiction detection.
|
||||
- More than two tiers (the table is 2-tier; a 3rd break would be a future schema change).
|
||||
- Changing the *planned* break concept used for scheduled-hours math.
|
||||
|
||||
## 3. Locked decisions
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|---|---|
|
||||
| 1 | Rule model | **Per-province table**, 2-tier (`fusion.clock.break.rule`) |
|
||||
| 2 | Jurisdiction source | **Company province** (`company_id.state_id`) + global default fallback |
|
||||
| 3 | Override behaviour | **Fully automatic** — idempotent stored compute, recomputes on every save |
|
||||
| 4 | Planned-vs-statute | **Statutory only** — the planned/scheduled break never affects the actual deduction |
|
||||
|
||||
## 4. Design
|
||||
|
||||
### 4.1 New model `fusion.clock.break.rule`
|
||||
|
||||
`models/clock_break_rule.py`, `_name = 'fusion.clock.break.rule'`,
|
||||
`_description = 'Statutory Break Rule'`, `_order = 'sequence, name'`.
|
||||
|
||||
| Field | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | Char (required) | — | e.g. "Ontario" |
|
||||
| `country_id` | Many2one `res.country` | — | scopes the province picker |
|
||||
| `state_id` | Many2one `res.country.state` | — | the province; `domain` on `country_id` |
|
||||
| `is_default` | Boolean | False | global fallback when no province matches |
|
||||
| `break1_after_hours` | Float | 5.0 | first break trigger N1 |
|
||||
| `break1_minutes` | Float | 30.0 | first break amount M1 (0 = disabled) |
|
||||
| `break2_after_hours` | Float | 10.0 | second break trigger N2 |
|
||||
| `break2_minutes` | Float | 30.0 | second break amount M2 (0 = disabled) |
|
||||
| `sequence` | Integer | 10 | |
|
||||
| `active` | Boolean | True | |
|
||||
|
||||
**Constraints** (`models.Constraint`, per repo Odoo-19 rule 9):
|
||||
- `break1_after_hours >= 0`, `break2_after_hours >= 0`, minutes `>= 0`.
|
||||
- When `break2_minutes > 0`: `break2_after_hours > break1_after_hours`
|
||||
(a misordered second tier is a config error).
|
||||
- (Soft) at most one `is_default = True` — enforced in a Python `@api.constrains`
|
||||
rather than a partial unique index, to give a friendly message.
|
||||
|
||||
**Method** — `break_minutes_for(self, worked_hours)`:
|
||||
```
|
||||
self.ensure_one()
|
||||
total = 0.0
|
||||
if self.break1_minutes and worked_hours >= self.break1_after_hours:
|
||||
total += self.break1_minutes
|
||||
if self.break2_minutes and worked_hours >= self.break2_after_hours:
|
||||
total += self.break2_minutes
|
||||
return total
|
||||
```
|
||||
`>=` is intentional and matches the requirement ("equal to or more than N1").
|
||||
|
||||
**Seed** (`data/clock_break_rule_data.xml`, `noupdate="1"`): one row —
|
||||
`name="Ontario"`, `state_id=base.state_ca_on`, `is_default=True`,
|
||||
`break1_after_hours=5.0`, `break1_minutes=30.0`,
|
||||
`break2_after_hours=10.0`, `break2_minutes=30.0`.
|
||||
(Acting as both the Ontario match and the global fallback for this deployment.
|
||||
Other provinces can be added as rows.)
|
||||
|
||||
### 4.2 Jurisdiction resolver — `hr.employee._get_fclk_break_rule()`
|
||||
|
||||
```
|
||||
self.ensure_one()
|
||||
Rule = self.env['fusion.clock.break.rule'].sudo()
|
||||
state = self.company_id.state_id
|
||||
rule = Rule.browse()
|
||||
if state:
|
||||
rule = Rule.search([('state_id', '=', state.id)], limit=1)
|
||||
if not rule:
|
||||
rule = Rule.search([('is_default', '=', True)], limit=1)
|
||||
return rule # may be empty recordset → caller treats as 0 break
|
||||
```
|
||||
`sudo()` so the portal net-hours compute (run as the employee) can read the rule table
|
||||
without a direct ACL grant. Resolver is a single method → adding a per-employee override
|
||||
(`x_fclk_break_rule_id`) later is a two-line change.
|
||||
|
||||
### 4.3 `hr.attendance` — `x_fclk_break_minutes` becomes a stored compute
|
||||
|
||||
The field changes from a plain editable Float to a **stored computed** field — this is the
|
||||
single calculator that replaces all four write sites.
|
||||
|
||||
```python
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
compute='_compute_fclk_break_minutes',
|
||||
store=True,
|
||||
tracking=True,
|
||||
help="Unpaid break deducted from worked hours: statutory break (by province "
|
||||
"rule, from actual hours worked) plus any penalty minutes.",
|
||||
)
|
||||
|
||||
@api.depends('worked_hours', 'check_out',
|
||||
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
|
||||
def _compute_fclk_break_minutes(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
|
||||
for att in self:
|
||||
statutory = 0.0
|
||||
if auto and att.check_out and att.employee_id:
|
||||
rule = att.employee_id._get_fclk_break_rule()
|
||||
if rule:
|
||||
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
|
||||
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
|
||||
att.x_fclk_break_minutes = statutory + penalties
|
||||
```
|
||||
|
||||
Properties:
|
||||
- **Idempotent** — same hours + same penalties always yield the same value; no drift,
|
||||
nothing to wipe.
|
||||
- **Fires on every path** — `worked_hours` recomputes whenever `check_in`/`check_out`
|
||||
change, so portal, kiosk, NFC, cron, **and manual backend create/edit** all recompute
|
||||
automatically. This is what fixes the manual-entry gap.
|
||||
- **Mid-shift = 0** — `check_out` empty → statutory 0 (penalties, if any, still counted).
|
||||
- **Master toggle preserved** — `auto_deduct_break` False → statutory 0 (penalties remain).
|
||||
- `_compute_net_hours` is unchanged (still `worked_hours − break/60`); it now depends on a
|
||||
computed-stored field, which Odoo chains correctly.
|
||||
|
||||
The attendance form's Break field becomes read-only (consistent with "fully automatic").
|
||||
`views/hr_attendance_views.xml` updated accordingly.
|
||||
|
||||
### 4.4 Removals (the de-duplication)
|
||||
|
||||
| Remove | File | Replaced by |
|
||||
|---|---|---|
|
||||
| `_apply_break_deduction` method + its 3 call sites | `controllers/clock_api.py:161`, `controllers/clock_kiosk.py:158`, `controllers/clock_nfc_kiosk.py:381` | the compute |
|
||||
| cron's `x_fclk_break_minutes` write | `models/hr_attendance.py:343-346` | the compute |
|
||||
| penalty's `current_break + deduction` write | `controllers/clock_api.py:140-144` | the compute's `Σ penalty_minutes` |
|
||||
| setting `fclk_break_threshold_hours` + `fusion_clock.break_threshold_hours` | `models/res_config_settings.py:39`, seed in `data/ir_config_parameter_data.xml` | per-rule `break1_after_hours` |
|
||||
|
||||
**Kept and untouched:** `hr.employee._get_fclk_break_minutes()`, `fusion_clock.default_break_minutes`,
|
||||
`fusion.clock.shift.break_minutes`, `fusion.clock.schedule.break_minutes` — these are the
|
||||
**planned** break (used to compute scheduled `planned_hours`), a separate concept from the
|
||||
actual worked-hours deduction. Decision #4 keeps them out of the deduction path.
|
||||
|
||||
**Kept:** the `auto_deduct_break` master toggle (now gates the statutory portion only).
|
||||
|
||||
### 4.5 UI / security / data
|
||||
|
||||
- **Menu:** *Fusion Clock → Configuration → Break Rules* (new `ir.actions.act_window` +
|
||||
list/form views in `views/clock_break_rule_views.xml`), gated to
|
||||
`group_fusion_clock_manager`. Add the menu item in `views/clock_menus.xml`.
|
||||
- **Security:** `security/ir.model.access.csv` — `fusion.clock.break.rule`: manager =
|
||||
full CRUD; team-lead/user = read (or none — the resolver uses sudo, so no direct grant
|
||||
is strictly required; grant manager full, no portal access).
|
||||
- **Manifest `data`:** add `data/clock_break_rule_data.xml` (after security, before crons)
|
||||
and `views/clock_break_rule_views.xml` (with the other config views, before
|
||||
`clock_menus.xml`). Bump `version` to `19.0.4.1.0`.
|
||||
|
||||
## 5. Edge cases
|
||||
|
||||
- **No rule resolvable** (no province match, no default) → statutory 0. The seeded default
|
||||
prevents this in practice.
|
||||
- **Company has no `state_id`** → falls to the default rule.
|
||||
- **`break2_after_hours <= break1_after_hours`** → blocked by constraint.
|
||||
- **Penalty created after clock-out** → `x_fclk_penalty_ids` change retriggers the compute;
|
||||
final break = statutory + penalty (preserves today's combined-field semantics, reported
|
||||
as one "Break" number).
|
||||
- **Open attendance** (no checkout) → break 0; recomputed when it's closed.
|
||||
- **Worked hours exactly at a boundary** (5.0h, 10.0h) → tier fires (`>=`).
|
||||
|
||||
## 6. Migration / upgrade
|
||||
|
||||
- On upgrade, flipping `x_fclk_break_minutes` to `store=True compute` makes Odoo recompute
|
||||
it for all existing rows. For closed attendances this re-derives break from
|
||||
`worked_hours` + linked penalties using the seeded Ontario rule — which is the intended
|
||||
corrected value. Any historical hand-edited break values are replaced (acceptable per
|
||||
Decision #3, "fully automatic"). Call this out in the change log.
|
||||
- No `pre`/`post` migration script is required; the recompute is automatic. (If we later
|
||||
want to *avoid* touching very old periods, a guarded post-migrate could pin them — out of
|
||||
scope for now.)
|
||||
|
||||
## 7. Testing (`tests/test_break_rules.py`, `@tagged('-at_install','post_install','fusion_clock')`)
|
||||
|
||||
1. `break_minutes_for`: 4.99h→0, 5.0h→30, 9.99h→30, 10.0h→60.
|
||||
2. Resolver: company in Ontario → Ontario rule; company with unset/other province → default.
|
||||
3. **Manual backend create** of a closed attendance (check_in/out spanning 6h) → break 30,
|
||||
net = worked − 0.5. **Manual edit** extending to 10h → break 60. (This is the headline
|
||||
gap; assert it directly via `env['hr.attendance'].create(...)`, not via a controller.)
|
||||
4. Penalty additivity: 6h + one 15-min penalty record → break 45.
|
||||
5. Master toggle off (`auto_deduct_break=False`) → statutory 0 (penalty-only).
|
||||
6. Constraint: `break2_after_hours <= break1_after_hours` raises.
|
||||
|
||||
Run (note ephemeral ports per repo CLAUDE.md):
|
||||
```
|
||||
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock \
|
||||
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
|
||||
## 8. Rollout notes
|
||||
|
||||
- **Dual-path write** during dev: edit files in **both** `K:\Github\odoo-modsdev\addons\fusion_clock`
|
||||
(Docker-mounted, for tests) **and** `K:\Github\Odoo-Modules\fusion_clock` (git); commit
|
||||
from the git path only. (Per project memory.)
|
||||
- Live target is **entech** (`odoo-entech`); deploy after local tests pass and user review.
|
||||
- Asset/version bump already covered by the manifest `version` change.
|
||||
|
||||
## 9. Open questions
|
||||
|
||||
None — all four design forks resolved (see §3).
|
||||
@@ -0,0 +1,164 @@
|
||||
# Assessment Visit — bundled, funding-routed assessments
|
||||
|
||||
**Date:** 2026-06-02
|
||||
**Module:** `fusion_portal` (depends on `fusion_claims`, `fusion_tasks`); live on `odoo-westin` (DB `westin-v19`)
|
||||
**Status:** Draft for review
|
||||
**Author:** Brainstormed with Gurpreet (Fusion / Westin Healthcare)
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem & goals
|
||||
|
||||
A sales rep visits a client's home **with an occupational therapist (OT) and the client present for only 30–45 minutes**, and the OT's time is the scarcest resource. In that window the team often does more than one assessment — a wheelchair (ADP) plus, opportunistically, accessibility products the rep spots (a ramp at the front steps, a stair lift inside, a tub cutout, a patient lift for transfers). Today each assessment is a **separate, standalone web form** that re-collects the client's details and creates its own sale order, and the front-end forms give the rep **no way to mark a case's funding source** — so March-of-Dimes work silently defaults to private pay and never reaches the MOD pipeline.
|
||||
|
||||
**Goals**
|
||||
|
||||
1. **One visit, many assessments, entered once.** Bundle every assessment from one home visit; capture the client + funding details a single time.
|
||||
2. **Measurement-first.** Capture measurements while the OT is present; defer client/health-card data to after they leave; let the OT sign the ADP application on the spot.
|
||||
3. **Add as you go.** The rep adds an assessment/product the instant they spot it — repeatable, with a location tag (Front / Back / Inside).
|
||||
4. **Route by funding workflow.** On completion the visit emits **one sale order per funding workflow** (ADP, March of Dimes, ODSP, WSIB, private, …) — never one combined SO, and never a separate SO per item within the same funding.
|
||||
5. **Let the rep set funding at assessment time** (the real MOD "tracking" gap).
|
||||
6. **ADP multi-device** with valid-combination rules, including a new **mobility scooter** type and a **home-accessibility hard rule** for power mobility that feeds the accessibility upsell.
|
||||
|
||||
**Non-goals (v1):** voice/dictated entry; rebuilding the measurement math; a new MOD/ADP claim model (the pipelines already exist — we reuse them).
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state (verified against source)
|
||||
|
||||
- **Two assessment models, already two separate SO lineages.** `fusion.assessment` (ADP: rollator/wheelchair/powerchair) and `fusion.accessibility.assessment` (the 7 lift/mod types) each have their own `_create_draft_sale_order` (`assessment.py:587`, `accessibility_assessment.py:751`), their own `x_fc_sale_type`, and their own state machine — ADP's 24-state `x_fc_adp_application_status` vs MOD's 16-state `x_fc_mod_status`. Each guards against a second SO (`accessibility_assessment.py:503-511`). SO back-links are **scalar** Many2one: `assessment_id`, `accessibility_assessment_id` (`fusion_portal/models/sale_order.py:37,48`).
|
||||
- **SOs are born with no order lines.** Specs become a **chatter HTML note** (`_format_assessment_html_table`, `accessibility_assessment.py:815`); a human prices the draft afterward. **No per-type product mapping exists.**
|
||||
- **Funding is modelled but not on the measurement forms.** `x_fc_funding_source` (required, default `direct_private`) on the accessibility model — values `march_of_dimes`, `odsp`, `wsib`, `insurance`, `direct_private`, `other` (`accessibility_assessment.py:71-87`) — is present on the public booking form but **absent from all 7 measurement forms**, so they default to private. Canonical billing type `sale.order.x_fc_sale_type` (`fusion_claims/models/sale_order.py:320`) carries the full set incl. `adp`, `adp_odsp`, `march_of_dimes`, etc.
|
||||
- **MOD tracking already exists** as `x_fc_mod_status` (16 states) + ~60 `x_fc_mod_*` fields (HVMP reference #, vendor code, drawings, PCA, POD, approved/payment amounts, dated audit trail) + MOD views + ~7 wizards + ~40 MOD/ODSP stage emails (`fusion_claims/models/sale_order.py:438,877`). An accessibility assessment funded `march_of_dimes` already lands its SO in this pipeline at `need_to_schedule`. **The gap is purely that the rep can't choose `march_of_dimes` on the form.**
|
||||
- **Emails** are mostly Python-built via the shared `fusion.email.builder.mixin._email_build` (`fusion_tasks/models/email_builder_mixin.py:8`), gated by `ir.config_parameter` `fusion_claims.enable_email_notifications`. Completion email fires from inside `_create_draft_sale_order` (`assessment.py:847`; `accessibility_assessment.py:624`). Stage emails (`_adp_send_stage_email`, `_mod_email_build`, `_odsp_email_build`) are keyed off the SO's funding type + status, so **they keep working per-SO unchanged**.
|
||||
- **Known bug:** backend ADP `action_complete()` sends the authorizer **two** completion emails (template pair at `assessment.py:494` + inline report via `:847`). Must consolidate before fanning out across a visit.
|
||||
|
||||
---
|
||||
|
||||
## 3. The design
|
||||
|
||||
### 3.1 The Visit aggregate (only net-new model)
|
||||
|
||||
`fusion.assessment.visit` — the hub for one home visit.
|
||||
|
||||
- **Client/context, entered once:** `partner_id`, address fields, `visit_date`, `sales_rep_id`, `authorizer_id` (OT), `x_fc_funding_source`-style default, `state` (`measuring` → `client_pending` → `done`).
|
||||
- **Links to its assessments:** `adp_assessment_ids` (One2many → `fusion.assessment`) and `accessibility_assessment_ids` (One2many → `fusion.accessibility.assessment`). Each assessment gains `visit_id`.
|
||||
- **Links to its sale orders:** `sale_order_ids` (One2many → `sale.order`) — one per funding workflow it produced.
|
||||
- On the SO side, add `visit_id`. Each assessment already carries `sale_order_id` (Many2one — `accessibility_assessment.py:153`, `assessment.py:422`), so several same-funding assessments can already point at one SO; the redundant **scalar** `assessment_id` / `accessibility_assessment_id` on the SO (`fusion_portal/models/sale_order.py:37,48`) become **One2many** (or are dropped in favour of the `sale_order_id` reverse) so an SO no longer assumes a single source assessment.
|
||||
|
||||
Client info moves to the Visit as the single source of truth; the per-assessment `client_name`-required gate is relaxed (the model keeps the field for back-compat / standalone use but the Visit flow fills it from `partner_id`).
|
||||
|
||||
### 3.2 Add-as-you-go workspace (portal UX)
|
||||
|
||||
A portal "visit workspace" (reps are portal users, tablet-first):
|
||||
|
||||
- Always-present **"+ Add"** → pick a type + location tag (Front / Back / Inside / custom) → drop **straight into the existing measurement form** for that type. No client paperwork required to start.
|
||||
- Each added assessment is a **card** showing type, location, status (To measure / Measured / Signed), and — once priced — its amount.
|
||||
- **Measurement-first:** the forms render with client fields hidden/optional; a **deferred "Client + funding" step** is completed after the OT leaves and is shared by every item.
|
||||
- The **OT signs the ADP application (Page 11)** inline on the wheelchair/ADP item, on-site, independent of client demographics (reuse `portal_assessment_express` Page-11 section + signature pad).
|
||||
- Mockups (for reference, in repo `docs/mockups/` if committed): `fusion_portal_new_approach_mockup.html`.
|
||||
|
||||
### 3.3 Multi-instance + location tags
|
||||
|
||||
Any type can be added **more than once**, each its own assessment record with a **location label** ("Main stairs", "Basement", "Front porch"). Two stair lifts = two assessment records (→ two lines on the same funding SO; see §3.6). A **"Same as the previous"** action copies shared options so the rep only re-enters the differing measurements.
|
||||
|
||||
### 3.4 Per-item funding selector — the MOD gap fix
|
||||
|
||||
Expose `x_fc_funding_source` on **each accessibility assessment** in the flow: **Private Pay / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other**. This one field drives the existing `sale_type_map` → `x_fc_sale_type` → correct pipeline (MOD 16-state tracker, ODSP, hardship, …). Defaults to the previous item's funding so an all-MOD visit isn't re-picked each time. **ADP/wheelchair items are fixed to ADP** (no picker). This is the minimal change that closes the "can't mark a case as March of Dimes" gap — no new tracking model.
|
||||
|
||||
> **Patient lift** is an accessibility/equipment item that uses this same picker — funded by March of Dimes, **ODSP**, or **Hardship** (e.g. Toronto residents), so its funding is chosen per case, not fixed.
|
||||
> **`sale_type_map` gap:** `x_fc_funding_source` currently lacks `hardship` while `x_fc_sale_type` already has it (`sale_order.py:320`) — add `hardship` to the picker + a `sale_type_map` entry (`accessibility_assessment.py:771`), and review the map so every offered funding routes to a real `x_fc_sale_type`.
|
||||
> **MOD funding cap** applies to MOD items — see Resolved decision 1 (§4).
|
||||
|
||||
### 3.5 ADP multi-device + combinations + scooter + home-access rule
|
||||
|
||||
**Multi-device ADP order.** Today one ADP device per order; the visit allows a **valid combination** of ADP devices for one client, all landing on the **one ADP SO**. Each ADP device is an item; the combination check runs across the visit's ADP items.
|
||||
|
||||
**Device categories:** Walker/Rollator · Manual Wheelchair · Power Wheelchair · **Scooter (new)**.
|
||||
|
||||
**Combination rules (confirmed):**
|
||||
|
||||
| Combination | Allowed? |
|
||||
|---|---|
|
||||
| Any single device | ✓ |
|
||||
| Walker + Manual Wheelchair | ✓ |
|
||||
| Walker + Power Wheelchair | ✓ |
|
||||
| Walker + Scooter | ✓ |
|
||||
| Manual + Power Wheelchair | ✗ |
|
||||
| Power Wheelchair + Scooter | ✗ |
|
||||
| Manual Wheelchair + Scooter | ✗ |
|
||||
| Two walkers / any duplicate | ✗ |
|
||||
|
||||
Rule in words: **at most one "seated-mobility" device** {manual wheelchair, power wheelchair, scooter}, **optionally one walker/rollator alongside, no duplicates.** Enforced when adding/saving an ADP device.
|
||||
|
||||
**Scooter (new ADP type) fields:** `client_weight` (exists), scooter type, **maximum travel range**, and the home-accessibility check (below). Gets its own measurement section in the ADP form, mirroring the rollator/wheelchair/powerchair sections.
|
||||
|
||||
**Power-mobility home-accessibility hard rule.** For **scooter and power wheelchair**, a required check: *"Is the home accessible enough for the device to be used **inside and outside** the home independently — no lifting, not left outside/in the garage?"* ADP will not fund power mobility a home can't accommodate. If the answer is **No**, the visit **flags an accessibility need** and prompts the rep to add an accessibility item (ramp / porch lift, typically March of Dimes) to remediate. This is the explicit bridge between the ADP power-mobility item and the accessibility/MOD upsell.
|
||||
|
||||
> **The power-wheelchair form is already well-optimized — do NOT change its fields.** The *only* addition there is this home-accessibility warning. The new **scooter** type gets its own section (fields above); the manual-wheelchair and rollator sections are unchanged.
|
||||
|
||||
### 3.6 Funding-workflow grouping → one SO per workflow
|
||||
|
||||
On visit completion, group its assessments by **funding workflow** (`x_fc_sale_type`) and create **one SO per group**:
|
||||
|
||||
- All `march_of_dimes` items (stair lift + porch lift + tub cutout, or two stair lifts) → **one MOD SO, multiple lines** (funding permitting).
|
||||
- All ADP devices (the valid combination) → **one ADP SO**.
|
||||
- Private / ODSP / WSIB / insurance → their own SO each.
|
||||
- A separate SO appears **only when the case type changes**, never per-item within a funding.
|
||||
|
||||
Refactor the two per-model `_create_draft_sale_order` routines into a **shared, group-aware builder** that takes a set of same-funding assessments and produces one SO, branching on funding type to stamp the right starting status field (`x_fc_adp_application_status` for ADP, `x_fc_mod_status` for MOD, etc. — mirroring `assessment.py:600-622`) and the right links. **Reuse the existing MOD/ADP/ODSP pipelines unchanged.**
|
||||
|
||||
### 3.7 Emails
|
||||
|
||||
- Reuse `fusion.email.builder.mixin` and the existing per-funding stage emails (they're keyed off SO type + status, so per-SO they keep working).
|
||||
- **Move the completion send to per-SO** inside the new builder (not per-assessment), and **dedupe recipients**, so a 3-item visit doesn't emit 3–6 completion emails.
|
||||
- **Fix the existing duplicate** (authorizer gets two completion emails on backend ADP completion) as part of this.
|
||||
- Make `enable_email_notifications` gating consistent across the sends the visit touches.
|
||||
|
||||
### 3.8 Reused vs net-new
|
||||
|
||||
- **Reused, largely untouched:** the 7 accessibility measurement forms + their JS/Python calc; the ADP Express form + Page-11 signature; the MOD/ADP/ODSP pipelines, views, wizards, and stage emails; the email branding mixin.
|
||||
- **Net-new:** the `fusion.assessment.visit` model + workspace UI; per-item funding selector on the accessibility forms; the group-aware SO builder + link-cardinality change; ADP multi-device + combination validation; scooter type + fields; power-mobility home-access rule + cross-sell flag; completion-email consolidation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Resolved decisions
|
||||
|
||||
1. **MOD funding cap — documented rule, light-touch in v1.** March of Dimes covers **up to $15,000 per person, lifetime**, income-gated: if the client's income is **under** that year's threshold (the threshold changes annually), MOD funds the full $15k; if **over**, MOD may **deny or partially approve**. **v1:** surface this cap as a reminder on MOD items and capture an *"income under MOD threshold? (yes / no / unknown)"* flag so the rep can judge — **do not** auto-compute lifetime used-vs-remaining across the client's prior MOD orders (the SO's existing `x_fc_mod_*` approved/payment fields already record per-order amounts). **Future:** yearly-threshold config + automatic lifetime-remaining tracking + a hard warning.
|
||||
2. **No auto pricing / products in v1.** The visit creates a **draft** SO per funding workflow and appends each assessment's specs to that SO's chatter (today's pattern); **the sales rep builds the quotation lines manually.** One SO can hold many items. No per-assessment-type product mapping. (Auto-pricing is a future expansion.)
|
||||
3. **Patient-lift funding is chosen per case** via the funding picker — March of Dimes, **ODSP**, or **Hardship** (e.g. Toronto residents) all fund it; it is not fixed (see §3.4).
|
||||
4. **Power-wheelchair form unchanged** — already well-optimized; the only addition is the **home-accessibility warning** (device usable **inside and outside** the home). The home-access rule applies to **scooter (new type, new section) and power wheelchair (warning only)**.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phasing
|
||||
|
||||
- **Phase 1 — Funding correctness + visit backbone:** `fusion.assessment.visit`, link-cardinality change, **funding selector on the accessibility forms** (incl. Hardship; patient-lift routing), **MOD $15k-cap reminder + income-threshold flag** (informational), group-and-route to per-workflow **draft** SOs (specs to chatter, manual pricing) reusing existing pipelines, completion-email consolidation + duplicate fix. *(Delivers the MOD-routing fix and the multi-SO split.)*
|
||||
- **Phase 2 — ADP expansion:** multi-device ADP order + combination validation, **scooter** type + fields, power-mobility **home-access hard rule** + accessibility cross-sell prompt.
|
||||
- **Phase 3 — Seamless field UX:** the full add-as-you-go workspace, measurement-first deferral, location tags, "same as previous", OT on-site sign-off polish.
|
||||
- **Later:** product-line auto-pricing, MOD funding-cap tracking, voice/quick entry.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks (from investigation)
|
||||
|
||||
- **Duplicate completion emails** already live on the ADP backend path — fix before fan-out (§3.7).
|
||||
- **Scalar back-links + double-SO guards** assume one SO per assessment; grouping breaks them — must move to `visit_id` / One2many and make the guard visit-aware.
|
||||
- **Inconsistent `enable_email_notifications`** — template sends ignore the kill-switch; don't route new traffic through templates without honoring it.
|
||||
- **Label drift** `x_fc_funding_source` vs `x_fc_sale_type` (`insurance`="Private Insurance" vs "Insurance"; `direct_private`="Private Pay (Direct)" vs "Direct/Private") — keys match so routing works; align labels in any shared UI.
|
||||
- **Unreachable funding types from accessibility:** `sale_type_map` (`accessibility_assessment.py:771`) covers 6 values; decide which funding types each assessment type may emit.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files in scope
|
||||
|
||||
- `fusion_portal/models/assessment.py` — ADP `_create_draft_sale_order` (:587), completion email (:847), multi-device + scooter + home-access.
|
||||
- `fusion_portal/models/accessibility_assessment.py` — accessibility `_create_draft_sale_order` (:751), `action_complete` (:493), completion email (:624), funding routing.
|
||||
- `fusion_portal/models/sale_order.py` — back-links (:37,:48) → `visit_id` / One2many.
|
||||
- `fusion_portal/models/visit.py` — **new** `fusion.assessment.visit`.
|
||||
- `fusion_portal/views/portal_accessibility_forms.xml` + `portal_assessment_express.xml` — funding selector, scooter section, home-access check; workspace shell.
|
||||
- `fusion_portal/controllers/portal_main.py` (`/my/accessibility/save` :2482) + `portal_assessment.py` — visit-aware save/group/route.
|
||||
- `fusion_claims/models/sale_order.py` — reuse `x_fc_sale_type` (:320), `x_fc_mod_status` (:438), stage emails (:6876,:9038,:10063); no pipeline rebuild.
|
||||
- `fusion_tasks/models/email_builder_mixin.py` — reuse for any new visit emails.
|
||||
|
||||
**Deployment note:** `fusion_portal` is live on `odoo-westin` (`westin-v19`, container `odoo-dev-app`). Ship per the rename/deploy procedure (backup → code sync → `-u fusion_portal` → cache-bust → restart → verify).
|
||||
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.
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