Compare commits
368 Commits
phase6_1-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Editor / OS noise
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Odoo runtime
|
||||
*.pyc-tmp
|
||||
|
||||
# Local-only diagnostic logs from test runs
|
||||
_test_*.log
|
||||
@@ -77,6 +77,7 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
|
||||
## Cursor-Managed Modules
|
||||
- **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`
|
||||
|
||||
130
CLAUDE.md
130
CLAUDE.md
@@ -12,9 +12,26 @@
|
||||
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%'`.
|
||||
|
||||
## Card Styling — Copy Odoo's Kanban Pattern
|
||||
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
||||
@@ -75,12 +92,20 @@ 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_authorizer_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
|
||||
@@ -110,3 +135,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).
|
||||
|
||||
166
docs/superpowers/2026-05-27-fusion-billing-session-handoff.md
Normal file
166
docs/superpowers/2026-05-27-fusion-billing-session-handoff.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# fusion_centralize_billing — Session Handoff (2026-05-27)
|
||||
|
||||
Resume point for the centralized-billing initiative. Read this first, then continue
|
||||
from **"Decision pending"** below.
|
||||
|
||||
## Where we are
|
||||
|
||||
- **Sub-project #1 (core billing engine): DONE and on `main`** (tip `d770c0c3`, pushed to
|
||||
GitHub + Gitea).
|
||||
- 11/11 plan tasks, TDD, Opus code-reviewed; all Critical/High bugs fixed
|
||||
(cross-billing cron → match by `plan_id`; `/usage` authz vs IDOR; input validation →
|
||||
4xx not 500; correct billing-period window; idempotency scoped to `(sub, metric, key)`;
|
||||
webhook sign-exact-bytes + event-id + SSRF guard).
|
||||
- **39 tests green on Odoo 19 Enterprise.**
|
||||
- Note: the 14 billing commits were rebased off the old login-audit/helpdesk stack and
|
||||
landed cleanly on `main`.
|
||||
|
||||
- **`fusion_login_audit`: also landed on `main`** (2026-05-27). Its 19 commits were rebased
|
||||
onto `main` and the `feat/fusion-login-audit` branch was deleted. This also restored
|
||||
Odoo-19 rules #9–14 in `CLAUDE.md`, which had gone missing on `main` when billing landed
|
||||
alone (they were authored alongside login_audit and never existed on the old base).
|
||||
- A concurrent `feat/helpdesk-customer-followup` session still carries pre-landing copies
|
||||
of the billing + login_audit commits; when it merges, replay its helpdesk-only commits
|
||||
onto `main`.
|
||||
|
||||
- **Reference docs (on `main`):**
|
||||
- Spec: `docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`
|
||||
- Core plan: `docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md`
|
||||
|
||||
## Next: sub-project #2 — NexaCloud adapter + dual-run reconciliation
|
||||
|
||||
Per spec §12, each sub-project is its own spec → plan → build cycle. #2 decomposes into
|
||||
four chunks (dependency order):
|
||||
|
||||
| Chunk | What | Risk |
|
||||
|-------|------|------|
|
||||
| **2a — Mapping + importer** | Read `nexacloud` DB → create `res.partner` + `account.link`, `product.template` + subscription plans, one subscription `sale.order` per deployment | **Low** — read-only on NexaCloud, writes only into Odoo |
|
||||
| **2b — Usage metering wiring** | NexaCloud `usage_metering.py` pushes CPU-seconds → Odoo `/usage`; verify aggregation → draft invoice w/ quota + overage + HST | Edits NexaCloud code |
|
||||
| **2c — Control loop** | NexaCloud consumes Odoo's outbound webhooks (`invoice.payment_failed` → suspend via existing `network_isolation`/`throttle_checker`; `subscription.terminated` → deprovision) | Edits NexaCloud code |
|
||||
| **2d — Dual-run reconciliation** | `fusion.billing.reconciliation` diffs Odoo-computed vs NexaCloud-actual per customer/period for ≥ 1 cycle before any flip | Safety gate before flipping real billing |
|
||||
|
||||
The core engine already built the *receiving* side (`/usage`, webhook engine, charge math).
|
||||
#2 is about **connecting NexaCloud to it and proving the numbers match before flipping.**
|
||||
|
||||
## Decision pending (resume here)
|
||||
|
||||
We were in the `superpowers:brainstorming` flow for #2 and stopped at: **which slice to
|
||||
start with?**
|
||||
|
||||
- **(recommended) 2a — Mapping + importer** — lowest risk, foundation for everything else.
|
||||
- 2d — Reconciliation first (front-load the trust mechanism).
|
||||
- Full #2 design as one spec, then one plan.
|
||||
- Just write the #2 plan, no code this session.
|
||||
|
||||
## Open questions to resolve before building #2
|
||||
|
||||
- **Spec §15 Q2 — NexaCloud billing granularity:** confirm **one subscription per
|
||||
deployment** (spec leans this way) vs one subscription per customer with deployment line
|
||||
items.
|
||||
- **Access / environments needed:**
|
||||
- Read access to the `nexacloud` DB schema (LXC 102 / its Postgres on LXC 201) to design
|
||||
the importer mapping.
|
||||
- A NexaCloud staging or safe path for 2b/2c (they edit live NexaCloud code).
|
||||
- Test target for the Odoo side stays the odoo-trial Enterprise sandbox.
|
||||
- **Resolved already:** Stripe is one account (`acct_1ShlA9IkwUB1dVox`) for everything — no
|
||||
account migration (spec §11 / §15 Q1). Branch strategy — land on `main`, branch new work
|
||||
off `main`.
|
||||
|
||||
## How to run / test
|
||||
|
||||
- **Billing tests:** `bash scripts/fcb_test_on_trial.sh` from repo root → pass = `FCB_EXIT=0`
|
||||
(~1–2 min). Syncs the module to the odoo-trial Enterprise sandbox (Proxmox VM 316, db
|
||||
`trial`) and runs `--test-enable`. Local dev Odoo is Community and **cannot** install this
|
||||
module.
|
||||
|
||||
## Branch hygiene (lesson from this session)
|
||||
|
||||
Cut each new feature branch from `main`, and land it before starting the next. For any
|
||||
cross-branch git surgery, use a **throwaway `git worktree`** — never switch the shared
|
||||
working dir's branch, because a concurrent session may be working on it.
|
||||
|
||||
---
|
||||
|
||||
## UPDATE — sub-project #2 complete (2026-05-27, later session)
|
||||
|
||||
All four chunks of #2 are now built. The brainstorm "which slice" question resolved to
|
||||
2a-first; everything else followed.
|
||||
|
||||
**Done + on `main` in `Odoo-Modules` (fully tested on odoo-trial, suite `FCB_EXIT=0`):**
|
||||
- **2a — importer** (`fusion.billing.import.wizard`): read-only `psycopg2` reader split
|
||||
from pure-Odoo writes; users→partners+links, plans→`cpu_seconds` charge catalog
|
||||
(`plan_id` NULL), deployments→one **draft shadow** `sale.order` each with the flat price.
|
||||
Shadow-safe by construction (draft + no token + NULL `plan_id`). Idempotent, dry-run,
|
||||
Test-Connection guard, README runbook.
|
||||
- **2d — reconciliation** (`fusion.billing.reconciliation`): `_compute_reconciliation` +
|
||||
`_reconcile_rows` (Odoo flat+overage vs NexaCloud actual, status match/delta), reader for
|
||||
NexaCloud usage+invoice actuals, "Run Reconciliation" button. **Upsert key is
|
||||
`(service, external_subscription_id, period)`** — per subscription, so a customer with
|
||||
two deployments doesn't collide.
|
||||
- **/usage enabler**: `_api_record_usage` resolves a subscription by the source app's own
|
||||
id (`x_fc_nexacloud_subscription_id`) so NexaCloud can push against shadow subs.
|
||||
- Core-engine bug fixed in passing: `charge.price_per_unit` is now `Float(16,6)` and
|
||||
`_compute_billable` keeps 6-dp precision (was `Monetary`/cent-rounded → would under-bill
|
||||
sub-cent rates and drift from NexaCloud's 4-dp amounts).
|
||||
|
||||
**Code-complete in `Nexa-Cloud` (feature-flagged, NOT deployed, NOT integration-tested):**
|
||||
- **2b — usage push**: `services/odoo_billing_client.py` + a hook in `usage_metering.py`
|
||||
posting cpu-seconds to Odoo `/usage`. **2c — control loop**:
|
||||
`routers/odoo_billing.py` (`POST /api/v1/billing/webhooks/central`, HMAC-verified) +
|
||||
`services/odoo_billing_integration.py` (suspend/restore/deprovision). All INERT unless
|
||||
`ODOO_BILLING_ENABLED`. Implemented as NEW modules + edits to clean files only —
|
||||
NexaCloud `main` had concurrent **Cursor uncommitted WIP** (`routers/billing.py`,
|
||||
`scheduler.py`, `stripe_service.py`, `models/billing.py`, …) which was deliberately not
|
||||
touched. Commits: `94542ec` + `956abb2` (only my files staged).
|
||||
|
||||
**Deployment status (2026-05-27):**
|
||||
- **odoo-nexa (production `nexamain`): DEPLOYED** — `fusion_centralize_billing` (core + 2a
|
||||
+ 2d) **fresh-installed** (#1 had never actually been deployed here; `DIR_ABSENT` before).
|
||||
`ir_module_module.state = installed`, `odoo-nexa-app` healthy. **INERT**: no
|
||||
`nexacloud_dsn`, all charges `plan_id` NULL (rating cron no-op), no webhooks queued
|
||||
(dispatch cron no-op), inbound API 401s with no key configured. Synced to
|
||||
`/opt/odoo/custom-addons` + `-i` via the restart-safe recipe.
|
||||
- **NexaCloud (prod, `vps.nexasystems.ca` / 192.168.1.250): DEPLOYED — INERT.** Did NOT
|
||||
use `./deploy.sh` (it `rsync --delete`s the working tree → would have shipped the
|
||||
concurrent **uncommitted Cursor WIP** (7 files) AND wiped the gitignored prod `.env`
|
||||
files). Instead deployed **surgically**: rsync of ONLY my 6 committed billing files (no
|
||||
`--delete`; `.env` + Cursor's files untouched), `docker compose build backend`,
|
||||
**boot-tested in a throwaway container** (`run --rm --no-deps backend python -c "import
|
||||
app.main"` → BOOT_OK) before swapping, then `up -d backend`. `nexacloud-api` healthy,
|
||||
`/health` OK. Feature OFF: `ODOO_BILLING_ENABLED` unset → `/billing/webhooks/central`
|
||||
returns 404 and no usage is pushed. Activate later by setting `ODOO_BILLING_*` in
|
||||
`/opt/nexacloud/.env` (+ compose env passthrough) once the Odoo side is wired.
|
||||
**NOTE:** Cursor's 7-file WIP remains uncommitted locally and was never deployed — when
|
||||
Cursor finishes, a normal `./deploy.sh` will ship it (and re-sync `.env`).
|
||||
|
||||
**Dual-run stand-up results (2026-05-27) — STOPPED here for review, NOT flipped:**
|
||||
- Read-only role `odoo_billing_ro` created on nexacloud Postgres (192.168.1.50); DSN set in
|
||||
`ir.config_parameter` `fusion_billing.nexacloud_dsn` on nexamain. Test Connection OK
|
||||
(read 7 users / 232 plans / 87 subscriptions).
|
||||
- **Shadow import committed on nexamain**: 7 partners, 232 plan catalogs, 87 draft shadow
|
||||
subscriptions; 0 skipped, 0 failed. (NOTE: importer takes ALL plans/subs regardless of
|
||||
active status → ~464 NC-* products now in the prod ERP catalog. Consider filtering to
|
||||
`is_active` plans / active subscriptions, or prune the shadow records — all reversible.)
|
||||
- **Reconciliation pass**: 9 (sub,period) rows had real billing activity → **2 match, 7
|
||||
delta**, 0 failed. The 7 deltas, MUST resolve before flipping:
|
||||
1. **One-off / non-subscription invoices** (3 rows: $877.99, $872.66, $32.20) — nexacloud
|
||||
invoices with NULL subscription_id (fees/manual/credits); not modeled per-subscription.
|
||||
2. **List-price ≠ actual-invoiced** (4 rows: Odoo $200/$50 vs actual ~$9.1x) — likely
|
||||
proration or NexaCloud invoicing ≠ plan list price.
|
||||
- **2d bug surfaced (analysis-only, not safety):** `_reconcile_rows` with an empty
|
||||
`subscription_external_id` matches NULL-field orders instead of skipping → spurious
|
||||
delta rows for the one-off invoices. Add `if not sub_ext: skip`.
|
||||
|
||||
**Remaining before go-live (gated on infra / ops you do):**
|
||||
1. Grant the read-only DSN (`fusion_billing.nexacloud_dsn`) — see the module README — then
|
||||
Test Connection → dry-run import → review → real import.
|
||||
2. Run a dual-run cycle (Run Reconciliation), confirm all rows `match`.
|
||||
3. **2c needs the Odoo side to actually EMIT** `invoice.payment_failed` /
|
||||
`payment_succeeded` / `subscription.terminated` webhooks with `deployment_id` in the
|
||||
payload — that emission isn't wired yet (it belongs to the live billing flow). The
|
||||
NexaCloud receiver is built to that contract; confirm the payload shape when wiring it.
|
||||
4. Integration-test + deploy the NexaCloud changes (no test harness in that repo).
|
||||
5. The flip: set `charge.plan_id`, attach Stripe tokens, confirm the shadow subs.
|
||||
|
||||
Specs/plans: `specs/2026-05-27-nexacloud-billing-importer-design.md`,
|
||||
`specs/2026-05-27-nexacloud-reconciliation-design.md`, and the matching plans.
|
||||
2703
docs/superpowers/plans/2026-05-26-fusion-login-audit.md
Normal file
2703
docs/superpowers/plans/2026-05-26-fusion-login-audit.md
Normal file
File diff suppressed because it is too large
Load Diff
1104
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
Normal file
1104
docs/superpowers/plans/2026-05-27-fusion-centralize-billing-core.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,477 @@
|
||||
# Fusion Helpdesk — Customer Follow-up & Embedded Inbox Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Attach real customer identity to every helpdesk ticket and give client-deployment staff an in-app ticket inbox (read replies + follow up without leaving their Odoo), while external customers use the native Enterprise portal + magic link.
|
||||
|
||||
**Architecture:** Keystone = pass `partner_email`/`partner_name`/`x_fc_client_label` in the ticket-create payload; native helpdesk then creates the partner + subscribes the follower. Client module (`fusion_helpdesk`) gains read/reply RPC endpoints + a tabbed dialog + unread badge, all scoped server-side by the logged-in user's identity. Central module (`fusion_helpdesk_central`) adds the `x_fc_client_label` field + a branded acknowledgement email.
|
||||
|
||||
**Tech Stack:** Odoo 19 (Enterprise on central, Community on client deployments), Python 3.11, OWL 2, XML-RPC client→central, `helpdesk` (Enterprise), `portal.mixin`, `mail.thread.cc`.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-fusion-helpdesk-customer-followup-design.md`
|
||||
|
||||
**Testability note:** `fusion_helpdesk` depends only on base/web/mail → installable + testable on local Community (`odoo-modsdev-app`, DB `modsdev`). Pure logic (scope-domain, message filtering, vals builder, unread math) is extracted into `fusion_helpdesk/utils.py` and unit-tested with no live remote. `fusion_helpdesk_central` depends on `helpdesk` (Enterprise) → install/test on the deploy target (odoo-nexa) or odoo-trial.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**`fusion_helpdesk` (client)**
|
||||
- `utils.py` *(new)* — pure helpers: `build_scope_domain`, `is_public_message`, `build_ticket_vals`, `compute_unread_count`. No Odoo env needed → trivially unit-testable.
|
||||
- `controllers/main.py` *(modify)* — keystone payload in `submit()`; new endpoints `my_tickets`, `ticket_detail`, `ticket_reply`, `unread_count`; a mockable `_rpc(model, method, args, kw)` seam.
|
||||
- `models/__init__.py`, `models/fusion_helpdesk_ticket_seen.py` *(new)* — `fusion.helpdesk.ticket.seen` read-tracking model.
|
||||
- `security/ir.model.access.csv` *(modify)* — ACL for the seen model.
|
||||
- `security/fusion_helpdesk_groups.xml` *(new)* — `group_reporter_admin`.
|
||||
- `static/src/js/fusion_helpdesk_dialog.js` *(modify)* — tabs (New / My Tickets), list, thread, reply.
|
||||
- `static/src/xml/fusion_helpdesk_dialog.xml` *(modify)* — tab markup + list/thread/reply templates + confirmed-email field.
|
||||
- `static/src/js/fusion_helpdesk_systray.js` *(modify)* — unread badge.
|
||||
- `static/src/xml/fusion_helpdesk_systray.xml` *(modify)* — badge markup.
|
||||
- `static/src/scss/fusion_helpdesk.scss` *(modify)* — tab/list/thread/badge styles.
|
||||
- `tests/__init__.py`, `tests/test_utils.py`, `tests/test_seen.py` *(new)*.
|
||||
- `__manifest__.py` *(modify)* — version bump, register groups XML + tests dir + new model.
|
||||
|
||||
**`fusion_helpdesk_central` (central)**
|
||||
- `models/__init__.py`, `models/helpdesk_ticket.py` *(new)* — inherit `helpdesk.ticket`, add `x_fc_client_label`.
|
||||
- `views/helpdesk_ticket_views.xml` *(new)* — list column + search filter for `x_fc_client_label`.
|
||||
- `data/mail_template_ack.xml` *(new)* — branded acknowledgement template.
|
||||
- `data/helpdesk_ack_automation.xml` *(new)* OR create-override in `helpdesk_ticket.py` — send ack on create.
|
||||
- `tests/__init__.py`, `tests/test_identity.py` *(new)* — partner resolution + follower + label.
|
||||
- `__manifest__.py` *(modify)* — version bump, register models/views/data/tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Keystone identity
|
||||
|
||||
### Task 1: Pure `build_ticket_vals` helper (client)
|
||||
|
||||
**Files:** Create `fusion_helpdesk/utils.py`; Test `fusion_helpdesk/tests/test_utils.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
```python
|
||||
# fusion_helpdesk/tests/test_utils.py
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
from odoo.addons.fusion_helpdesk.utils import build_ticket_vals
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||
class TestBuildTicketVals(TransactionCase):
|
||||
def test_identity_fields_present(self):
|
||||
vals = build_ticket_vals(
|
||||
kind='bug', subject='X', body_html='<p>b</p>',
|
||||
team_id=1, client_label='ENTECH',
|
||||
reporter_name='John Doe', reporter_email='john@entech.com',
|
||||
company_name='ENTECH Inc',
|
||||
)
|
||||
self.assertEqual(vals['partner_email'], 'john@entech.com')
|
||||
self.assertEqual(vals['partner_name'], 'John Doe')
|
||||
self.assertEqual(vals['x_fc_client_label'], 'ENTECH')
|
||||
self.assertEqual(vals['partner_company_name'], 'ENTECH Inc')
|
||||
self.assertEqual(vals['team_id'], 1)
|
||||
self.assertIn('X', vals['name'])
|
||||
|
||||
def test_no_email_omits_partner_email(self):
|
||||
vals = build_ticket_vals(
|
||||
kind='feature', subject='Y', body_html='<p>b</p>',
|
||||
team_id=False, client_label='', reporter_name='Jane',
|
||||
reporter_email='', company_name='',
|
||||
)
|
||||
self.assertNotIn('partner_email', vals) # never send empty email
|
||||
self.assertNotIn('team_id', vals) # omit falsy team
|
||||
self.assertEqual(vals['partner_name'], 'Jane')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect ImportError/FAIL**
|
||||
Run: `docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_helpdesk -u fusion_helpdesk --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -30`
|
||||
|
||||
- [ ] **Step 3: Implement `build_ticket_vals`**
|
||||
```python
|
||||
# fusion_helpdesk/utils.py
|
||||
"""Pure helpers for fusion_helpdesk — no Odoo env, unit-testable in isolation."""
|
||||
|
||||
def build_ticket_vals(kind, subject, body_html, team_id, client_label,
|
||||
reporter_name, reporter_email, company_name):
|
||||
"""Construct helpdesk.ticket create vals. Identity fields drive native
|
||||
partner find-or-create + follower subscription on the central Odoo."""
|
||||
kind_label = 'Bug Report' if kind == 'bug' else 'Feature Request'
|
||||
prefix = ('[%s] ' % client_label) if client_label else ''
|
||||
vals = {
|
||||
'name': '%s%s: %s' % (prefix, kind_label, subject or '(untitled)'),
|
||||
'description': body_html,
|
||||
'partner_name': reporter_name or '',
|
||||
}
|
||||
if team_id:
|
||||
vals['team_id'] = team_id
|
||||
if reporter_email:
|
||||
vals['partner_email'] = reporter_email
|
||||
if company_name:
|
||||
vals['partner_company_name'] = company_name
|
||||
if client_label:
|
||||
vals['x_fc_client_label'] = client_label
|
||||
return vals
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run — expect PASS** (same command as Step 2)
|
||||
- [ ] **Step 5: Commit** `git add fusion_helpdesk/utils.py fusion_helpdesk/tests/ && git commit -m "feat(fusion_helpdesk): pure build_ticket_vals helper (identity keystone)"`
|
||||
|
||||
### Task 2: Wire keystone into `submit()` (client)
|
||||
|
||||
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||
|
||||
- [ ] **Step 1:** In `submit()`, accept new arg `reply_email=None`. Replace the inline `ticket_vals` block with:
|
||||
```python
|
||||
from odoo.addons.fusion_helpdesk.utils import build_ticket_vals
|
||||
# ...
|
||||
user = request.env.user
|
||||
reporter_email = (reply_email or user.email or user.login or '').strip()
|
||||
body_html = '\n'.join(body_parts)
|
||||
ticket_vals = build_ticket_vals(
|
||||
kind=kind, subject=subject, body_html=body_html,
|
||||
team_id=cfg['team_id'], client_label=cfg['client_label'],
|
||||
reporter_name=user.name, reporter_email=reporter_email,
|
||||
company_name=request.env.company.name,
|
||||
)
|
||||
```
|
||||
- [ ] **Step 2:** Keep the existing create + attachment + return logic. Verify `_build_diag_block` still appends.
|
||||
- [ ] **Step 3: Manual sanity** — `docker exec odoo-modsdev-app odoo -d modsdev -u fusion_helpdesk --stop-after-init 2>&1 | tail -20` (module upgrades clean).
|
||||
- [ ] **Step 4: Commit** `git commit -am "feat(fusion_helpdesk): send partner identity in ticket payload"`
|
||||
|
||||
### Task 3: `x_fc_client_label` field on central
|
||||
|
||||
**Files:** Create `fusion_helpdesk_central/models/__init__.py`, `models/helpdesk_ticket.py`; Modify `__init__.py`, `__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Write failing test** (runs on Enterprise env)
|
||||
```python
|
||||
# fusion_helpdesk_central/tests/test_identity.py
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk_central')
|
||||
class TestTicketIdentity(TransactionCase):
|
||||
def test_label_field_and_partner_resolution(self):
|
||||
team = self.env['helpdesk.team'].search([], limit=1)
|
||||
t = self.env['helpdesk.ticket'].create({
|
||||
'name': 'T1', 'team_id': team.id,
|
||||
'partner_email': 'newperson@example.com',
|
||||
'partner_name': 'New Person',
|
||||
'x_fc_client_label': 'ENTECH',
|
||||
})
|
||||
self.assertEqual(t.x_fc_client_label, 'ENTECH')
|
||||
self.assertTrue(t.partner_id, "native create should resolve partner from email")
|
||||
self.assertIn(t.partner_id, t.message_partner_ids, "customer should be a follower")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement field**
|
||||
```python
|
||||
# fusion_helpdesk_central/models/helpdesk_ticket.py
|
||||
from odoo import fields, models
|
||||
|
||||
class HelpdeskTicket(models.Model):
|
||||
_inherit = 'helpdesk.ticket'
|
||||
|
||||
x_fc_client_label = fields.Char(
|
||||
string='Client Deployment', index=True, copy=False,
|
||||
help='Deployment tag (e.g. ENTECH) set by the in-app reporter. '
|
||||
'Scopes the embedded "My Tickets" inbox per client.',
|
||||
)
|
||||
```
|
||||
```python
|
||||
# fusion_helpdesk_central/models/__init__.py
|
||||
from . import helpdesk_ticket
|
||||
```
|
||||
- [ ] **Step 3:** `fusion_helpdesk_central/__init__.py` → add `from . import models`. `__manifest__.py` → `version` bump to `19.0.1.1.0`, add `'models'` import is implicit; add `views/helpdesk_ticket_views.xml` to `data`, add `tests` discovery (automatic).
|
||||
- [ ] **Step 4: Run on Enterprise** (deferred to Phase 6 deploy; can't run on local Community).
|
||||
- [ ] **Step 5: Commit** `git commit -am "feat(fusion_helpdesk_central): x_fc_client_label on helpdesk.ticket"`
|
||||
|
||||
### Task 4: Backend list/search exposure (central)
|
||||
|
||||
**Files:** Create `fusion_helpdesk_central/views/helpdesk_ticket_views.xml`
|
||||
- [ ] **Step 1:** Inherit the helpdesk ticket list + search to add `x_fc_client_label` (column `optional="show"`, search field + a group-by). Use `group_ids` not `groups_id` if gating (none needed here).
|
||||
```xml
|
||||
<odoo>
|
||||
<record id="fhc_ticket_list_label" model="ir.ui.view">
|
||||
<field name="name">fhc.helpdesk.ticket.list.label</field>
|
||||
<field name="model">helpdesk.ticket</field>
|
||||
<field name="inherit_id" ref="helpdesk.helpdesk_ticket_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="partner_id" position="after">
|
||||
<field name="x_fc_client_label" optional="show"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
<record id="fhc_ticket_search_label" model="ir.ui.view">
|
||||
<field name="name">fhc.helpdesk.ticket.search.label</field>
|
||||
<field name="model">helpdesk.ticket</field>
|
||||
<field name="inherit_id" ref="helpdesk.helpdesk_tickets_view_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="partner_id" position="after">
|
||||
<field name="x_fc_client_label"/>
|
||||
<filter string="Client Deployment" name="group_client_label"
|
||||
context="{'group_by': 'x_fc_client_label'}"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
> NOTE at execution: verify the exact `inherit_id` external IDs by reading the live views (`helpdesk.helpdesk_ticket_view_tree`, `helpdesk.helpdesk_tickets_view_search`) on odoo-nexa — names differ across versions. Adjust before install.
|
||||
- [ ] **Step 2: Commit** `git commit -am "feat(fusion_helpdesk_central): expose client label in ticket views"`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Read APIs + scoping (client)
|
||||
|
||||
### Task 5: Pure scoping + message-filter + unread helpers
|
||||
|
||||
**Files:** Modify `fusion_helpdesk/utils.py`; Modify `fusion_helpdesk/tests/test_utils.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
```python
|
||||
from odoo.addons.fusion_helpdesk.utils import (
|
||||
build_scope_domain, is_public_message, compute_unread_count)
|
||||
|
||||
def test_regular_scope_binds_email_and_label(self):
|
||||
dom = build_scope_domain(label='ENTECH', email='john@entech.com', is_admin=False)
|
||||
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
||||
self.assertIn(('partner_email', '=ilike', 'john@entech.com'), dom)
|
||||
|
||||
def test_admin_scope_binds_label_only(self):
|
||||
dom = build_scope_domain(label='ENTECH', email='a@entech.com', is_admin=True)
|
||||
self.assertIn(('x_fc_client_label', '=', 'ENTECH'), dom)
|
||||
self.assertFalse(any(t[0] == 'partner_email' for t in dom))
|
||||
|
||||
def test_admin_still_bounded_by_label(self):
|
||||
# label is ALWAYS present — no cross-deployment leakage
|
||||
self.assertTrue(build_scope_domain('ENTECH', 'a@x', True))
|
||||
|
||||
def test_internal_note_is_not_public(self):
|
||||
self.assertFalse(is_public_message({'subtype_is_internal': True}))
|
||||
self.assertTrue(is_public_message({'subtype_is_internal': False}))
|
||||
|
||||
def test_unread_count(self):
|
||||
tickets = [{'id': 1, 'last_support_msg_id': 10},
|
||||
{'id': 2, 'last_support_msg_id': 5},
|
||||
{'id': 3, 'last_support_msg_id': 0}]
|
||||
seen = {1: 10, 2: 3} # ticket 2 has newer support msg; 1 is read; 3 none
|
||||
self.assertEqual(compute_unread_count(tickets, seen), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
- [ ] **Step 3: Implement**
|
||||
```python
|
||||
def build_scope_domain(label, email, is_admin):
|
||||
"""Server-side ticket scope. label is ALWAYS bound (defense in depth)."""
|
||||
domain = [('x_fc_client_label', '=', label or '__none__')]
|
||||
if not is_admin:
|
||||
domain.append(('partner_email', '=ilike', email or '__none__'))
|
||||
return domain
|
||||
|
||||
def is_public_message(msg):
|
||||
"""True when a message is customer-visible (not an internal note)."""
|
||||
return not msg.get('subtype_is_internal', False)
|
||||
|
||||
def compute_unread_count(tickets, seen_by_id):
|
||||
"""Count tickets whose latest support message id exceeds the user's
|
||||
last-seen id for that ticket (0/absent = unseen baseline)."""
|
||||
n = 0
|
||||
for t in tickets:
|
||||
last = t.get('last_support_msg_id') or 0
|
||||
if last and last > (seen_by_id.get(t['id']) or 0):
|
||||
n += 1
|
||||
return n
|
||||
```
|
||||
- [ ] **Step 4: Run — PASS**; **Step 5: Commit**
|
||||
|
||||
### Task 6: `fusion.helpdesk.ticket.seen` model + ACL
|
||||
|
||||
**Files:** Create `fusion_helpdesk/models/__init__.py`, `models/fusion_helpdesk_ticket_seen.py`; Modify `__init__.py`, `security/ir.model.access.csv`, `__manifest__.py`; Test `fusion_helpdesk/tests/test_seen.py`
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
```python
|
||||
# tests/test_seen.py
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
@tagged('post_install', '-at_install', 'fusion_helpdesk')
|
||||
class TestSeen(TransactionCase):
|
||||
def test_mark_seen_upserts(self):
|
||||
Seen = self.env['fusion.helpdesk.ticket.seen']
|
||||
Seen._mark_seen(central_ticket_id=42, last_message_id=100)
|
||||
Seen._mark_seen(central_ticket_id=42, last_message_id=120)
|
||||
rec = Seen.search([('user_id', '=', self.env.uid),
|
||||
('central_ticket_id', '=', 42)])
|
||||
self.assertEqual(len(rec), 1)
|
||||
self.assertEqual(rec.last_seen_message_id, 120)
|
||||
|
||||
def test_seen_map(self):
|
||||
Seen = self.env['fusion.helpdesk.ticket.seen']
|
||||
Seen._mark_seen(1, 10); Seen._mark_seen(2, 20)
|
||||
self.assertEqual(Seen._seen_map([1, 2, 3]), {1: 10, 2: 20})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
- [ ] **Step 3: Implement model**
|
||||
```python
|
||||
# models/fusion_helpdesk_ticket_seen.py
|
||||
from odoo import api, fields, models
|
||||
|
||||
class FusionHelpdeskTicketSeen(models.Model):
|
||||
_name = 'fusion.helpdesk.ticket.seen'
|
||||
_description = 'Fusion Helpdesk — per-user read tracking (metadata only)'
|
||||
|
||||
user_id = fields.Many2one('res.users', required=True, index=True,
|
||||
default=lambda s: s.env.uid, ondelete='cascade')
|
||||
central_ticket_id = fields.Integer(required=True, index=True)
|
||||
last_seen_message_id = fields.Integer(default=0)
|
||||
|
||||
_user_ticket_uniq = models.Constraint(
|
||||
'UNIQUE(user_id, central_ticket_id)',
|
||||
'One seen-row per user per ticket.')
|
||||
|
||||
@api.model
|
||||
def _mark_seen(self, central_ticket_id, last_message_id):
|
||||
rec = self.search([('user_id', '=', self.env.uid),
|
||||
('central_ticket_id', '=', central_ticket_id)], limit=1)
|
||||
if rec:
|
||||
if last_message_id > rec.last_seen_message_id:
|
||||
rec.last_seen_message_id = last_message_id
|
||||
else:
|
||||
self.create({'central_ticket_id': central_ticket_id,
|
||||
'last_seen_message_id': last_message_id})
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _seen_map(self, central_ticket_ids):
|
||||
rows = self.search([('user_id', '=', self.env.uid),
|
||||
('central_ticket_id', 'in', central_ticket_ids)])
|
||||
return {r.central_ticket_id: r.last_seen_message_id for r in rows}
|
||||
```
|
||||
- [ ] **Step 4:** ACL CSV row:
|
||||
```csv
|
||||
access_fhd_seen_user,fusion.helpdesk.ticket.seen.user,model_fusion_helpdesk_ticket_seen,base.group_user,1,1,1,1
|
||||
```
|
||||
`models/__init__.py` → `from . import fusion_helpdesk_ticket_seen`; `__init__.py` → `from . import models`; manifest registers nothing extra (models auto).
|
||||
- [ ] **Step 5: Run — PASS**; **Step 6: Commit**
|
||||
|
||||
### Task 7: Admin group
|
||||
|
||||
**Files:** Create `fusion_helpdesk/security/fusion_helpdesk_groups.xml`; Modify `__manifest__.py` (add to `data`, FIRST so the group exists before ACLs reference it if needed)
|
||||
- [ ] **Step 1:**
|
||||
```xml
|
||||
<odoo>
|
||||
<record id="group_reporter_admin" model="res.groups">
|
||||
<field name="name">Helpdesk Reporter Admin</field>
|
||||
<field name="comment">Can view all tickets filed from this deployment in the in-app inbox.</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
> Odoo 19: NO `users`/`category_id` fields on res.groups. Keep the record minimal.
|
||||
- [ ] **Step 2:** Upgrade clean; **Step 3: Commit**
|
||||
|
||||
### Task 8: Read endpoints (`my_tickets`, `ticket_detail`, `unread_count`)
|
||||
|
||||
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||
|
||||
- [ ] **Step 1:** Add a mockable RPC seam + identity helper:
|
||||
```python
|
||||
def _identity(self):
|
||||
user = request.env.user
|
||||
cfg = self._read_config()
|
||||
return {
|
||||
'email': (user.email or user.login or '').strip(),
|
||||
'label': cfg['client_label'],
|
||||
'is_admin': user.has_group('fusion_helpdesk.group_reporter_admin'),
|
||||
'cfg': cfg,
|
||||
}
|
||||
|
||||
def _rpc(self, cfg, model, method, args, kw=None):
|
||||
uid, proxy = self._authenticate(cfg) # existing
|
||||
return proxy.execute_kw(cfg['db'], uid, cfg['password'], model, method, args, kw or {})
|
||||
```
|
||||
- [ ] **Step 2:** Implement endpoints (all `type='jsonrpc'`, `auth='user'`). `my_tickets` builds the scoped domain via `build_scope_domain`, `search_read` fields `[id, name, stage_id, write_date]`, plus a per-ticket latest public support message id (read `message_ids` or a dedicated query), then computes `has_unread` via the seen map. `ticket_detail` re-resolves the ticket through the scoped domain (reject if absent), reads public messages only (filter via `is_public_message` using each message's subtype internal flag fetched from central), and calls `_mark_seen`. `unread_count` returns `compute_unread_count(...)`.
|
||||
> Execution detail: fetch message subtype "internal" flag from central by reading `mail.message` fields `[author_id, date, body, message_type, subtype_id]` and resolving `subtype_id.internal` via a second read or by filtering `message_type='comment'` + excluding notes. Confirm the cleanest field set against the live `mail.message` model during execution.
|
||||
- [ ] **Step 3:** Manual: upgrade module; **Step 4: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Reply endpoint (client)
|
||||
|
||||
### Task 9: `ticket_reply`
|
||||
|
||||
**Files:** Modify `fusion_helpdesk/controllers/main.py`
|
||||
- [ ] **Step 1:** Endpoint `/fusion_helpdesk/ticket/<int:ticket_id>/reply`, `auth='user'`. Re-resolve ticket via scoped domain (reject if not in scope). Resolve author partner on central by the replier's email (find-or-create via `res.partner` search/create through bot, or pass `author_id` resolved from `partner_email`). Post:
|
||||
```python
|
||||
self._rpc(cfg, 'helpdesk.ticket', 'message_post', [ticket_id], {
|
||||
'body': body_html, # already-safe HTML (escape user text)
|
||||
'message_type': 'comment',
|
||||
'subtype_xmlid': 'mail.mt_comment',
|
||||
'author_id': author_partner_id,
|
||||
})
|
||||
```
|
||||
- [ ] **Step 2:** Escape the user's text to HTML server-side (reuse `_html_escape`). Mark seen after posting.
|
||||
- [ ] **Step 3:** Manual upgrade; **Step 4: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Client UI (dialog tabs, thread, badge)
|
||||
|
||||
### Task 10: Dialog tabs + My Tickets list + thread + reply + confirmed email
|
||||
|
||||
**Files:** Modify `static/src/js/fusion_helpdesk_dialog.js`, `static/src/xml/fusion_helpdesk_dialog.xml`, `static/src/scss/fusion_helpdesk.scss`
|
||||
- [ ] **Step 1:** Add to state: `tab:'new'|'list'|'thread'`, `tickets:[]`, `loadingList`, `current:{id,subject,messages,canReply}`, `replyBody`, `replyEmail` (default from a new `/fusion_helpdesk/whoami` or seeded via session user email — read `user.email` via `useService('user')`/`session`), `scope:'mine'|'all'`, `isAdmin`.
|
||||
- [ ] **Step 2:** Methods: `openList()` → rpc `/fusion_helpdesk/my_tickets` (with `scope`); `openTicket(id)` → rpc detail, switch to thread, refresh list badge; `sendReply()` → rpc reply then reload thread; `setScope()` (admin toggle). Add confirmed **Your email** input on the New tab bound to `state.replyEmail`, passed as `reply_email` in submit payload.
|
||||
- [ ] **Step 3:** Template: a tab header (New | My Tickets); New pane = existing form + email field; List pane = table (ref, subject, stage chip, unread dot) + admin Mine/All toggle; Thread pane = messages (author, date, body, attachments) + reply box + Back. Use `Markup`-safe rendering: render message bodies with `t-out` (OWL) since central returns sanitized HTML.
|
||||
- [ ] **Step 4:** SCSS for tabs/list/thread (follow Odoo kanban hex pattern + dark-mode `$o-webclient-color-scheme` branch per CLAUDE.md).
|
||||
- [ ] **Step 5:** Manual QA locally (dialog opens, tabs switch). **Step 6: Commit**
|
||||
|
||||
### Task 11: Systray unread badge
|
||||
|
||||
**Files:** Modify `static/src/js/fusion_helpdesk_systray.js`, `static/src/xml/fusion_helpdesk_systray.xml`, SCSS
|
||||
- [ ] **Step 1:** On setup, call `/fusion_helpdesk/unread_count`; store `state.unread`. Poll on an interval (e.g. 120s) and on dialog close. Show a badge bubble when `unread > 0`.
|
||||
- [ ] **Step 2:** Badge markup over the icon. **Step 3: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Central acknowledgement email
|
||||
|
||||
### Task 12: Branded acknowledgement template + send-on-create
|
||||
|
||||
**Files:** Create `fusion_helpdesk_central/data/mail_template_ack.xml`; Modify `models/helpdesk_ticket.py`, `__manifest__.py`
|
||||
- [ ] **Step 1:** `mail.template` on `helpdesk.ticket` with subject "We received your request [{{ object.ticket_ref }}]" and a body using the company email layout + a prominent button to `{{ object.get_base_url() }}{{ object.access_url }}` (magic link). Canadian English.
|
||||
- [ ] **Step 2:** Send on create via a create-override (central inherit), gated:
|
||||
```python
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
tickets = super().create(vals_list)
|
||||
tmpl = self.env.ref('fusion_helpdesk_central.mail_template_ticket_ack', raise_if_not_found=False)
|
||||
for t in tickets:
|
||||
if tmpl and t.partner_email and t.x_fc_client_label: # in-app channel only → avoid double-ack with native web form
|
||||
tmpl.send_mail(t.id, force_send=False)
|
||||
return tickets
|
||||
```
|
||||
> Decision: gate on `x_fc_client_label` so only in-app-channel tickets get OUR ack; external web/email customers rely on native confirmation (verify native behavior during deploy; widen the gate if native sends nothing).
|
||||
- [ ] **Step 3:** Register template data in manifest; **Step 4: Commit**
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Review, fix, deploy, smoke test
|
||||
|
||||
### Task 13: Code review + fix
|
||||
- [ ] Run the code-review skill / pr-review-toolkit `code-reviewer` + `silent-failure-hunter` over the diff. Fix HIGH/MEDIUM findings. Re-run client tests locally. Commit fixes.
|
||||
|
||||
### Task 14: Deploy + test central on odoo-nexa
|
||||
- [ ] Copy/confirm `fusion_helpdesk_central` source is visible to odoo-nexa (`/opt/odoo/custom-addons`).
|
||||
- [ ] Run module tests on nexa: `-u fusion_helpdesk_central --test-enable --test-tags /fusion_helpdesk_central --stop-after-init` (ephemeral http port). Fix failures.
|
||||
- [ ] Upgrade live: `-u fusion_helpdesk_central --stop-after-init` then restart `odoo-nexa-app`.
|
||||
|
||||
### Task 15: Deploy client on odoo-entech
|
||||
- [ ] Look up entech access (memory: DB `admin`; confirm container/SSH via Supabase quick_commands). Confirm entech's `fusion_helpdesk.client_label` (e.g. ENTECH) + remote config points at nexa.
|
||||
- [ ] Ensure `fusion_helpdesk` source present on entech; upgrade `-u fusion_helpdesk --stop-after-init`; restart.
|
||||
|
||||
### Task 16: Smoke test (one ticket)
|
||||
- [ ] From entech: file ONE test ticket via the dialog (or simulate the controller path).
|
||||
- [ ] On nexa: confirm the new ticket has `partner_id` resolved, `partner_email`/`partner_name`/`x_fc_client_label` set, customer is a follower, ack email queued/sent.
|
||||
- [ ] Reply as agent on nexa → confirm notification email to the reporter w/ magic link; confirm the entech dialog "My Tickets" shows the ticket + reply and the badge increments.
|
||||
- [ ] Confirm pre-existing identity-less tickets are untouched (the "lots already submitted" set) and do NOT leak across deployments in the inbox query.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (run before execution)
|
||||
- **Spec coverage:** keystone (T1-3), label field+views (T3-4), scoping (T5,8,9), seen/badge (T6,10,11), admin group (T7), ack email (T12), portal/native (config — verified live, no code), tests (T1,5,6 local + T3 enterprise), deploy+smoke (T14-16). ✓
|
||||
- **Placeholders:** none — code shown for all Python/XML; JS tasks specify state/methods/markup concretely. JS is manually QA'd (OWL unit tests out of scope).
|
||||
- **Type consistency:** `build_scope_domain(label,email,is_admin)`, `is_public_message(msg)`, `compute_unread_count(tickets,seen)`, `_mark_seen(central_ticket_id,last_message_id)`, `_seen_map(ids)`, `x_fc_client_label` — names consistent across tasks. ✓
|
||||
956
docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md
Normal file
956
docs/superpowers/plans/2026-05-27-nexacloud-billing-importer.md
Normal file
@@ -0,0 +1,956 @@
|
||||
# NexaCloud → Odoo Billing Importer (Sub-project #2a) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a one-time, re-runnable, read-only importer that backfills NexaCloud customers/plans/deployments into Odoo as a shadow copy (drafts, no charge) for dual-run reconciliation.
|
||||
|
||||
**Architecture:** A `fusion.billing.import.wizard` transient model. `_read_nexacloud_rows()` opens a read-only `psycopg2` connection (DSN from `ir.config_parameter`) and returns plain row dicts — the only code touching NexaCloud. `_import_rows(data, dry_run)` is pure Odoo: it upserts the `nexacloud` service, a `cpu_seconds` metric, Monthly/Yearly recurrences, partners+links (reusing `_resolve_or_create_partner`), a per-plan catalog (product + CPU-overage product + `fusion.billing.charge` with `plan_id` left NULL), and one **draft** shadow `sale.order` per deployment with the flat price set explicitly on the line. Shadow-safety holds by construction: draft + no payment token + charge `plan_id` NULL.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise (Python 3.12), `sale_subscription`, `account_accountant`, `payment_stripe`, `psycopg2`. Tests: `odoo.tests.common.TransactionCase` on odoo-trial.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-billing-importer-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Conventions for every task
|
||||
|
||||
- **Never code Odoo internals from memory** (repo CLAUDE.md rule #1). The uncertain internals (`recurring_invoice`, `is_subscription` on a draft order, `sale.subscription.plan` fields, `price_unit` stickiness, `sale.subscription.plan` `billing_period_unit` values) are *verified by the tests themselves* on odoo-trial — when a test fails because an assumption is wrong, fix the source, do not weaken the assertion.
|
||||
- **Models, not UI:** all logic lives in `_import_rows` / `_do_import` / `_import_*` model methods; the wizard button only calls them. This keeps everything testable under `TransactionCase`.
|
||||
- **Money:** CAD, prices are `Float`/`Monetary`. CPU overage: `price_per_unit=0.0075`, `unit_batch=3600`.
|
||||
- **New fields on native models:** `x_fc_*` prefix.
|
||||
- **Registering tests:** append `from . import test_importer` to `tests/__init__.py` in the task that creates it; commit `__init__.py` alongside so the package always imports.
|
||||
|
||||
## Test environment
|
||||
|
||||
Tests run on **odoo-trial** (Proxmox VM 316, Odoo 19 Enterprise, db `trial`) — local dev is Community and cannot install this module. One runner:
|
||||
|
||||
```bash
|
||||
bash scripts/fcb_test_on_trial.sh
|
||||
```
|
||||
|
||||
- It re-syncs the module to the sandbox and runs `-u fusion_centralize_billing --test-enable --test-tags /fusion_centralize_billing`.
|
||||
- **Pass condition:** output contains `FCB_EXIT=0`.
|
||||
- The script runs the **whole** FCB suite (it cannot target one test); every "run the test" step below means "run the suite, ~1–2 min".
|
||||
- **Never** run `--test-enable` against production `nexamain`.
|
||||
|
||||
## File structure (this plan)
|
||||
|
||||
```
|
||||
fusion_centralize_billing/
|
||||
__init__.py # + from . import wizards
|
||||
models/
|
||||
__init__.py # + from . import res_partner
|
||||
sale_order.py # + x_fc_* fields on the existing SaleOrder inherit
|
||||
res_partner.py # NEW: x_fc_stripe_customer_id
|
||||
wizards/
|
||||
__init__.py # NEW
|
||||
import_wizard.py # NEW: the importer (read + import logic)
|
||||
views/
|
||||
import_wizard_views.xml # NEW: wizard form + action + menu
|
||||
security/
|
||||
ir.model.access.csv # + wizard ACL line
|
||||
__manifest__.py # + views file
|
||||
tests/
|
||||
__init__.py # + from . import test_importer
|
||||
test_importer.py # NEW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffolding — x_fc fields, partner inherit, wizard skeleton, security, manifest
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/models/sale_order.py`
|
||||
- Create: `fusion_centralize_billing/models/res_partner.py`
|
||||
- Modify: `fusion_centralize_billing/models/__init__.py`
|
||||
- Create: `fusion_centralize_billing/wizards/__init__.py`
|
||||
- Create: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||
- Create: `fusion_centralize_billing/views/import_wizard_views.xml`
|
||||
- Modify: `fusion_centralize_billing/__init__.py`
|
||||
- Modify: `fusion_centralize_billing/security/ir.model.access.csv`
|
||||
- Modify: `fusion_centralize_billing/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Add `x_fc_*` fields to the existing `sale.order` inherit**
|
||||
|
||||
In `models/sale_order.py`, add these fields to the `SaleOrder` class (keep `_fc_rate_usage`):
|
||||
```python
|
||||
x_fc_nexacloud_subscription_id = fields.Char(
|
||||
index=True, copy=False,
|
||||
help="Source NexaCloud subscription id — the importer's idempotency key.")
|
||||
x_fc_nexacloud_deployment_id = fields.Char(index=True, copy=False)
|
||||
x_fc_billing_service_id = fields.Many2one(
|
||||
"fusion.billing.service", index=True, copy=False, ondelete="set null")
|
||||
x_fc_shadow = fields.Boolean(
|
||||
default=False, copy=False,
|
||||
help="Imported in shadow mode: Odoo computes but must not charge/post/email.")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create the `res.partner` inherit**
|
||||
|
||||
`fusion_centralize_billing/models/res_partner.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = "res.partner"
|
||||
|
||||
x_fc_stripe_customer_id = fields.Char(
|
||||
index=True, copy=False,
|
||||
help="Existing Stripe customer id imported from a source app, reused at flip.")
|
||||
```
|
||||
Append to `models/__init__.py`: `from . import res_partner`.
|
||||
|
||||
- [ ] **Step 3: Create the wizard skeleton**
|
||||
|
||||
`fusion_centralize_billing/wizards/__init__.py`:
|
||||
```python
|
||||
from . import import_wizard
|
||||
```
|
||||
|
||||
`fusion_centralize_billing/wizards/import_wizard.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
NEXACLOUD_CODE = "nexacloud"
|
||||
CPU_METRIC_CODE = "cpu_seconds"
|
||||
CPU_RATE_PER_CORE_HOUR = 0.0075 # NexaCloud CPU rate, CAD per core-hour
|
||||
CPU_SECONDS_PER_CORE_HOUR = 3600.0 # one core-hour = 3600 cpu-seconds
|
||||
|
||||
|
||||
class FusionBillingImportWizard(models.TransientModel):
|
||||
_name = "fusion.billing.import.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Importer"
|
||||
|
||||
dry_run = fields.Boolean(
|
||||
default=True,
|
||||
help="Read and report what would be imported, without writing anything.")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
|
||||
def action_run_import(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_rows()
|
||||
summary = self._import_rows(data, dry_run=self.dry_run)
|
||||
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": self._name,
|
||||
"res_id": self.id,
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_rows(self):
|
||||
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
|
||||
ir.config_parameter 'fusion_billing.nexacloud_dsn') and return rows as dicts.
|
||||
Raises UserError on a missing DSN or a failed connection."""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError(
|
||||
"NexaCloud DSN not configured. Set the 'fusion_billing.nexacloud_dsn' "
|
||||
"system parameter to a read-only Postgres connection string.")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001 - surface as a user error
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
data = {}
|
||||
cur.execute(
|
||||
"SELECT id, email, full_name, company, billing_email, billing_address, "
|
||||
"billing_city, billing_state, billing_postal_code, billing_country, "
|
||||
"tax_id, stripe_customer_id FROM users")
|
||||
data["users"] = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT id, name, price_monthly, price_yearly, cpu_seconds_quota, "
|
||||
"is_active FROM plans")
|
||||
data["plans"] = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT id, user_id, deployment_id, plan_id, status, billing_cycle, "
|
||||
"current_period_start, current_period_end FROM subscriptions")
|
||||
data["subscriptions"] = [dict(r) for r in cur.fetchall()]
|
||||
return data
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ----- import side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _import_rows(self, data, dry_run=False):
|
||||
"""Upsert NexaCloud rows into Odoo. Idempotent. With dry_run=True the writes
|
||||
happen inside a savepoint that is rolled back, so nothing persists."""
|
||||
if not dry_run:
|
||||
return self._do_import(data)
|
||||
result = {}
|
||||
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
result.update(self._do_import(data))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
result["dry_run"] = True
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _do_import(self, data):
|
||||
return {"created": {}, "updated": {}, "skipped": [], "failed": []}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the wizard view + action + menu**
|
||||
|
||||
`fusion_centralize_billing/views/import_wizard_views.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_billing_import_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.import.wizard.form</field>
|
||||
<field name="model">fusion.billing.import.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Import from NexaCloud">
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_run_import" type="object" string="Run Import"
|
||||
class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_billing_import_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Import from NexaCloud</field>
|
||||
<field name="res_model">fusion.billing.import.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_billing_root" name="Fusion Billing"
|
||||
parent="account.menu_finance" sequence="90"/>
|
||||
<menuitem id="menu_fusion_billing_import" name="Import from NexaCloud"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fusion_billing_import_wizard" sequence="10"
|
||||
groups="base.group_system"/>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Wire module imports, security, manifest**
|
||||
|
||||
Append to `fusion_centralize_billing/__init__.py`: `from . import wizards`.
|
||||
(Confirm it already has `from . import models` and `from . import controllers`; add the wizards line.)
|
||||
|
||||
Append to `security/ir.model.access.csv`:
|
||||
```
|
||||
access_fusion_billing_import_wizard,fusion.billing.import.wizard,model_fusion_billing_import_wizard,base.group_system,1,1,1,1
|
||||
```
|
||||
|
||||
In `__manifest__.py`, add the view to `data` (after the cron):
|
||||
```python
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"data/ir_cron.xml",
|
||||
"views/import_wizard_views.xml",
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify the module upgrades cleanly on odoo-trial**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0` (the 39 existing tests still pass; new model/fields/view load with no traceback).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/models/sale_order.py fusion_centralize_billing/models/res_partner.py fusion_centralize_billing/models/__init__.py fusion_centralize_billing/wizards/ fusion_centralize_billing/views/import_wizard_views.xml fusion_centralize_billing/__init__.py fusion_centralize_billing/security/ir.model.access.csv fusion_centralize_billing/__manifest__.py
|
||||
git commit -m "feat(billing): importer scaffold — x_fc fields, wizard, security, view"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Identity import (users → partners + links)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||
- Create: `fusion_centralize_billing/tests/test_importer.py`
|
||||
- Modify: `fusion_centralize_billing/tests/__init__.py`
|
||||
|
||||
- [ ] **Step 1: Register + write the failing test**
|
||||
|
||||
Append to `tests/__init__.py`: `from . import test_importer`.
|
||||
|
||||
`fusion_centralize_billing/tests/test_importer.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
def _fixture():
|
||||
"""Two users, one plan, two subscriptions (monthly + yearly) — the canonical
|
||||
NexaCloud row dicts the importer consumes."""
|
||||
return {
|
||||
"users": [
|
||||
{"id": "u-1", "email": "ar@acme.test", "full_name": "Acme Inc",
|
||||
"company": "Acme", "billing_email": "billing@acme.test",
|
||||
"billing_address": "1 Main St", "billing_city": "Toronto",
|
||||
"billing_state": "ON", "billing_postal_code": "M1M1M1",
|
||||
"billing_country": "CA", "tax_id": "123456789RT0001",
|
||||
"stripe_customer_id": "cus_ACME"},
|
||||
{"id": "u-2", "email": "ops@globex.test", "full_name": "Globex",
|
||||
"company": "Globex", "billing_email": None, "billing_address": None,
|
||||
"billing_city": None, "billing_state": None, "billing_postal_code": None,
|
||||
"billing_country": None, "tax_id": None, "stripe_customer_id": "cus_GLBX"},
|
||||
],
|
||||
"plans": [
|
||||
{"id": "p-1", "name": "Starter", "price_monthly": 20.0,
|
||||
"price_yearly": 200.0, "cpu_seconds_quota": 18000.0, "is_active": True},
|
||||
],
|
||||
"subscriptions": [
|
||||
{"id": "s-1", "user_id": "u-1", "deployment_id": "d-1", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "monthly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"},
|
||||
{"id": "s-2", "user_id": "u-2", "deployment_id": "d-2", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "yearly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2027-05-01"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterIdentity(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
self.Link = self.env['fusion.billing.account.link'].sudo()
|
||||
|
||||
def test_imports_users_as_partners_and_links(self):
|
||||
self.Wizard._import_rows({'users': _fixture()['users']})
|
||||
svc = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')])
|
||||
self.assertTrue(svc, "importer must find-or-create the nexacloud service")
|
||||
link1 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-1')])
|
||||
self.assertEqual(len(link1), 1)
|
||||
self.assertEqual(link1.partner_id.email, 'billing@acme.test') # billing_email wins
|
||||
self.assertEqual(link1.partner_id.city, 'Toronto')
|
||||
self.assertEqual(link1.partner_id.vat, '123456789RT0001')
|
||||
self.assertEqual(link1.partner_id.x_fc_stripe_customer_id, 'cus_ACME')
|
||||
self.assertEqual(link1.partner_id.country_id.code, 'CA')
|
||||
link2 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-2')])
|
||||
self.assertEqual(link2.partner_id.email, 'ops@globex.test') # falls back to email
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect failure**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: FAIL — `_do_import` returns the empty stub; no partners/links created.
|
||||
|
||||
- [ ] **Step 3: Implement service/metric/recurrence helpers + user import**
|
||||
|
||||
Replace the stub `_do_import` and add helpers in `wizards/import_wizard.py`:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_service(self):
|
||||
Service = self.env['fusion.billing.service']
|
||||
svc = Service.search([('code', '=', NEXACLOUD_CODE)], limit=1)
|
||||
return svc or Service.create({'name': 'NexaCloud', 'code': NEXACLOUD_CODE})
|
||||
|
||||
@api.model
|
||||
def _fc_cpu_metric(self):
|
||||
Metric = self.env['fusion.billing.metric']
|
||||
m = Metric.search([('code', '=', CPU_METRIC_CODE)], limit=1)
|
||||
return m or Metric.create({
|
||||
'name': 'CPU seconds', 'code': CPU_METRIC_CODE,
|
||||
'aggregation': 'sum', 'unit_label': 'CPU-seconds'})
|
||||
|
||||
@api.model
|
||||
def _fc_recurrence_plan(self, unit):
|
||||
Plan = self.env['sale.subscription.plan']
|
||||
plan = Plan.search([('billing_period_value', '=', 1),
|
||||
('billing_period_unit', '=', unit)], limit=1)
|
||||
if plan:
|
||||
return plan
|
||||
label = 'Monthly' if unit == 'month' else 'Yearly'
|
||||
return Plan.create({'name': label, 'billing_period_value': 1,
|
||||
'billing_period_unit': unit})
|
||||
|
||||
@api.model
|
||||
def _fc_resolve_country(self, value):
|
||||
Country = self.env['res.country']
|
||||
if not value:
|
||||
return Country.browse()
|
||||
v = value.strip()
|
||||
return Country.search(['|', ('code', '=ilike', v), ('name', '=ilike', v)], limit=1)
|
||||
|
||||
@staticmethod
|
||||
def _bump(summary, created, key):
|
||||
bucket = 'created' if created else 'updated'
|
||||
summary[bucket][key] = summary[bucket].get(key, 0) + 1
|
||||
|
||||
@api.model
|
||||
def _import_user(self, service, urow):
|
||||
Link = self.env['fusion.billing.account.link']
|
||||
ext = str(urow['id'])
|
||||
email = (urow.get('billing_email') or urow.get('email') or '').strip().lower() or None
|
||||
name = urow.get('full_name') or urow.get('company') or email or ext
|
||||
existed = bool(Link.search(
|
||||
[('service_id', '=', service.id), ('external_id', '=', ext)], limit=1))
|
||||
link = Link._resolve_or_create_partner(service, ext, name=name, email=email)
|
||||
vals = {}
|
||||
if urow.get('billing_address'):
|
||||
vals['street'] = urow['billing_address']
|
||||
if urow.get('billing_city'):
|
||||
vals['city'] = urow['billing_city']
|
||||
if urow.get('billing_postal_code'):
|
||||
vals['zip'] = urow['billing_postal_code']
|
||||
if urow.get('tax_id'):
|
||||
vals['vat'] = urow['tax_id']
|
||||
if urow.get('stripe_customer_id'):
|
||||
vals['x_fc_stripe_customer_id'] = urow['stripe_customer_id']
|
||||
country = self._fc_resolve_country(urow.get('billing_country'))
|
||||
if country:
|
||||
vals['country_id'] = country.id
|
||||
if vals:
|
||||
link.partner_id.write(vals)
|
||||
return link, not existed
|
||||
|
||||
@api.model
|
||||
def _do_import(self, data):
|
||||
service = self._fc_service()
|
||||
summary = {'created': {}, 'updated': {}, 'skipped': [], 'failed': []}
|
||||
partner_by_user = {}
|
||||
for u in data.get('users', []):
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
link, created = self._import_user(service, u)
|
||||
partner_by_user[str(u['id'])] = link.partner_id
|
||||
self._bump(summary, created, 'partners')
|
||||
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||
summary['failed'].append(
|
||||
{'kind': 'user', 'id': str(u.get('id')), 'error': str(e)})
|
||||
return summary
|
||||
```
|
||||
|
||||
> **Note:** `partner_by_user` and (Task 3) `plan_ctx_by_id` are **method-local** dicts — never set them as attributes on `self` (Odoo recordsets reject arbitrary attribute assignment). Tasks 3 and 4 add their loops to this same `_do_import` method, so the locals stay in scope.
|
||||
|
||||
- [ ] **Step 4: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0`; `TestImporterIdentity` passes. If `country_id.code` assertion fails, fix `_fc_resolve_country` (don't weaken the assertion).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py fusion_centralize_billing/tests/__init__.py
|
||||
git commit -m "feat(billing): importer identity (NexaCloud users -> partners + links)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Catalog import (plans → metric + products + charge, plan_id NULL)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterCatalog(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_imports_plan_as_charge_with_null_plan_id(self):
|
||||
self.Wizard._import_rows({'plans': _fixture()['plans']})
|
||||
metric = self.env['fusion.billing.metric'].search([('code', '=', 'cpu_seconds')])
|
||||
self.assertTrue(metric)
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
self.assertEqual(len(charge), 1)
|
||||
self.assertEqual(charge.metric_id, metric)
|
||||
self.assertEqual(charge.included_quota, 18000.0) # = plan.cpu_seconds_quota
|
||||
self.assertEqual(charge.unit_batch, 3600.0) # one core-hour
|
||||
self.assertAlmostEqual(charge.price_per_unit, 0.0075) # CAD per core-hour
|
||||
self.assertEqual(charge.charge_model, 'standard')
|
||||
self.assertFalse(charge.plan_id, "shadow: charge.plan_id must be NULL so the "
|
||||
"rating cron never auto-mutates order lines")
|
||||
self.assertTrue(charge.product_id, "charge needs an overage product")
|
||||
self.assertTrue(charge.product_id.recurring_invoice is False
|
||||
or charge.product_id.recurring_invoice in (False, None))
|
||||
|
||||
def test_charge_math_matches_nexacloud(self):
|
||||
# 18000 quota + 2 core-hours overage (7200s) -> 2 batches * $0.0075 = $0.015
|
||||
self.Wizard._import_rows({'plans': _fixture()['plans']})
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
_overage, amount = charge._compute_billable(18000.0 + 7200.0)
|
||||
self.assertAlmostEqual(amount, 0.015, places=4)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect failure**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: FAIL — no charge created (catalog import not implemented).
|
||||
|
||||
- [ ] **Step 3: Implement catalog import**
|
||||
|
||||
Add to `wizards/import_wizard.py`:
|
||||
```python
|
||||
@api.model
|
||||
def _import_plan(self, metric, prow):
|
||||
Product = self.env['product.product']
|
||||
Charge = self.env['fusion.billing.charge']
|
||||
plan_code = str(prow['id'])
|
||||
name = prow.get('name') or plan_code
|
||||
price_monthly = float(prow.get('price_monthly') or 0.0)
|
||||
price_yearly = float(prow.get('price_yearly') or 0.0)
|
||||
|
||||
sub_code = 'NC-PLAN-%s' % plan_code
|
||||
sub_product = Product.search([('default_code', '=', sub_code)], limit=1)
|
||||
created = False
|
||||
if not sub_product:
|
||||
sub_product = Product.create({
|
||||
'name': 'NexaCloud %s' % name, 'default_code': sub_code,
|
||||
'type': 'service', 'recurring_invoice': True,
|
||||
'list_price': price_monthly})
|
||||
created = True
|
||||
|
||||
ov_code = 'NC-CPU-OVG-%s' % plan_code
|
||||
ov_product = Product.search([('default_code', '=', ov_code)], limit=1)
|
||||
if not ov_product:
|
||||
ov_product = Product.create({
|
||||
'name': 'NexaCloud CPU overage (%s)' % name, 'default_code': ov_code,
|
||||
'type': 'service', 'list_price': 0.0})
|
||||
|
||||
charge_vals = {
|
||||
'name': 'NexaCloud CPU overage — %s' % name,
|
||||
'plan_code': plan_code, 'metric_id': metric.id, 'product_id': ov_product.id,
|
||||
'included_quota': float(prow.get('cpu_seconds_quota') or 0.0),
|
||||
'price_per_unit': CPU_RATE_PER_CORE_HOUR, 'unit_batch': CPU_SECONDS_PER_CORE_HOUR,
|
||||
'charge_model': 'standard',
|
||||
# plan_id intentionally omitted (NULL) — shadow safety guarantee #3
|
||||
}
|
||||
charge = Charge.search(
|
||||
[('plan_code', '=', plan_code), ('metric_id', '=', metric.id)], limit=1)
|
||||
if charge:
|
||||
charge.write(charge_vals)
|
||||
else:
|
||||
charge = Charge.create(charge_vals)
|
||||
created = True
|
||||
return {'sub_product': sub_product, 'overage_product': ov_product,
|
||||
'charge': charge, 'price_monthly': price_monthly,
|
||||
'price_yearly': price_yearly}, created
|
||||
```
|
||||
In `_do_import`, after the users loop, add the plans loop:
|
||||
```python
|
||||
metric = self._fc_cpu_metric()
|
||||
plan_ctx_by_id = {}
|
||||
for p in data.get('plans', []):
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
ctx, created = self._import_plan(metric, p)
|
||||
plan_ctx_by_id[str(p['id'])] = ctx
|
||||
self._bump(summary, created, 'plans')
|
||||
except Exception as e: # noqa: BLE001
|
||||
summary['failed'].append(
|
||||
{'kind': 'plan', 'id': str(p.get('id')), 'error': str(e)})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0`; both catalog tests pass. If `product.product` rejects `recurring_invoice` or `type='service'`, read the field on odoo-trial and fix the source.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "feat(billing): importer catalog (plans -> products + CPU charge, plan_id NULL)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Subscription import (deployments → draft shadow sale.order)
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/wizards/import_wizard.py`
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterSubscriptions(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_imports_one_draft_shadow_subscription_per_deployment(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
SaleOrder = self.env['sale.order']
|
||||
sub1 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
self.assertEqual(len(sub1), 1)
|
||||
self.assertTrue(sub1.is_subscription)
|
||||
self.assertTrue(sub1.x_fc_shadow)
|
||||
self.assertEqual(sub1.x_fc_nexacloud_deployment_id, 'd-1')
|
||||
self.assertNotEqual(sub1.subscription_state, '3_progress') # left in draft
|
||||
# monthly flat price set explicitly on the plan product line
|
||||
plan_line = sub1.order_line.filtered(
|
||||
lambda l: l.product_id.default_code == 'NC-PLAN-p-1')
|
||||
self.assertEqual(len(plan_line), 1)
|
||||
self.assertAlmostEqual(plan_line.price_unit, 20.0) # price_monthly
|
||||
# the yearly subscription gets the yearly price + yearly recurrence
|
||||
sub2 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-2')])
|
||||
line2 = sub2.order_line.filtered(lambda l: l.product_id.default_code == 'NC-PLAN-p-1')
|
||||
self.assertAlmostEqual(line2.price_unit, 200.0) # price_yearly
|
||||
self.assertEqual(sub2.plan_id.billing_period_unit, 'year')
|
||||
|
||||
def test_subscription_skipped_when_user_or_plan_unresolved(self):
|
||||
data = _fixture()
|
||||
data['subscriptions'].append(
|
||||
{"id": "s-3", "user_id": "u-missing", "deployment_id": "d-3", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "monthly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"})
|
||||
summary = self.Wizard._import_rows(data)
|
||||
self.assertFalse(self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-3')]))
|
||||
self.assertTrue(any(s.get('id') == 's-3' for s in summary['skipped']))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect failure**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: FAIL — no subscriptions created (subscription import not implemented).
|
||||
|
||||
- [ ] **Step 3: Implement subscription import**
|
||||
|
||||
Add to `wizards/import_wizard.py`:
|
||||
```python
|
||||
@api.model
|
||||
def _import_subscription(self, service, partner, plan_ctx, recurrence_plans, srow):
|
||||
SaleOrder = self.env['sale.order']
|
||||
SaleOrderLine = self.env['sale.order.line']
|
||||
sub_ext = str(srow['id'])
|
||||
cycle = (srow.get('billing_cycle') or 'monthly').lower()
|
||||
rec_plan = recurrence_plans['yearly'] if cycle == 'yearly' else recurrence_plans['monthly']
|
||||
price = plan_ctx['price_yearly'] if cycle == 'yearly' else plan_ctx['price_monthly']
|
||||
product = plan_ctx['sub_product']
|
||||
order_vals = {
|
||||
'partner_id': partner.id, 'plan_id': rec_plan.id,
|
||||
'x_fc_nexacloud_subscription_id': sub_ext,
|
||||
'x_fc_nexacloud_deployment_id': str(srow.get('deployment_id') or ''),
|
||||
'x_fc_billing_service_id': service.id, 'x_fc_shadow': True,
|
||||
}
|
||||
existing = SaleOrder.search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
|
||||
if existing:
|
||||
existing.write(order_vals)
|
||||
line = existing.order_line.filtered(lambda l: l.product_id == product)
|
||||
line_vals = {'product_uom_qty': 1, 'price_unit': price}
|
||||
if line:
|
||||
line.write(line_vals)
|
||||
else:
|
||||
SaleOrderLine.create(dict(order_id=existing.id, product_id=product.id, **line_vals))
|
||||
order = existing
|
||||
created = False
|
||||
else:
|
||||
order_vals['order_line'] = [(0, 0, {
|
||||
'product_id': product.id, 'product_uom_qty': 1, 'price_unit': price})]
|
||||
order = SaleOrder.create(order_vals)
|
||||
created = True
|
||||
# guarantee the explicit price stuck (a pricelist compute may have overwritten it)
|
||||
line = order.order_line.filtered(lambda l: l.product_id == product)
|
||||
if line and line.price_unit != price:
|
||||
line.price_unit = price
|
||||
return order, created
|
||||
```
|
||||
In `_do_import`, before `return summary`, add the recurrences + subscriptions loop:
|
||||
```python
|
||||
recurrence_plans = {'monthly': self._fc_recurrence_plan('month'),
|
||||
'yearly': self._fc_recurrence_plan('year')}
|
||||
for s in data.get('subscriptions', []):
|
||||
partner = partner_by_user.get(str(s.get('user_id') or ''))
|
||||
ctx = plan_ctx_by_id.get(str(s.get('plan_id') or ''))
|
||||
if not partner or not ctx:
|
||||
summary['skipped'].append({
|
||||
'kind': 'subscription', 'id': str(s.get('id')),
|
||||
'reason': 'unresolved %s' % ('user' if not partner else 'plan')})
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
_order, created = self._import_subscription(
|
||||
service, partner, ctx, recurrence_plans, s)
|
||||
self._bump(summary, created, 'subscriptions')
|
||||
except Exception as e: # noqa: BLE001
|
||||
summary['failed'].append(
|
||||
{'kind': 'subscription', 'id': str(s.get('id')), 'error': str(e)})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0`. If `is_subscription` is False on the draft order, that disproves the design assumption — read `sale_order.py` in `sale_subscription` on odoo-trial and adjust how the subscription is created (e.g. set the field driving `is_subscription`), never weaken the assertion. If `billing_period_unit` rejects `'year'`, read the selection values and fix `_fc_recurrence_plan`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "feat(billing): importer subscriptions (one draft shadow sale.order per deployment)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Idempotency + dry-run
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterIdempotencyDryRun(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def _counts(self):
|
||||
return (
|
||||
self.env['fusion.billing.account.link'].search_count([]),
|
||||
self.env['fusion.billing.charge'].search_count([]),
|
||||
self.env['sale.order'].search_count([('x_fc_shadow', '=', True)]),
|
||||
)
|
||||
|
||||
def test_rerun_updates_not_duplicates(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
before = self._counts()
|
||||
# change a value and re-run; counts stay the same, value updates
|
||||
data = _fixture()
|
||||
data['plans'][0]['cpu_seconds_quota'] = 99999.0
|
||||
self.Wizard._import_rows(data)
|
||||
self.assertEqual(self._counts(), before, "re-run must upsert, not duplicate")
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
self.assertEqual(charge.included_quota, 99999.0)
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
summary = self.Wizard._import_rows(_fixture(), dry_run=True)
|
||||
self.assertTrue(summary.get('dry_run'))
|
||||
self.assertEqual(self._counts(), (0, 0, 0), "dry-run must not persist anything")
|
||||
# the nexacloud service is created inside the rolled-back savepoint too
|
||||
self.assertFalse(self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')]))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0` — idempotency and dry-run already hold from Tasks 2–4 + the savepoint in `_import_rows`. If the dry-run leaves a `nexacloud` service behind, the savepoint isn't wrapping `_fc_service` — confirm `_do_import` (which creates the service) runs entirely inside the `with self.env.cr.savepoint()` block.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "test(billing): importer idempotency + dry-run"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Shadow-mode safety assertions
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterShadowSafety(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_import_creates_no_invoice_and_no_payment_token(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
self.assertTrue(subs)
|
||||
partners = subs.mapped('partner_id')
|
||||
# no posted/draft customer invoice for any imported partner
|
||||
invoices = self.env['account.move'].search([
|
||||
('partner_id', 'in', partners.ids), ('move_type', '=', 'out_invoice')])
|
||||
self.assertFalse(invoices, "shadow import must not create any invoice")
|
||||
# no Stripe payment token -> charging is physically impossible
|
||||
tokens = self.env['payment.token'].search([('partner_id', 'in', partners.ids)])
|
||||
self.assertFalse(tokens, "shadow import must not attach a payment token")
|
||||
# every imported charge has a NULL plan_id so the rating cron skips it
|
||||
charges = self.env['fusion.billing.charge'].search([('plan_code', 'like', 'p-%')])
|
||||
self.assertTrue(charges)
|
||||
self.assertFalse(any(charges.mapped('plan_id')))
|
||||
|
||||
def test_rating_cron_leaves_shadow_subscriptions_untouched(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
lines_before = sum(len(s.order_line) for s in subs)
|
||||
self.env['fusion.billing.usage']._cron_rate_open_periods()
|
||||
subs.invalidate_recordset()
|
||||
lines_after = sum(len(s.order_line) for s in subs)
|
||||
self.assertEqual(lines_before, lines_after,
|
||||
"charges with NULL plan_id must keep the rating cron a no-op")
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0` — the safety properties hold by construction (draft, no token, NULL plan_id). If `payment.token` is not a valid model name in this build, read the `payment` model names on odoo-trial and use the correct one (don't drop the assertion). If an invoice *is* found, the draft-import guarantee is broken — investigate whether `sale.order.create` auto-invoices, and stop confirming/posting.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "test(billing): importer shadow-mode safety (no invoice/token, cron no-op)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Error handling — malformed rows isolated
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterErrorIsolation(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_one_bad_user_does_not_abort_the_batch(self):
|
||||
data = _fixture()
|
||||
# a row with no id -> str(urow['id']) raises KeyError, must be caught per-row
|
||||
data['users'].insert(0, {"email": "broken@x.test"})
|
||||
summary = self.Wizard._import_rows(data)
|
||||
# the two good users still import
|
||||
self.assertEqual(
|
||||
self.env['fusion.billing.account.link'].search_count([]), 2)
|
||||
self.assertTrue(summary['failed'], "the bad row must be recorded in failed[]")
|
||||
self.assertTrue(any(f['kind'] == 'user' for f in summary['failed']))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0` — the per-row `try/except` + `savepoint` already isolates failures. If the whole batch aborts, the `savepoint` is missing around `_import_user` or the broad `except` is too narrow — fix so one bad row never poisons the cursor.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "test(billing): importer per-row error isolation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Read path — DSN guard
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_centralize_billing/tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (append to `test_importer.py`)
|
||||
|
||||
```python
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterReadGuard(TransactionCase):
|
||||
|
||||
def test_missing_dsn_raises_usererror(self):
|
||||
# ensure no DSN is configured in the test DB
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||
with self.assertRaises(UserError):
|
||||
wiz._read_nexacloud_rows()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it, expect pass**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0` — `_read_nexacloud_rows` raises `UserError` when the DSN param is empty (implemented in Task 1). If `psycopg2` import fails on odoo-trial, confirm it ships with the image (it does — Odoo depends on it).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add fusion_centralize_billing/tests/test_importer.py
|
||||
git commit -m "test(billing): importer read-path DSN guard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Full suite + static checks
|
||||
|
||||
**Files:** none (verification task)
|
||||
|
||||
- [ ] **Step 1: Full test run**
|
||||
|
||||
Run: `bash scripts/fcb_test_on_trial.sh`
|
||||
Expected: `FCB_EXIT=0`, no `FAIL`/`ERROR` lines for `fusion_centralize_billing`.
|
||||
|
||||
- [ ] **Step 2: No `_sql_constraints` regressions**
|
||||
|
||||
Run: `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo "clean"`
|
||||
Expected: `clean`.
|
||||
|
||||
- [ ] **Step 3: No bare `sale.subscription` model references**
|
||||
|
||||
Run: `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ || echo "clean"`
|
||||
Expected: `clean` (only `sale.subscription.plan` is valid).
|
||||
|
||||
- [ ] **Step 4: Pyflakes the new Python**
|
||||
|
||||
Run: `docker exec odoo-modsdev-app python3 -m pyflakes fusion_centralize_billing/wizards/import_wizard.py fusion_centralize_billing/models/res_partner.py 2>&1 | tail -20 || true`
|
||||
Expected: no undefined names (catches the kind of `_norm_email` NameError the helpdesk smoke test missed).
|
||||
|
||||
- [ ] **Step 5: Commit (if any fixes)**
|
||||
|
||||
```bash
|
||||
git add -A fusion_centralize_billing/
|
||||
git commit -m "test(billing): 2a importer full suite green + static checks"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Done = 2a importer complete
|
||||
|
||||
A NexaCloud backfill produces, idempotently: unified partners + links, a `cpu_seconds` charge catalog (`plan_id` NULL), and one draft shadow `sale.order` per deployment carrying the exact NexaCloud flat price — with zero customer-visible billing in Odoo (no invoice, no token, rating cron a no-op). The `psycopg2` read path is ready; the live run is gated only on the read-only DSN grant.
|
||||
|
||||
## Next (not this plan)
|
||||
|
||||
- 2b: NexaCloud `usage_metering.py` pushes cpu-seconds (= core-hours × 3600) to `POST /usage`.
|
||||
- 2c: NexaCloud consumes `invoice.payment_failed` / `subscription.terminated` webhooks → throttle/deprovision.
|
||||
- 2d: `fusion.billing.reconciliation` diffs Odoo-computed (flat + `charge._compute_billable`) vs NexaCloud actuals per period; flip when within tolerance (set `charge.plan_id`, attach tokens, confirm subs).
|
||||
637
docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
Normal file
637
docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# NexaCloud → Odoo Invoice Ledger — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Ingest NexaCloud's real (Stripe-billed) invoices into Odoo as posted `account.move` customer invoices with reconciled payments + HST, so Odoo is the accounting system of record — all history + ongoing, revenue split by service family, draft-first on the live books.
|
||||
|
||||
**Architecture:** A new ingester in `fusion_centralize_billing` mirroring the importer's read/write split: `_read_nexacloud_invoices` (read-only psycopg2 via the existing DSN) → `_ingest_invoices` (pure Odoo: create `account.move` drafts idempotently, map lines to per-family income accounts, derive tax, reconcile Stripe payments) → `_post_ingested` (bulk-post after review). Reuses the `account.link` partner mapping. Native Odoo accounting does the rest.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise, `account_accountant`, `psycopg2`. Tests: `TransactionCase` on odoo-trial (`bash scripts/fcb_test_on_trial.sh`, pass = `FCB_EXIT=0`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
- **Never code accounting internals from memory** (CLAUDE rule #1). Reference confirmed on trial: `account.move` has `invoice_line_ids`/`invoice_date`/`action_post`; `account.payment.register` exists; `account_type='income'`/`'asset_receivable'` valid; sale taxes are Canadian (find HST 13% by `amount=13` / name). Where a step says "read reference", confirm before relying on it.
|
||||
- **Models, not UI:** logic in model methods; the wizard only calls them. Testable under `TransactionCase`.
|
||||
- **New fields on native models:** `x_fc_*`. Declarative `models.Constraint` only.
|
||||
- Tests run on **odoo-trial** (`bash scripts/fcb_test_on_trial.sh`, full suite, ~1–2 min). Register each new `tests/test_*.py` in `tests/__init__.py` in the same task.
|
||||
|
||||
## File structure
|
||||
```
|
||||
fusion_centralize_billing/
|
||||
models/
|
||||
account_move.py # NEW: account.move inherit (x_fc_nexacloud_invoice_id, x_fc_stripe_invoice_id)
|
||||
__init__.py # + account_move
|
||||
wizards/
|
||||
invoice_ledger.py # NEW: the ingester (read + ingest + post + family/tax/payment helpers)
|
||||
__init__.py # + invoice_ledger
|
||||
views/
|
||||
invoice_ledger_views.xml # NEW: wizard form + action + menu + cron
|
||||
security/ir.model.access.csv # + ledger wizard ACL
|
||||
__manifest__.py # + views/invoice_ledger_views.xml
|
||||
tests/
|
||||
test_invoice_ledger.py # NEW
|
||||
__init__.py # + test_invoice_ledger
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffold — account.move fields + ledger wizard skeleton
|
||||
|
||||
**Files:** create `models/account_move.py`, `wizards/invoice_ledger.py`, `views/invoice_ledger_views.xml`; modify `models/__init__.py`, `wizards/__init__.py`, `security/ir.model.access.csv`, `__manifest__.py`.
|
||||
|
||||
- [ ] **Step 1: account.move inherit** — `models/account_move.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
x_fc_nexacloud_invoice_id = fields.Char(
|
||||
index=True, copy=False, help="Source NexaCloud invoice id — ledger idempotency key.")
|
||||
x_fc_stripe_invoice_id = fields.Char(index=True, copy=False)
|
||||
|
||||
_fc_nc_invoice_uniq = models.Constraint(
|
||||
"unique(x_fc_nexacloud_invoice_id)",
|
||||
"One Odoo invoice per NexaCloud invoice id.")
|
||||
```
|
||||
Add `from . import account_move` to `models/__init__.py`.
|
||||
|
||||
- [ ] **Step 2: ledger wizard skeleton** — `wizards/invoice_ledger.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
_name = "fusion.billing.invoice.ledger.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Invoice Ledger Ingester"
|
||||
|
||||
dry_run = fields.Boolean(default=True)
|
||||
auto_post = fields.Boolean(
|
||||
default=False, help="Post invoices immediately (else leave draft for review).")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
|
||||
def _ingest_invoices(self, data, post=False):
|
||||
return {"created": 0, "updated": 0, "posted": 0, "skipped": [], "failed": [], "by_family": {}}
|
||||
```
|
||||
Add `from . import invoice_ledger` to `wizards/__init__.py`.
|
||||
|
||||
- [ ] **Step 3: view + action + menu** — `views/invoice_ledger_views.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fc_invoice_ledger_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.invoice.ledger.wizard.form</field>
|
||||
<field name="model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Ingest NexaCloud Invoices">
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
<field name="auto_post"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_run" type="object" string="Run" class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="action_fc_invoice_ledger_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Ingest NexaCloud Invoices</field>
|
||||
<field name="res_model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
<menuitem id="menu_fc_invoice_ledger" name="Ingest NexaCloud Invoices"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fc_invoice_ledger_wizard" sequence="20"
|
||||
groups="base.group_system"/>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: security + manifest** — append to `security/ir.model.access.csv`:
|
||||
```
|
||||
access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1
|
||||
```
|
||||
Add `"views/invoice_ledger_views.xml"` to `__manifest__.py` `data`.
|
||||
|
||||
- [ ] **Step 5: verify upgrade** — `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0` (existing tests pass; new model/fields/view load).
|
||||
|
||||
- [ ] **Step 6: commit** — `feat(billing): invoice-ledger scaffold (account.move x_fc fields + wizard)`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Service-family classification + income account
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`; create `tests/test_invoice_ledger.py` (+ register in `tests/__init__.py`).
|
||||
|
||||
- [ ] **Step 1: failing test** — `tests/test_invoice_ledger.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerFamily(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_family_classification(self):
|
||||
f = self.W._fc_family_for
|
||||
self.assertEqual(f('Odoo ERP Hosting (2026-05-01 to 2026-06-01)'), 'hosting')
|
||||
self.assertEqual(f('WordPress Website Hosting - Managed (at $50.00 / month)'), 'hosting')
|
||||
self.assertEqual(f('Managed Odoo - Standard (at $49.99 / month)'), 'managed')
|
||||
self.assertEqual(f('Daily Backup Protection'), 'addons')
|
||||
self.assertEqual(f('Remaining time on Daily Backup Protection after 27 May 2026'), 'addons')
|
||||
self.assertEqual(f('Something Unmapped'), 'other')
|
||||
|
||||
def test_income_account_per_family_distinct(self):
|
||||
a_host = self.W._fc_income_account('hosting')
|
||||
a_add = self.W._fc_income_account('addons')
|
||||
self.assertEqual(a_host.account_type, 'income')
|
||||
self.assertNotEqual(a_host, a_add) # split by family
|
||||
self.assertEqual(self.W._fc_income_account('hosting'), a_host) # idempotent
|
||||
```
|
||||
Append `from . import test_invoice_ledger` to `tests/__init__.py`.
|
||||
|
||||
- [ ] **Step 2: run** → FAIL (`_fc_family_for` missing).
|
||||
|
||||
- [ ] **Step 3: implement** — in `wizards/invoice_ledger.py`:
|
||||
```python
|
||||
_FAMILY_KEYWORDS = [
|
||||
('hosting', ['odoo erp hosting', 'wordpress website hosting']),
|
||||
('managed', ['managed']),
|
||||
('addons', ['daily backup', 'whatsapp', 'forms builder', 'white label']),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _fc_family_for(self, description):
|
||||
import re
|
||||
d = (description or '').lower()
|
||||
m = re.match(r'remaining time on (.+?)(?: after| from |\s*\()', d)
|
||||
if m:
|
||||
d = m.group(1) # classify proration by the prorated item
|
||||
for fam, kws in self._FAMILY_KEYWORDS:
|
||||
if any(k in d for k in kws):
|
||||
return fam
|
||||
return 'other'
|
||||
|
||||
@api.model
|
||||
def _fc_income_account(self, family):
|
||||
Account = self.env['account.account']
|
||||
code = 'NCR-' + family.upper()[:6]
|
||||
acc = Account.search([('code', '=', code)], limit=1)
|
||||
if not acc:
|
||||
acc = Account.create({
|
||||
'code': code, 'name': 'NexaCloud %s Revenue' % family.title(),
|
||||
'account_type': 'income'})
|
||||
return acc
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If `account.account.create` needs more required fields on this build, read `account_account.py` on trial and add them — don't weaken the test.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ledger service-family classification + per-family income accounts`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Tax derivation (match NexaCloud's invoice.tax)
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerTax(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
|
||||
tax = self.W._fc_tax_for(100.0, 13.0)
|
||||
self.assertTrue(tax, "expected an HST/13% sale tax on the Canadian COA")
|
||||
self.assertEqual(tax.type_tax_use, 'sale')
|
||||
# the chosen tax computes 13.00 on 100.00
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 13.0, places=2)
|
||||
|
||||
def test_tax_for_zero_is_zero_or_empty(self):
|
||||
tax = self.W._fc_tax_for(100.0, 0.0)
|
||||
if tax:
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 0.0, places=2)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement**:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_tax_for(self, subtotal, tax_amount):
|
||||
"""Map a NexaCloud invoice's (subtotal, tax_amount) to the Odoo sale tax whose
|
||||
computed tax equals it. Picks by effective percent; falls back to a 0% sale tax."""
|
||||
Tax = self.env['account.tax']
|
||||
sub = float(subtotal or 0.0)
|
||||
tax_amt = float(tax_amount or 0.0)
|
||||
if sub <= 0 or tax_amt <= 0:
|
||||
return Tax.search([('type_tax_use', '=', 'sale'), ('amount', '=', 0.0)], limit=1)
|
||||
rate = round(100.0 * tax_amt / sub)
|
||||
tax = Tax.search([('type_tax_use', '=', 'sale'), ('amount_type', '=', 'percent'),
|
||||
('amount', '=', float(rate))], limit=1)
|
||||
if not tax:
|
||||
tax = Tax.search([('type_tax_use', '=', 'sale'), ('name', 'ilike', '%s' % rate)], limit=1)
|
||||
return tax
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (Read reference if no 13% sale tax exists: `docker exec odoo-trial-app ... grep -i hst` the l10n_ca data; on nexamain confirm the HST 13% record from `nexa_coa_setup`.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ledger tax derivation matching source invoice tax`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Ingest invoices → draft account.move (idempotent)
|
||||
|
||||
**Read reference first:**
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"def action_post|invoice_line_ids|move_type\\\" /mnt/enterprise-addons/account_accountant/../account/models/account_move.py | head\"'"
|
||||
```
|
||||
Confirm `account.move.create({'move_type':'out_invoice','partner_id':..,'invoice_line_ids':[(0,0,{'name','quantity','price_unit','account_id','tax_ids'})]})` and `move.amount_untaxed/amount_tax/amount_total`.
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append) — uses a fixture invoice dict shaped like `_read_nexacloud_invoices` output:
|
||||
```python
|
||||
def _inv_fixture():
|
||||
return [{
|
||||
'id': 'inv-1', 'stripe_invoice_id': 'in_test1', 'invoice_number': 'NEX-0001',
|
||||
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
||||
'subtotal': 100.0, 'tax': 13.0, 'amount_paid': 0.0, 'paid_at': None,
|
||||
'items': [{'description': 'Odoo ERP Hosting (2026-05-01 to 2026-06-01)',
|
||||
'quantity': 1.0, 'unit_price': 100.0, 'amount': 100.0}],
|
||||
}]
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerIngest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.svc = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
|
||||
def test_ingest_creates_draft_invoice_with_right_totals(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(len(mv), 1)
|
||||
self.assertEqual(mv.move_type, 'out_invoice')
|
||||
self.assertEqual(mv.state, 'draft')
|
||||
self.assertAlmostEqual(mv.amount_untaxed, 100.0, places=2)
|
||||
self.assertAlmostEqual(mv.amount_tax, 13.0, places=2) # equals source tax
|
||||
self.assertAlmostEqual(mv.amount_total, 113.0, places=2)
|
||||
self.assertEqual(mv.partner_id.email, 'ar@acme.test')
|
||||
line = mv.invoice_line_ids
|
||||
self.assertEqual(line.account_id, self.W._fc_income_account('hosting'))
|
||||
|
||||
def test_ingest_is_idempotent(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.assertEqual(self.env['account.move'].search_count(
|
||||
[('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement** the partner resolver + `_ingest_invoices`:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_partner_for(self, inv):
|
||||
"""Resolve the unified partner for an invoice via the nexacloud account.link
|
||||
(by user_external_id); create partner+link if missing (covers NULL-subscription
|
||||
invoices, which still carry a user)."""
|
||||
service = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')], limit=1)
|
||||
link = self.env['fusion.billing.account.link']._resolve_or_create_partner(
|
||||
service, str(inv.get('user_external_id')),
|
||||
name=inv.get('partner_name'), email=inv.get('partner_email'))
|
||||
return link.partner_id
|
||||
|
||||
@api.model
|
||||
def _ingest_invoices(self, data, post=False):
|
||||
Move = self.env['account.move']
|
||||
cad = self.env.ref('base.CAD', raise_if_not_found=False) or self.env.company.currency_id
|
||||
summary = {'created': 0, 'updated': 0, 'posted': 0, 'skipped': [], 'failed': [], 'by_family': {}}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get('id') or '')
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
existing = Move.search([('x_fc_nexacloud_invoice_id', '=', nc_id)], limit=1)
|
||||
if existing:
|
||||
if existing.state != 'draft':
|
||||
summary['skipped'].append({'id': nc_id, 'reason': 'already posted'})
|
||||
continue
|
||||
existing.invoice_line_ids.unlink() # draft: replace lines
|
||||
move = existing
|
||||
else:
|
||||
move = Move.create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': self._fc_partner_for(inv).id,
|
||||
'invoice_date': inv.get('invoice_date'),
|
||||
'ref': inv.get('invoice_number'),
|
||||
'currency_id': cad.id,
|
||||
'x_fc_nexacloud_invoice_id': nc_id,
|
||||
'x_fc_stripe_invoice_id': inv.get('stripe_invoice_id'),
|
||||
})
|
||||
tax = self._fc_tax_for(inv.get('subtotal'), inv.get('tax'))
|
||||
line_vals = []
|
||||
for it in inv.get('items', []):
|
||||
fam = self._fc_family_for(it.get('description'))
|
||||
summary['by_family'][fam] = round(
|
||||
summary['by_family'].get(fam, 0.0) + float(it.get('amount') or 0.0), 2)
|
||||
line_vals.append((0, 0, {
|
||||
'name': it.get('description') or 'NexaCloud',
|
||||
'quantity': float(it.get('quantity') or 1.0),
|
||||
'price_unit': float(it.get('unit_price') or it.get('amount') or 0.0),
|
||||
'account_id': self._fc_income_account(fam).id,
|
||||
'tax_ids': [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||
}))
|
||||
move.write({'invoice_line_ids': line_vals})
|
||||
summary['updated' if existing else 'created'] += 1
|
||||
if post:
|
||||
move.action_post()
|
||||
summary['posted'] += 1
|
||||
self._fc_reconcile_payment(move, inv)
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Ledger ingest: invoice %s failed", nc_id)
|
||||
summary['failed'].append({'id': nc_id, 'error': '%s: %s' % (type(e).__name__, e)})
|
||||
return summary
|
||||
|
||||
@api.model
|
||||
def _fc_reconcile_payment(self, move, inv):
|
||||
"""Placeholder until Task 5; defined so post=True doesn't AttributeError."""
|
||||
return False
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If tax computes to 13.00 only when the company/fiscal position allows it, read the tax setup on trial; if `amount_tax` ≠ 13.00, the chosen tax is wrong — fix `_fc_tax_for`, never weaken the assertion.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ingest NexaCloud invoices -> draft account.move (idempotent)`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Reconcile Stripe payments (paid invoices show paid)
|
||||
|
||||
**Read reference first:** confirm the payment-register flow on trial:
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"_create_payments|def action_create_payments\\\" /mnt/enterprise-addons/account/wizard/account_payment_register.py | head\"'"
|
||||
```
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
def test_paid_invoice_is_reconciled_and_shows_paid(self):
|
||||
data = _inv_fixture()
|
||||
data[0].update({'status': 'paid', 'amount_paid': 113.0, 'paid_at': '2026-05-02'})
|
||||
self.W._ingest_invoices(data, post=True)
|
||||
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||
```
|
||||
(Add this inside `TestLedgerIngest`.)
|
||||
|
||||
- [ ] **Step 2: run** → FAIL (payment not reconciled).
|
||||
|
||||
- [ ] **Step 3: implement** `_fc_reconcile_payment` + a journal helper (replace the placeholder):
|
||||
```python
|
||||
@api.model
|
||||
def _fc_stripe_journal(self):
|
||||
Journal = self.env['account.journal']
|
||||
j = Journal.search([('code', '=', 'NCSTR')], limit=1)
|
||||
if not j:
|
||||
j = Journal.create({'name': 'NexaCloud Stripe', 'code': 'NCSTR', 'type': 'bank'})
|
||||
return j
|
||||
|
||||
@api.model
|
||||
def _fc_reconcile_payment(self, move, inv):
|
||||
paid = float(inv.get('amount_paid') or 0.0)
|
||||
if (inv.get('status') != 'paid' and paid <= 0) or move.state != 'posted':
|
||||
return False
|
||||
reg = self.env['account.payment.register'].with_context(
|
||||
active_model='account.move', active_ids=move.ids).create({
|
||||
'journal_id': self._fc_stripe_journal().id,
|
||||
'payment_date': inv.get('paid_at') or move.invoice_date or fields.Date.today(),
|
||||
'amount': paid or move.amount_total,
|
||||
})
|
||||
reg._create_payments()
|
||||
return True
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If `payment_state` is `in_payment` rather than `paid`, that's expected when the bank journal isn't reconciled to a statement — accept both, as the assertion does.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): reconcile Stripe payments so ingested invoices show paid`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Reader + wizard actions + bulk-post + cron
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `views/invoice_ledger_views.xml`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** for bulk-post + DSN guard (append):
|
||||
```python
|
||||
def test_post_ingested_posts_drafts(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
n = self.W._post_ingested()
|
||||
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertGreaterEqual(n, 1)
|
||||
|
||||
def test_read_invoices_guards_missing_dsn(self):
|
||||
from odoo.exceptions import UserError
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
with self.assertRaises(UserError):
|
||||
self.W._read_nexacloud_invoices()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement** `_post_ingested`, `_read_nexacloud_invoices`, `action_run`, and a cron entry:
|
||||
```python
|
||||
@api.model
|
||||
def _post_ingested(self):
|
||||
moves = self.env['account.move'].search([
|
||||
('x_fc_nexacloud_invoice_id', '!=', False),
|
||||
('state', '=', 'draft'), ('move_type', '=', 'out_invoice')])
|
||||
posted = 0
|
||||
for mv in moves:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
mv.action_post()
|
||||
posted += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.exception("Ledger post: move %s failed", mv.id)
|
||||
return posted
|
||||
|
||||
def _read_nexacloud_invoices(self, since=None):
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
dsn = self.env['ir.config_parameter'].sudo().get_param('fusion_billing.nexacloud_dsn')
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
where = "WHERE i.created_at >= %(since)s" if since else ""
|
||||
cur.execute(
|
||||
"SELECT i.id, i.stripe_invoice_id, i.invoice_number, i.user_id AS user_external_id, "
|
||||
"u.full_name AS partner_name, COALESCE(u.billing_email,u.email) AS partner_email, "
|
||||
"i.created_at AS invoice_date, i.currency, i.status, i.subtotal, i.tax, "
|
||||
"i.amount_paid, i.paid_at "
|
||||
"FROM invoices i JOIN users u ON u.id = i.user_id " + where +
|
||||
" ORDER BY i.created_at", {'since': since})
|
||||
invoices = {str(r['id']): dict(r, items=[]) for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"SELECT ii.invoice_id, ii.description, ii.quantity, ii.unit_price, ii.amount "
|
||||
"FROM invoice_items ii WHERE ii.invoice_id = ANY(%(ids)s)",
|
||||
{'ids': list(invoices.keys())})
|
||||
for r in cur.fetchall():
|
||||
inv = invoices.get(str(r['invoice_id']))
|
||||
if inv:
|
||||
inv['items'].append({'description': r['description'], 'quantity': r['quantity'],
|
||||
'unit_price': r['unit_price'], 'amount': r['amount']})
|
||||
for inv in invoices.values():
|
||||
inv['id'] = str(inv['id'])
|
||||
inv['user_external_id'] = str(inv['user_external_id'])
|
||||
return list(invoices.values())
|
||||
except psycopg2.Error as e:
|
||||
raise UserError("Failed reading NexaCloud invoices — schema may have changed:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def action_run(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_invoices()
|
||||
if self.dry_run:
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
res = {}
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
res.update(self._ingest_invoices(data, post=False))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
res['dry_run'] = True
|
||||
else:
|
||||
res = self._ingest_invoices(data, post=self.auto_post)
|
||||
self.result_summary = json.dumps(res, indent=2, default=str)
|
||||
if res.get('failed'):
|
||||
_logger.error("Ledger ingest: %s failed: %s", len(res['failed']), res['failed'])
|
||||
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||
```
|
||||
Add a daily cron to `views/invoice_ledger_views.xml`:
|
||||
```xml
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Ingest NexaCloud invoices (daily)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
```
|
||||
And `_cron_ingest_recent` (ingest invoices from the last 2 days, idempotent):
|
||||
```python
|
||||
def _cron_ingest_recent(self):
|
||||
from datetime import timedelta
|
||||
since = fields.Datetime.to_string(fields.Datetime.now() - timedelta(days=2))
|
||||
return self._ingest_invoices(self._read_nexacloud_invoices(since=since), post=True)
|
||||
```
|
||||
(Cron ships `active=False` — enabled only after the backfill is reviewed.)
|
||||
|
||||
- [ ] **Step 4: run** → PASS.
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): invoice-ledger reader, wizard actions, bulk-post, daily cron`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Prune obsolete metered shadow data
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
def test_prune_shadow_removes_shadow_subs_only(self):
|
||||
# a shadow sub + a normal order
|
||||
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
||||
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
||||
n = self.W._fc_prune_metered_shadow()
|
||||
self.assertFalse(shadow.exists())
|
||||
self.assertGreaterEqual(n.get('subscriptions', 0), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement**:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_prune_metered_shadow(self):
|
||||
"""Delete the superseded metered shadow data (shadow sale.orders, NC-* products,
|
||||
NexaCloud charges, reconciliation rows). Reversible only by re-import."""
|
||||
counts = {}
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
counts['subscriptions'] = len(subs)
|
||||
subs.unlink()
|
||||
prods = self.env['product.product'].search([('default_code', '=like', 'NC-%')])
|
||||
counts['products'] = len(prods)
|
||||
prods.unlink()
|
||||
ch = self.env['fusion.billing.charge'].search([])
|
||||
counts['charges'] = len(ch)
|
||||
ch.unlink()
|
||||
rec = self.env['fusion.billing.reconciliation'].search([])
|
||||
counts['reconciliations'] = len(rec)
|
||||
rec.unlink()
|
||||
return counts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If a product can't unlink due to references, archive instead — read the error and adjust.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): prune obsolete metered shadow data helper`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full suite + static checks
|
||||
|
||||
- [ ] `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0`.
|
||||
- [ ] `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo clean` → clean.
|
||||
- [ ] `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ | grep -v "sale.subscription.plan"` → only docstring.
|
||||
- [ ] commit any fixes.
|
||||
|
||||
## Done = invoice ledger ready to run
|
||||
|
||||
Then (separate, gated, NOT in this plan): on nexamain — prune shadow data, **dry-run** the full backfill (review the per-family $ summary + unmatched "Other" lines), ingest **as draft**, you review a sample, **bulk-post**, enable the daily cron.
|
||||
288
docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md
Normal file
288
docs/superpowers/plans/2026-05-27-nexacloud-reconciliation.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# NexaCloud Dual-Run Reconciliation (Sub-project #2d) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Checkbox steps.
|
||||
|
||||
**Goal:** Compute, per shadow subscription + period, Odoo's would-be charge vs NexaCloud's actual charge and record the delta in `fusion.billing.reconciliation`, so the dual-run can prove parity before any flip.
|
||||
|
||||
**Architecture:** A pure `_compute_reconciliation(...)` (testable) + `_reconcile_rows(rows)` (resolves the shadow sub → flat + charge, upserts recon rows) + a read-only `_read_reconciliation_rows()` (psycopg2, integration glue). Triggered from the import wizard + cron. Odoo-only; reads NexaCloud, writes only reconciliation rows.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise, `psycopg2`. Tests: `TransactionCase` on odoo-trial (`bash scripts/fcb_test_on_trial.sh`, pass = `FCB_EXIT=0`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-reconciliation-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: 2a amendment — store the NexaCloud plan id on the shadow subscription
|
||||
|
||||
**Files:** `models/sale_order.py`, `wizards/import_wizard.py`, `tests/test_importer.py`
|
||||
|
||||
- [ ] **Step 1: failing test** (append to `TestImporterSubscriptions` in `tests/test_importer.py`):
|
||||
```python
|
||||
def test_subscription_records_nexacloud_plan_id(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
sub1 = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
self.assertEqual(sub1.x_fc_nexacloud_plan_id, 'p-1')
|
||||
```
|
||||
- [ ] **Step 2: run** `bash scripts/fcb_test_on_trial.sh` → FAIL (field missing).
|
||||
- [ ] **Step 3: add the field** to `models/sale_order.py` (next to the other `x_fc_*`):
|
||||
```python
|
||||
x_fc_nexacloud_plan_id = fields.Char(index=True, copy=False)
|
||||
```
|
||||
- [ ] **Step 4: set it in the importer.** In `wizards/import_wizard.py` `_import_subscription`, add the plan id to both the `shadow_vals` dict (so re-runs keep it current) :
|
||||
```python
|
||||
shadow_vals = {
|
||||
"x_fc_nexacloud_deployment_id": str(srow.get("deployment_id") or ""),
|
||||
"x_fc_nexacloud_plan_id": str(srow.get("plan_id") or ""),
|
||||
"x_fc_billing_service_id": service.id, "x_fc_shadow": True,
|
||||
}
|
||||
```
|
||||
- [ ] **Step 5: run** → PASS.
|
||||
- [ ] **Step 6: commit** `feat(billing): record NexaCloud plan id on shadow subscription (for reconciliation)`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: pure reconciliation math
|
||||
|
||||
**Files:** `models/reconciliation.py`, `tests/test_reconciliation.py` (new), `tests/__init__.py`
|
||||
|
||||
- [ ] **Step 1:** append `from . import test_reconciliation` to `tests/__init__.py`.
|
||||
- [ ] **Step 2: failing test** `tests/test_reconciliation.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconciliationMath(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'CPU', 'plan_code': 'p-1', 'metric_id': self.metric.id,
|
||||
'included_quota': 18000.0, 'price_per_unit': 0.0075,
|
||||
'unit_batch': 3600.0, 'charge_model': 'standard'})
|
||||
|
||||
def test_match_within_tolerance(self):
|
||||
# flat 20 + 0 overage (under quota) vs external 20.00 -> match
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 10000.0, 20.0, 0.01)
|
||||
self.assertAlmostEqual(odoo_amt, 20.0)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
def test_overage_match(self):
|
||||
# flat 20 + 2 core-hours overage (7200s -> $0.015) = 20.015 vs external 20.015
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 18000.0 + 7200.0, 20.015, 0.01)
|
||||
self.assertAlmostEqual(odoo_amt, 20.015, places=4)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
def test_delta_flags_mismatch(self):
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 18000.0, 25.0, 0.01) # external 25 vs odoo 20
|
||||
self.assertAlmostEqual(delta, -5.0, places=2)
|
||||
self.assertEqual(status, 'delta')
|
||||
```
|
||||
- [ ] **Step 3: run** → FAIL (`_compute_reconciliation` missing).
|
||||
- [ ] **Step 4: implement** in `models/reconciliation.py` (add `from odoo import api, fields, models`):
|
||||
```python
|
||||
@api.model
|
||||
def _compute_reconciliation(self, flat_amount, charge, cpu_seconds, external_amount,
|
||||
tolerance=0.01):
|
||||
"""Return (odoo_amount, delta, status). odoo = flat + overage(cpu_seconds);
|
||||
delta = odoo - external; status 'match' if |delta| <= tolerance else 'delta'."""
|
||||
_units, overage = charge._compute_billable(cpu_seconds) if charge else (0.0, 0.0)
|
||||
odoo_amount = round((flat_amount or 0.0) + (overage or 0.0), 2)
|
||||
delta = round(odoo_amount - (external_amount or 0.0), 2)
|
||||
status = 'match' if abs(delta) <= (tolerance or 0.0) else 'delta'
|
||||
return odoo_amount, delta, status
|
||||
```
|
||||
- [ ] **Step 5: run** → PASS.
|
||||
- [ ] **Step 6: commit** `feat(billing): reconciliation math (odoo-computed vs external)`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `_reconcile_rows` — resolve shadow sub and upsert recon rows
|
||||
|
||||
**Files:** `models/reconciliation.py`, `tests/test_reconciliation.py`
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileRows(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
from odoo.addons.fusion_centralize_billing.tests.test_importer import _fixture
|
||||
self.Wizard._import_rows(_fixture()) # creates shadow subs + p-1 charge
|
||||
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||
|
||||
def test_creates_one_row_per_subscription_with_status(self):
|
||||
# s-1 monthly flat 20, no overage; external 20.00 -> match.
|
||||
# s-2 yearly flat 200; external 250 -> delta -50.
|
||||
summary = self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0},
|
||||
{'subscription_external_id': 's-2', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 250.0},
|
||||
])
|
||||
rows = self.Recon.search([('period', '=', '2026-05')])
|
||||
self.assertEqual(len(rows), 2)
|
||||
s1 = rows.filtered(lambda r: r.odoo_amount == 20.0)
|
||||
self.assertEqual(s1.status, 'match')
|
||||
s2 = rows.filtered(lambda r: r.odoo_amount == 200.0)
|
||||
self.assertEqual(s2.status, 'delta')
|
||||
self.assertAlmostEqual(s2.delta, -50.0, places=2)
|
||||
self.assertEqual(summary['match'], 1)
|
||||
self.assertEqual(summary['delta'], 1)
|
||||
|
||||
def test_rerun_upserts(self):
|
||||
row = [{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0}]
|
||||
self.Recon._reconcile_rows(row)
|
||||
self.Recon._reconcile_rows(row)
|
||||
self.assertEqual(self.Recon.search_count(
|
||||
[('period', '=', '2026-05'),
|
||||
('partner_id', '=', self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-1')]).partner_id.id)]), 1)
|
||||
|
||||
def test_unknown_subscription_is_skipped(self):
|
||||
summary = self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 'nope', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 1.0}])
|
||||
self.assertTrue(any(s['id'] == 'nope' for s in summary['skipped']))
|
||||
```
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
- [ ] **Step 3: implement** in `models/reconciliation.py`:
|
||||
```python
|
||||
@api.model
|
||||
def _reconcile_rows(self, rows, tolerance=0.01):
|
||||
SaleOrder = self.env['sale.order']
|
||||
Charge = self.env['fusion.billing.charge']
|
||||
Service = self.env['fusion.billing.service']
|
||||
service = Service.search([('code', '=', 'nexacloud')], limit=1)
|
||||
summary = {'match': 0, 'delta': 0, 'skipped': [], 'failed': []}
|
||||
for r in rows:
|
||||
sub_ext = str(r.get('subscription_external_id') or '')
|
||||
period = str(r.get('period') or '')
|
||||
try:
|
||||
sub = SaleOrder.search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
|
||||
if not sub:
|
||||
summary['skipped'].append({'id': sub_ext, 'reason': 'unknown subscription'})
|
||||
continue
|
||||
charge = Charge.search(
|
||||
[('plan_code', '=', sub.x_fc_nexacloud_plan_id)], limit=1)
|
||||
plan_line = sub.order_line.filtered(
|
||||
lambda l: l.product_id.default_code
|
||||
and l.product_id.default_code.startswith('NC-PLAN-'))
|
||||
flat = plan_line[:1].price_unit
|
||||
odoo_amount, delta, status = self._compute_reconciliation(
|
||||
flat, charge, float(r.get('cpu_seconds') or 0.0),
|
||||
float(r.get('external_amount') or 0.0), tolerance)
|
||||
vals = {
|
||||
'service_id': service.id if service else False,
|
||||
'partner_id': sub.partner_id.id, 'period': period,
|
||||
'odoo_amount': odoo_amount,
|
||||
'external_amount': float(r.get('external_amount') or 0.0),
|
||||
'delta': delta, 'status': status,
|
||||
}
|
||||
existing = self.search([
|
||||
('service_id', '=', vals['service_id']),
|
||||
('partner_id', '=', sub.partner_id.id), ('period', '=', period)], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
self.create(vals)
|
||||
summary['match' if status == 'match' else 'delta'] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||
summary['failed'].append({'id': sub_ext, 'error': '%s: %s' % (type(e).__name__, e)})
|
||||
return summary
|
||||
```
|
||||
- [ ] **Step 4: run** → PASS.
|
||||
- [ ] **Step 5: commit** `feat(billing): reconcile shadow subscriptions -> fusion.billing.reconciliation`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: read NexaCloud actuals + wizard trigger
|
||||
|
||||
**Files:** `wizards/import_wizard.py`, `views/import_wizard_views.xml`
|
||||
|
||||
- [ ] **Step 1: add the reader** in `wizards/import_wizard.py` (reuses the DSN + the same connect/guard pattern as `_read_nexacloud_rows`). Aggregate usage cpu_hours per (subscription, period) and the invoice subtotal per (subscription, period); return rows shaped for `_reconcile_rows`:
|
||||
```python
|
||||
def _read_reconciliation_rows(self):
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
# period label = YYYY-MM of the usage period_start; cpu_seconds = cpu_hours*3600
|
||||
cur.execute("""
|
||||
SELECT u.subscription_id::text AS subscription_external_id,
|
||||
to_char(u.period_start, 'YYYY-MM') AS period,
|
||||
COALESCE(SUM(u.cpu_hours), 0) * 3600.0 AS cpu_seconds
|
||||
FROM usage_records u
|
||||
GROUP BY u.subscription_id, to_char(u.period_start, 'YYYY-MM')""")
|
||||
usage = {(r['subscription_external_id'], r['period']): r for r in cur.fetchall()}
|
||||
cur.execute("""
|
||||
SELECT i.subscription_id::text AS subscription_external_id,
|
||||
to_char(ii.period_start, 'YYYY-MM') AS period,
|
||||
COALESCE(SUM(i.subtotal), 0) AS external_amount
|
||||
FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id
|
||||
GROUP BY i.subscription_id, to_char(ii.period_start, 'YYYY-MM')""")
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
key = (r['subscription_external_id'], r['period'])
|
||||
rows.append({
|
||||
'subscription_external_id': r['subscription_external_id'],
|
||||
'period': r['period'],
|
||||
'cpu_seconds': float((usage.get(key) or {}).get('cpu_seconds') or 0.0),
|
||||
'external_amount': float(r['external_amount'] or 0.0)})
|
||||
return rows
|
||||
except psycopg2.Error as e:
|
||||
raise UserError("Failed reading NexaCloud actuals — schema may have changed:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def action_run_reconciliation(self):
|
||||
self.ensure_one()
|
||||
rows = self._read_reconciliation_rows()
|
||||
summary = self.env['fusion.billing.reconciliation']._reconcile_rows(rows)
|
||||
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||
self.failed_count = len(summary.get('failed') or [])
|
||||
if summary.get('delta') or summary.get('failed'):
|
||||
_logger.error("NexaCloud reconciliation: %s delta / %s failed row(s): %s",
|
||||
summary.get('delta'), len(summary.get('failed') or []), summary)
|
||||
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||
```
|
||||
- [ ] **Step 2: add the button** to `views/import_wizard_views.xml` footer:
|
||||
```xml
|
||||
<button name="action_run_reconciliation" type="object"
|
||||
string="Run Reconciliation" class="btn-secondary"/>
|
||||
```
|
||||
- [ ] **Step 3:** `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0` (module upgrades; reader is integration-only, not unit-tested).
|
||||
- [ ] **Step 4: commit** `feat(billing): NexaCloud reconciliation reader + wizard trigger`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: full suite + static checks
|
||||
|
||||
- [ ] `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0`.
|
||||
- [ ] `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo clean` → clean.
|
||||
- [ ] `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ | grep -v "sale.subscription.plan"` → only docstring.
|
||||
- [ ] commit any fixes.
|
||||
|
||||
## Done = 2d complete
|
||||
|
||||
The dual-run can be run each cycle (button/cron): it reads NexaCloud usage + invoice subtotals, computes Odoo's would-be charge, and records per-subscription `match`/`delta` rows. Flip happens (manually) once a cycle is all-match.
|
||||
@@ -3,7 +3,7 @@
|
||||
**Date:** 2026-05-20
|
||||
**Module:** `fusion_repairs` (new)
|
||||
**Owner:** Gurpreet
|
||||
**Status:** Approved (ready for implementation plan)
|
||||
**Status:** Implemented in repo (bundles 1–11); see [`fusion_repairs/cloud.md`](../../../fusion_repairs/cloud.md) for shipped vs deferred
|
||||
**Scope:** Four-phase build (~8-12 weeks); three intake surfaces; 53 features
|
||||
**Sister modules:** `fusion_repair_compliance`, `fusion_repair_plans`, `fusion_repair_shop`, `fusion_repair_analytics` (Phase 4, optional split)
|
||||
|
||||
@@ -26,8 +26,8 @@ Built incrementally across 4 phases; each phase ships a usable slice.
|
||||
|
||||
## Current state
|
||||
|
||||
- [`fusion_repairs/`](fusion_repairs/) is an **empty folder** — no `__manifest__.py`, models, or views yet.
|
||||
- No existing code in the repo extends Odoo's `repair` app.
|
||||
- [`fusion_repairs/`](fusion_repairs/) is a **full Odoo 19 addon** (~100+ files, version `19.0.2.2.4`). Living status: [`fusion_repairs/cloud.md`](../../../fusion_repairs/cloud.md).
|
||||
- Extends Odoo `repair.order`, `sale.order`, `fusion.technician.task`, portals, and adds flowchart / pricing / parts models.
|
||||
- Closest precedents:
|
||||
- [`fusion_ltc_management/models/ltc_repair.py`](fusion_ltc_management/models/ltc_repair.py) — repair workflow + SO + technician task (LTC facilities only; **keep separate**)
|
||||
- [`fusion_tasks/models/technician_task.py`](fusion_tasks/models/technician_task.py) — field service scheduling with `task_type` including `repair` / `maintenance`
|
||||
|
||||
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.
|
||||
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\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
106
fusion_centralize_billing/README.md
Normal file
106
fusion_centralize_billing/README.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Fusion Centralized Billing (`fusion_centralize_billing`)
|
||||
|
||||
Centralized billing engine that makes this Odoo 19 **Enterprise** instance the single
|
||||
billing brain for every NexaSystems service — **NexaCloud** hosting, **NexaDesk** chat,
|
||||
**NexaMaps** API, custom apps, and memberships. It replaces Lago and absorbs NexaCloud's
|
||||
home-grown Stripe billing into one customer ledger and one accounting system.
|
||||
|
||||
> **Design spec:** [`docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md`](../docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md)
|
||||
>
|
||||
> **Status:** Core engine (sub-project #1) and the **NexaCloud importer (sub-project #2a)**
|
||||
> are implemented and tested on odoo-trial Enterprise. 2b (usage wiring), 2c (control loop),
|
||||
> and 2d (reconciliation) are pending.
|
||||
|
||||
## Why this module is small
|
||||
|
||||
We build **only** the metering + integration layer. Everything financial — recurring
|
||||
invoicing, HST tax, proration, dunning, customer portal, credit notes, Stripe — is
|
||||
**native Odoo Enterprise** (`sale_subscription`, `account_accountant`, `payment_stripe`),
|
||||
already installed and running.
|
||||
|
||||
## Design decisions (locked)
|
||||
|
||||
1. Odoo fully replaces Lago (we build the metered-billing engine; Lago is decommissioned last).
|
||||
2. One unified `res.partner` per client; **separate invoice per service**.
|
||||
3. **Apps drive**, Odoo is the billing system of record — apps call the inbound API (as they call Lago today); Odoo bills and webhooks back.
|
||||
4. Odoo owns the **billing catalog**; apps own **feature entitlements** (shared `plan_code`).
|
||||
5. Pilot = **NexaCloud**, phased dual-run cutover.
|
||||
6. **Aggregate-push** usage ingestion (periodic counters, not a raw-event firehose).
|
||||
|
||||
## Models (`fusion.billing.*`)
|
||||
|
||||
| Model | Purpose |
|
||||
|---|---|
|
||||
| `fusion.billing.service` | One source app; bearer API key (hashed) + webhook config. |
|
||||
| `fusion.billing.account.link` | External account id → one `res.partner` (identity resolution). |
|
||||
| `fusion.billing.metric` | Billable metric + aggregation (sum/max/last/unique). |
|
||||
| `fusion.billing.charge` | Plan + metric → included quota + overage pricing. |
|
||||
| `fusion.billing.usage` | Aggregated per-period usage rollups (idempotent). |
|
||||
| `fusion.billing.webhook` | Outbound lifecycle event queue (HMAC + retry). |
|
||||
| `fusion.billing.reconciliation` | Dual-run Odoo-vs-app delta during cutover. |
|
||||
|
||||
> **Odoo 19 note (verified):** a subscription is a `sale.order` with `is_subscription=True`
|
||||
> (`plan_id` → `sale.subscription.plan`). There is **no** `sale.subscription` model.
|
||||
> `fusion.billing.usage.subscription_id` therefore points at `sale.order`.
|
||||
|
||||
## Inbound API
|
||||
|
||||
Lago-shaped REST under `/api/billing/v1/*`, bearer auth. Endpoints mirror NexaDesk's
|
||||
existing `lago-client.ts` so migration is a thin client swap. `/health` works today;
|
||||
the rest return `501` until implemented.
|
||||
|
||||
## Relationship to `fusion_api`
|
||||
|
||||
`fusion_api` manages **outbound** provider keys (OpenAI, Maps, Twilio) + cost tracking —
|
||||
i.e. COGS. This module tracks **customer** revenue. Complementary: feed `fusion_api`
|
||||
cost into margin reporting; reuse its daily-rollup aggregation pattern.
|
||||
|
||||
## Dependencies
|
||||
|
||||
`account_accountant`, `sale_subscription`, `sale_management`, `payment_stripe`.
|
||||
|
||||
## Running the NexaCloud import (2a)
|
||||
|
||||
Exposed as **Fusion Billing → Import from NexaCloud** (a wizard). It runs entirely
|
||||
read-only against NexaCloud, and everything it creates in Odoo is shadow-safe (draft
|
||||
subscriptions, no payment token, charges with NULL `plan_id`) so it cannot charge or post
|
||||
during the dual-run.
|
||||
|
||||
**1. Create a least-privilege read-only role in the NexaCloud Postgres (LXC 201):**
|
||||
|
||||
```sql
|
||||
CREATE ROLE odoo_billing_ro WITH LOGIN PASSWORD '<choose-a-strong-password>';
|
||||
GRANT CONNECT ON DATABASE nexacloud TO odoo_billing_ro;
|
||||
GRANT USAGE ON SCHEMA public TO odoo_billing_ro;
|
||||
GRANT SELECT ON users, plans, subscriptions, deployments TO odoo_billing_ro;
|
||||
```
|
||||
|
||||
**2. Point Odoo at it** via the system parameter (Settings → Technical → System Parameters,
|
||||
or odoo-shell). psycopg2 wants a **libpq DSN** — i.e. NexaCloud's SQLAlchemy URL *without*
|
||||
`+asyncpg`:
|
||||
|
||||
```
|
||||
key: fusion_billing.nexacloud_dsn
|
||||
value: postgresql://odoo_billing_ro:<password>@<lxc201-host>:5432/nexacloud
|
||||
```
|
||||
|
||||
(Odoo on nexa / VM 315 must have a network route to the LXC 201 Postgres port.)
|
||||
|
||||
**3. Validate → dry-run → run for real:**
|
||||
|
||||
- **Test Connection** — confirms reachability + schema and reports row counts; imports nothing.
|
||||
- **Run Import** with **Dry run** ticked — computes the whole import inside a rolled-back
|
||||
savepoint and reports created / updated / **skipped** / **failed** counts; writes nothing.
|
||||
A red/amber banner flags any failures — investigate them before proceeding.
|
||||
- Untick **Dry run** and **Run Import** to persist the shadow copy. Re-running is safe and
|
||||
idempotent (upserts, never duplicates).
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
docker exec odoo-nexa-app odoo -d nexamain -u fusion_centralize_billing --stop-after-init
|
||||
# tests (once added):
|
||||
docker exec odoo-nexa-app odoo -d nexamain --test-enable --test-tags /fusion_centralize_billing -u fusion_centralize_billing --stop-after-init
|
||||
```
|
||||
|
||||
Canadian English, CAD, HST via `account.tax`. New fields on native models use the `x_fc_*` prefix.
|
||||
3
fusion_centralize_billing/__init__.py
Normal file
3
fusion_centralize_billing/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
57
fusion_centralize_billing/__manifest__.py
Normal file
57
fusion_centralize_billing/__manifest__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
"name": "Fusion Centralized Billing",
|
||||
"version": "19.0.1.1.0",
|
||||
"category": "Accounting/Subscriptions",
|
||||
"summary": "Centralized billing engine for all NexaSystems services — metered usage, "
|
||||
"per-app billing API, and outbound webhooks on top of Odoo Enterprise subscriptions.",
|
||||
"description": """
|
||||
Fusion Centralized Billing
|
||||
==========================
|
||||
|
||||
Makes this Odoo Enterprise instance the single billing brain for every NexaSystems
|
||||
service (NexaCloud hosting, NexaDesk chat, NexaMaps API, custom apps, memberships).
|
||||
|
||||
It adds ONLY the metering + integration layer; all financial behaviour (invoicing,
|
||||
HST tax, proration, dunning, portal, credit notes, Stripe) is native Odoo Enterprise.
|
||||
|
||||
Capabilities
|
||||
------------
|
||||
* Service registry — one record per source app (NexaCloud / NexaDesk / NexaMaps) with
|
||||
bearer API key + webhook config.
|
||||
* Identity links — fold each app's external account into one ``res.partner``.
|
||||
* Metric + Charge catalog — billable metrics with quota + overage pricing, keyed by a
|
||||
shared ``plan_code`` (apps own feature entitlements; Odoo owns money).
|
||||
* Usage engine — aggregate-push: apps send periodic counters; a pre-invoice cron feeds
|
||||
billable quantities onto the subscription ``sale.order``.
|
||||
* Inbound API — Lago-shaped REST (``/api/billing/v1/*``), bearer auth.
|
||||
* Outbound webhooks — HMAC-signed lifecycle events (payment failed/succeeded,
|
||||
subscription terminated) so apps suspend / restore / deprovision.
|
||||
|
||||
Design spec: docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md
|
||||
|
||||
Status: SCAFFOLD. Model fields are in place; engine/API/webhook bodies are stubs to be
|
||||
implemented via the writing-plans output. Per repo CLAUDE.md, read live Odoo 19
|
||||
reference files from the container before implementing subscription/account internals.
|
||||
""",
|
||||
"author": "Nexa Systems Inc.",
|
||||
"website": "https://nexasystems.ca",
|
||||
"license": "OPL-1",
|
||||
"depends": [
|
||||
"account_accountant",
|
||||
"sale_subscription",
|
||||
"sale_management",
|
||||
"payment_stripe",
|
||||
],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"data/ir_cron.xml",
|
||||
"views/import_wizard_views.xml",
|
||||
"views/invoice_ledger_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"auto_install": False,
|
||||
}
|
||||
1
fusion_centralize_billing/controllers/__init__.py
Normal file
1
fusion_centralize_billing/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import api
|
||||
95
fusion_centralize_billing/controllers/api.py
Normal file
95
fusion_centralize_billing/controllers/api.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""Inbound, Lago-shaped billing API (spec §7).
|
||||
|
||||
Auth: bearer API key matched (by SHA-256 hash) against ``fusion.billing.service``.
|
||||
Routing: ``type="http"`` + ``auth="none"`` + ``csrf=False`` — external apps present
|
||||
bearer tokens, not Odoo sessions (so NOT ``type="jsonrpc"``).
|
||||
|
||||
STATUS: SCAFFOLD. Only auth + /health are wired. Endpoint bodies are stubs (HTTP 501)
|
||||
to be implemented from the writing-plans output. Per repo CLAUDE.md, read live Odoo 19
|
||||
references (sale.order subscription flow, account.move, payment_stripe) before
|
||||
implementing — do NOT code those internals from memory.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
API_BASE = "/api/billing/v1"
|
||||
|
||||
|
||||
class FusionBillingApi(http.Controller):
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────
|
||||
def _authenticate(self):
|
||||
"""Return the active fusion.billing.service for the bearer key, else None."""
|
||||
auth = request.httprequest.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return None
|
||||
return request.env["fusion.billing.service"].sudo()._match_api_key(auth[7:].strip()) or None
|
||||
|
||||
def _json(self, payload, status=200):
|
||||
return request.make_json_response(payload, status=status)
|
||||
|
||||
def _read_json(self):
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True) or "{}"
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ── routes ───────────────────────────────────────────────────────────
|
||||
@http.route(f"{API_BASE}/health", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
def health(self, **kw):
|
||||
return self._json({"status": "ok", "service": "fusion_centralize_billing"})
|
||||
|
||||
@http.route(f"{API_BASE}/customers", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def post_customer(self, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
payload = self._read_json()
|
||||
if payload is None:
|
||||
return self._json({"error": "invalid json"}, status=400)
|
||||
result = service._api_upsert_customer(payload)
|
||||
if result.get("status") == "error":
|
||||
return self._json(result, status=400)
|
||||
return self._json(result)
|
||||
|
||||
@http.route(f"{API_BASE}/usage", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def post_usage(self, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
payload = self._read_json()
|
||||
if payload is None:
|
||||
return self._json({"error": "invalid json"}, status=400)
|
||||
result = service._api_record_usage(payload)
|
||||
if result.get("status") == "error":
|
||||
return self._json(result, status=400)
|
||||
return self._json(result, status=202)
|
||||
|
||||
@http.route(f"{API_BASE}/plans", type="http", auth="none", methods=["GET"], csrf=False)
|
||||
def get_plans(self, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
return self._json(service._api_catalog())
|
||||
|
||||
@http.route(f"{API_BASE}/subscriptions", type="http", auth="none", methods=["POST"], csrf=False)
|
||||
def post_subscription(self, **kw):
|
||||
service = self._authenticate()
|
||||
if not service:
|
||||
return self._json({"error": "unauthorized"}, status=401)
|
||||
payload = self._read_json()
|
||||
if payload is None:
|
||||
return self._json({"error": "invalid json"}, status=400)
|
||||
result = service._api_create_subscription(payload)
|
||||
if result.get("status") == "error":
|
||||
return self._json(result, status=400)
|
||||
return self._json(result)
|
||||
35
fusion_centralize_billing/data/ir_cron.xml
Normal file
35
fusion_centralize_billing/data/ir_cron.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
<record id="cron_fc_rate_usage" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Rate usage before invoicing</field>
|
||||
<field name="model_id" ref="model_fusion_billing_usage"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_rate_open_periods()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<record id="cron_fc_dispatch_webhooks" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Dispatch outbound webhooks</field>
|
||||
<field name="model_id" ref="model_fusion_billing_webhook"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_dispatch()</field>
|
||||
<field name="interval_number">2</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Go-forward NexaCloud ledger sync. Ships INACTIVE: only enable once the Stripe
|
||||
(and Lago) API credentials are set on the instance and a manual run is verified,
|
||||
because the sync verifies each invoice against those sources before posting. -->
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Sync NexaCloud invoices (Stripe/Lago verified)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_sync_verified()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
</odoo>
|
||||
10
fusion_centralize_billing/models/__init__.py
Normal file
10
fusion_centralize_billing/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from . import service
|
||||
from . import account_link
|
||||
from . import metric
|
||||
from . import charge
|
||||
from . import usage
|
||||
from . import webhook
|
||||
from . import reconciliation
|
||||
from . import sale_order
|
||||
from . import res_partner
|
||||
from . import account_move
|
||||
59
fusion_centralize_billing/models/account_link.py
Normal file
59
fusion_centralize_billing/models/account_link.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionBillingAccountLink(models.Model):
|
||||
"""Identity resolution: maps an app's external account id to one res.partner.
|
||||
|
||||
Folds the NexaCloud user / NexaDesk tenant / NexaMaps client for the same
|
||||
real-world client onto a single partner (the unified customer). See spec §5.1.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.account.link"
|
||||
_description = "Fusion Billing — External Account → Partner Link"
|
||||
_order = "service_id, external_id"
|
||||
|
||||
service_id = fields.Many2one(
|
||||
"fusion.billing.service", required=True, ondelete="cascade", index=True,
|
||||
)
|
||||
external_id = fields.Char(
|
||||
required=True, index=True,
|
||||
help="The app's own account id (NexaCloud user, NexaDesk tenant, Maps client).",
|
||||
)
|
||||
external_email = fields.Char()
|
||||
partner_id = fields.Many2one(
|
||||
"res.partner", required=True, ondelete="restrict", index=True,
|
||||
)
|
||||
|
||||
_service_external_uniq = models.Constraint(
|
||||
"unique(service_id, external_id)",
|
||||
"An external account can only link to one partner per service.",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _resolve_or_create_partner(self, service, external_id, name=None, email=None, extra=None):
|
||||
"""Return the link for (service, external_id), creating partner+link if needed.
|
||||
|
||||
Unifies customers: if a link for this external_id exists, reuse it; else if a
|
||||
partner with the same email already exists (possibly from another service),
|
||||
link to it; else create a new partner.
|
||||
"""
|
||||
existing = self.search(
|
||||
[('service_id', '=', service.id), ('external_id', '=', external_id)], limit=1)
|
||||
if existing:
|
||||
return existing
|
||||
partner = self.env['res.partner']
|
||||
if email:
|
||||
# case-insensitive so a pre-existing partner with a differently-cased email
|
||||
# (created via the web UI or another sync) is reused, not duplicated.
|
||||
partner = partner.search([('email', '=ilike', email)], limit=1)
|
||||
if not partner:
|
||||
partner = partner.create({'name': name or external_id, 'email': email, **(extra or {})})
|
||||
return self.create({
|
||||
'service_id': service.id,
|
||||
'external_id': external_id,
|
||||
'external_email': email,
|
||||
'partner_id': partner.id,
|
||||
})
|
||||
17
fusion_centralize_billing/models/account_move.py
Normal file
17
fusion_centralize_billing/models/account_move.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
x_fc_nexacloud_invoice_id = fields.Char(
|
||||
index=True, copy=False, help="Source NexaCloud invoice id — ledger idempotency key.")
|
||||
x_fc_stripe_invoice_id = fields.Char(index=True, copy=False)
|
||||
|
||||
_fc_nc_invoice_uniq = models.Constraint(
|
||||
"unique(x_fc_nexacloud_invoice_id)",
|
||||
"One Odoo invoice per NexaCloud invoice id.",
|
||||
)
|
||||
92
fusion_centralize_billing/models/charge.py
Normal file
92
fusion_centralize_billing/models/charge.py
Normal file
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import math
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionBillingCharge(models.Model):
|
||||
"""Maps a plan + metric to quota + overage pricing.
|
||||
|
||||
This is where "5,000,000 included / $0.10 per 1k overage" (NexaMaps) or a
|
||||
NexaCloud CPU-seconds quota lives. Keyed by the shared ``plan_code`` the app
|
||||
references; Odoo owns the money, the app owns feature entitlements. See spec §5.1.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.charge"
|
||||
_description = "Fusion Billing — Metered Charge (quota + overage)"
|
||||
_order = "plan_code, name"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
plan_code = fields.Char(
|
||||
required=True, index=True,
|
||||
help="Shared plan_code the source app references (matches a sale.subscription.plan).",
|
||||
)
|
||||
plan_id = fields.Many2one(
|
||||
"sale.subscription.plan",
|
||||
help="Optional link to the Odoo recurrence/plan for this charge.",
|
||||
)
|
||||
metric_id = fields.Many2one(
|
||||
"fusion.billing.metric", required=True, ondelete="restrict",
|
||||
)
|
||||
product_id = fields.Many2one(
|
||||
"product.product", help="Usage product invoiced for overage.",
|
||||
)
|
||||
included_quota = fields.Float(
|
||||
default=0.0, help="Units included before overage applies, per period.",
|
||||
)
|
||||
price_per_unit = fields.Float(
|
||||
digits=(16, 6),
|
||||
help="Overage price per unit_batch. A Float (not Monetary) so sub-cent rates "
|
||||
"like $0.0075/core-hour are stored exactly — Monetary rounds to the "
|
||||
"currency's 2 decimals and would corrupt the rate. Final cent-rounding "
|
||||
"happens at the invoice line/total, not in the per-charge math.",
|
||||
)
|
||||
unit_batch = fields.Float(
|
||||
default=1.0, help="Batch size for overage pricing, e.g. 1000 = priced per 1k.",
|
||||
)
|
||||
charge_model = fields.Selection(
|
||||
[
|
||||
("standard", "Standard (per unit)"),
|
||||
("package", "Package"),
|
||||
],
|
||||
default="standard", required=True,
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
"res.currency", required=True,
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_price_non_negative = models.Constraint(
|
||||
"CHECK (price_per_unit >= 0)", "Overage price per unit cannot be negative.",
|
||||
)
|
||||
_unit_batch_positive = models.Constraint(
|
||||
"CHECK (unit_batch > 0)", "Unit batch must be greater than zero.",
|
||||
)
|
||||
|
||||
def _compute_billable(self, total_quantity):
|
||||
"""Return (overage_units, amount) for total period usage under this charge.
|
||||
|
||||
- overage_units = usage above included_quota (never negative)
|
||||
- 'standard': price the overage in (rounded-up) `unit_batch` blocks.
|
||||
- 'package': price whole packages over the RAW quantity (quota ignored for
|
||||
package counting); a partial package rounds up.
|
||||
|
||||
The amount keeps the rate's precision (rounded to 6 dp only to clear float
|
||||
noise) — it must NOT be rounded to cents here. Sub-cent rates (e.g.
|
||||
$0.0075/core-hour) and fractional totals are preserved so they match the
|
||||
source app's own sub-cent usage amounts; final cent-rounding happens once at
|
||||
the invoice line / invoice total, exactly as the source app does.
|
||||
"""
|
||||
self.ensure_one()
|
||||
overage = max(0.0, (total_quantity or 0.0) - (self.included_quota or 0.0))
|
||||
batch = self.unit_batch or 1.0
|
||||
if self.charge_model == 'package':
|
||||
# whole packages over the RAW quantity (quota ignored for package counting)
|
||||
blocks = math.ceil((total_quantity or 0.0) / batch) if total_quantity else 0
|
||||
return overage, round(blocks * (self.price_per_unit or 0.0), 6)
|
||||
# standard: price the overage in (rounded-up) batches
|
||||
blocks = math.ceil(overage / batch) if overage > 0 else 0
|
||||
return overage, round(blocks * (self.price_per_unit or 0.0), 6)
|
||||
32
fusion_centralize_billing/models/metric.py
Normal file
32
fusion_centralize_billing/models/metric.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionBillingMetric(models.Model):
|
||||
"""A billable metric (CPU-seconds, API calls, messages, tokens ...).
|
||||
|
||||
Defines how raw usage is aggregated within a billing period. See spec §5.1 / §6.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.metric"
|
||||
_description = "Fusion Billing — Billable Metric"
|
||||
_order = "code"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char(required=True, index=True)
|
||||
aggregation = fields.Selection(
|
||||
[
|
||||
("sum", "Sum"),
|
||||
("max", "Max"),
|
||||
("last", "Last value"),
|
||||
("unique_count", "Unique count"),
|
||||
],
|
||||
default="sum", required=True,
|
||||
)
|
||||
unit_label = fields.Char(help="e.g. CPU-seconds, API calls, messages, tokens.")
|
||||
rounding = fields.Float(default=1.0)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_code_uniq = models.Constraint("unique(code)", "Metric code must be unique.")
|
||||
128
fusion_centralize_billing/models/reconciliation.py
Normal file
128
fusion_centralize_billing/models/reconciliation.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBillingReconciliation(models.Model):
|
||||
"""Dual-run shadow-mode comparison: Odoo-computed vs the app's actual billing.
|
||||
|
||||
During phased cutover (NexaCloud first), Odoo computes invoices while the app
|
||||
keeps charging. This row records the per-customer, per-period delta so we only
|
||||
flip once deltas are within tolerance. See spec §10.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.reconciliation"
|
||||
_description = "Fusion Billing — Dual-Run Reconciliation"
|
||||
_order = "period desc, service_id"
|
||||
|
||||
service_id = fields.Many2one(
|
||||
"fusion.billing.service", required=True, ondelete="cascade", index=True,
|
||||
)
|
||||
partner_id = fields.Many2one("res.partner", required=True, ondelete="cascade", index=True)
|
||||
period = fields.Char(required=True, help="Billing period label, e.g. 2026-05.")
|
||||
external_subscription_id = fields.Char(
|
||||
index=True,
|
||||
help="Source-app subscription id this row reconciles (NexaCloud sub UUID). Part of "
|
||||
"the upsert key so a customer with multiple deployments gets one row PER "
|
||||
"subscription per period, not a single colliding row.")
|
||||
odoo_amount = fields.Monetary()
|
||||
external_amount = fields.Monetary(string="App-actual Amount")
|
||||
delta = fields.Monetary(help="odoo_amount - external_amount.")
|
||||
currency_id = fields.Many2one(
|
||||
"res.currency", required=True,
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
status = fields.Selection(
|
||||
[
|
||||
("match", "Within tolerance"),
|
||||
("delta", "Delta — investigate"),
|
||||
("resolved", "Resolved"),
|
||||
],
|
||||
default="delta", required=True, index=True,
|
||||
)
|
||||
note = fields.Text()
|
||||
|
||||
_service_sub_period_uniq = models.Constraint(
|
||||
"UNIQUE(service_id, external_subscription_id, period)",
|
||||
"One reconciliation row per service, subscription, and period.",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _compute_reconciliation(self, flat_amount, charge, cpu_seconds, external_amount,
|
||||
tolerance=0.01):
|
||||
"""Return (odoo_amount, delta, status).
|
||||
|
||||
odoo_amount = flat + CPU overage(cpu_seconds); delta = odoo - external;
|
||||
status 'match' if |delta| <= tolerance else 'delta'. Amounts are compared at cent
|
||||
precision (the dual-run cares about cent-level invoice parity)."""
|
||||
overage = 0.0
|
||||
if charge:
|
||||
_units, overage = charge._compute_billable(cpu_seconds)
|
||||
odoo_amount = round((flat_amount or 0.0) + (overage or 0.0), 2)
|
||||
delta = round(odoo_amount - (external_amount or 0.0), 2)
|
||||
status = 'match' if abs(delta) <= (tolerance or 0.0) else 'delta'
|
||||
return odoo_amount, delta, status
|
||||
|
||||
@api.model
|
||||
def _reconcile_rows(self, rows, tolerance=0.01):
|
||||
"""For each {subscription_external_id, period, cpu_seconds, external_amount},
|
||||
resolve the shadow sale.order, compute Odoo-vs-external, and UPSERT one
|
||||
reconciliation row keyed by (service_id, partner_id, period). Per-row isolated."""
|
||||
SaleOrder = self.env['sale.order']
|
||||
Charge = self.env['fusion.billing.charge']
|
||||
service = self.env['fusion.billing.service'].search(
|
||||
[('code', '=', 'nexacloud')], limit=1)
|
||||
if not service:
|
||||
raise UserError(
|
||||
"NexaCloud billing service not found — run the importer first so the "
|
||||
"service, catalog, and shadow subscriptions exist.")
|
||||
summary = {'match': 0, 'delta': 0, 'skipped': [], 'failed': []}
|
||||
for r in rows:
|
||||
sub_ext = str(r.get('subscription_external_id') or '')
|
||||
period = str(r.get('period') or '')
|
||||
try:
|
||||
sub = SaleOrder.search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', sub_ext)], limit=1)
|
||||
if not sub:
|
||||
summary['skipped'].append(
|
||||
{'id': sub_ext, 'reason': 'unknown subscription'})
|
||||
continue
|
||||
charge = Charge.search(
|
||||
[('plan_code', '=', sub.x_fc_nexacloud_plan_id)], limit=1)
|
||||
plan_line = sub.order_line.filtered(
|
||||
lambda l: l.product_id.default_code
|
||||
and l.product_id.default_code.startswith('NC-PLAN-'))
|
||||
flat = plan_line[:1].price_unit
|
||||
external_amount = float(r.get('external_amount') or 0.0)
|
||||
odoo_amount, delta, status = self._compute_reconciliation(
|
||||
flat, charge, float(r.get('cpu_seconds') or 0.0),
|
||||
external_amount, tolerance)
|
||||
vals = {
|
||||
'service_id': service.id,
|
||||
'partner_id': sub.partner_id.id, 'period': period,
|
||||
'external_subscription_id': sub_ext,
|
||||
'odoo_amount': odoo_amount, 'external_amount': external_amount,
|
||||
'delta': delta, 'status': status,
|
||||
}
|
||||
# Upsert per (service, subscription, period) — NOT per partner — so a
|
||||
# customer with two deployments gets a row for each, no overwrite.
|
||||
existing = self.search([
|
||||
('service_id', '=', service.id),
|
||||
('external_subscription_id', '=', sub_ext),
|
||||
('period', '=', period)], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
self.create(vals)
|
||||
summary['match' if status == 'match' else 'delta'] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||
_logger.exception("Reconciliation row %s failed", sub_ext)
|
||||
summary['failed'].append(
|
||||
{'id': sub_ext, 'error': '%s: %s' % (type(e).__name__, e)})
|
||||
return summary
|
||||
12
fusion_centralize_billing/models/res_partner.py
Normal file
12
fusion_centralize_billing/models/res_partner.py
Normal file
@@ -0,0 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = "res.partner"
|
||||
|
||||
x_fc_stripe_customer_id = fields.Char(
|
||||
index=True, copy=False,
|
||||
help="Existing Stripe customer id imported from a source app, reused at flip.")
|
||||
45
fusion_centralize_billing/models/sale_order.py
Normal file
45
fusion_centralize_billing/models/sale_order.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = "sale.order"
|
||||
|
||||
x_fc_nexacloud_subscription_id = fields.Char(
|
||||
index=True, copy=False,
|
||||
help="Source NexaCloud subscription id — the importer's idempotency key.")
|
||||
x_fc_nexacloud_deployment_id = fields.Char(index=True, copy=False)
|
||||
x_fc_nexacloud_plan_id = fields.Char(
|
||||
index=True, copy=False,
|
||||
help="Source NexaCloud plan id — links the shadow sub to its charge for 2d reconciliation.")
|
||||
x_fc_billing_service_id = fields.Many2one(
|
||||
"fusion.billing.service", index=True, copy=False, ondelete="set null")
|
||||
x_fc_shadow = fields.Boolean(
|
||||
default=False, copy=False,
|
||||
help="Imported in shadow mode: Odoo computes but must not charge/post/email.")
|
||||
|
||||
def _fc_rate_usage(self, charge, period_start, period_end):
|
||||
"""Aggregate this subscription's usage for `charge`'s metric in the period,
|
||||
compute the overage amount, and upsert a matching overage order line.
|
||||
Returns the amount.
|
||||
|
||||
A zero amount never *creates* a new line (no $0.00 overage clutter); if a
|
||||
line already exists it is still updated so a dropped-to-zero overage clears.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Usage = self.env['fusion.billing.usage']
|
||||
total = Usage._aggregate(self, charge.metric_id, period_start, period_end)
|
||||
_overage, amount = charge._compute_billable(total)
|
||||
if charge.product_id:
|
||||
line = self.order_line.filtered(lambda l: l.product_id == charge.product_id)
|
||||
if not line and amount == 0:
|
||||
return amount
|
||||
vals = {'product_uom_qty': 1, 'price_unit': amount}
|
||||
if line:
|
||||
line.write(vals)
|
||||
else:
|
||||
self.env['sale.order.line'].create(
|
||||
{'order_id': self.id, 'product_id': charge.product_id.id, **vals})
|
||||
return amount
|
||||
249
fusion_centralize_billing/models/service.py
Normal file
249
fusion_centralize_billing/models/service.py
Normal file
@@ -0,0 +1,249 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import secrets
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class FusionBillingService(models.Model):
|
||||
"""A source app that pushes billing data (NexaCloud / NexaDesk / NexaMaps).
|
||||
|
||||
The bearer API key is shown ONCE on generation and stored only as a SHA-256
|
||||
hash. This record is the auth + routing boundary for the inbound API and the
|
||||
target for outbound webhooks. See spec §5.1 / §7 / §8.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.service"
|
||||
_description = "Fusion Billing — Source Service"
|
||||
_order = "name"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
code = fields.Char(
|
||||
required=True, index=True,
|
||||
help="Stable code the app identifies itself with, e.g. nexacloud / nexadesk / nexamaps.",
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
api_key_hash = fields.Char(
|
||||
string="API Key (SHA-256)",
|
||||
help="Hash of the bearer key. The raw key is displayed once at generation time.",
|
||||
)
|
||||
webhook_url = fields.Char(help="Endpoint this app exposes to receive billing webhooks.")
|
||||
webhook_secret = fields.Char(help="Shared secret for HMAC-SHA256 webhook signatures.")
|
||||
|
||||
account_link_ids = fields.One2many(
|
||||
"fusion.billing.account.link", "service_id", string="Customer Links",
|
||||
)
|
||||
account_link_count = fields.Integer(compute="_compute_account_link_count")
|
||||
|
||||
_code_uniq = models.Constraint("unique(code)", "Service code must be unique.")
|
||||
|
||||
@api.depends("account_link_ids")
|
||||
def _compute_account_link_count(self):
|
||||
for rec in self:
|
||||
rec.account_link_count = len(rec.account_link_ids)
|
||||
|
||||
@api.constrains("webhook_url")
|
||||
def _check_webhook_url(self):
|
||||
"""Reject SSRF-prone webhook targets: a non-empty URL must be https and must
|
||||
not point at localhost or a private / link-local / loopback IP literal. Empty
|
||||
is allowed (no webhook configured)."""
|
||||
for rec in self:
|
||||
url = (rec.webhook_url or "").strip()
|
||||
if not url:
|
||||
continue
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme != "https":
|
||||
raise ValidationError("Webhook URL must use https.")
|
||||
host = parsed.hostname or ""
|
||||
if not host or host.lower() in ("localhost", "ip6-localhost", "ip6-loopback"):
|
||||
raise ValidationError(
|
||||
"Webhook URL must not target localhost or a private address.")
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
ip = None
|
||||
if ip is not None and (
|
||||
ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_reserved or ip.is_unspecified or ip.is_multicast
|
||||
):
|
||||
raise ValidationError(
|
||||
"Webhook URL must not target a private or loopback address.")
|
||||
|
||||
def action_generate_api_key(self):
|
||||
"""Generate a fresh bearer key, store only its hash, return the raw key.
|
||||
|
||||
TODO(spec §7): surface the raw key once in the UI (wizard/notification).
|
||||
"""
|
||||
self.ensure_one()
|
||||
raw = secrets.token_urlsafe(32)
|
||||
self.api_key_hash = hashlib.sha256(raw.encode()).hexdigest()
|
||||
return raw
|
||||
|
||||
@api.model
|
||||
def _match_api_key(self, raw_key):
|
||||
"""Return the active service whose stored hash matches raw_key, else empty recordset."""
|
||||
if not raw_key:
|
||||
return self.browse()
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
return self.search([('api_key_hash', '=', key_hash), ('active', '=', True)], limit=1)
|
||||
|
||||
def _api_upsert_customer(self, payload):
|
||||
"""Resolve/create the partner link for an external account.
|
||||
|
||||
Defensive: a non-dict payload or a missing/empty ``external_id`` returns a
|
||||
4xx-shaped error instead of raising (C3).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not isinstance(payload, dict):
|
||||
return {'status': 'error', 'error': 'invalid payload'}
|
||||
ext = payload.get('external_id')
|
||||
if not ext:
|
||||
return {'status': 'error', 'error': 'external_id required'}
|
||||
link = self.env['fusion.billing.account.link']._resolve_or_create_partner(
|
||||
self, ext, name=payload.get('name'), email=payload.get('email'))
|
||||
return {'status': 'ok', 'partner_id': link.partner_id.id, 'external_id': ext}
|
||||
|
||||
def _fc_resolve_subscription(self, external_ref):
|
||||
"""Resolve the subscription sale.order a usage event targets.
|
||||
|
||||
Prefer the source app's OWN id (``x_fc_nexacloud_subscription_id`` scoped to this
|
||||
service) so apps reference their own ids — this is what lets NexaCloud push usage
|
||||
against shadow subscriptions the importer created from its UUIDs. Falls back to a
|
||||
direct Odoo ``sale.order`` id for live-created subs (post-flip). Authorization is
|
||||
still enforced by the caller (partner must be linked to this service)."""
|
||||
self.ensure_one()
|
||||
SaleOrder = self.env['sale.order']
|
||||
sub = SaleOrder.search([
|
||||
('x_fc_nexacloud_subscription_id', '=', str(external_ref)),
|
||||
('x_fc_billing_service_id', '=', self.id),
|
||||
], limit=1)
|
||||
if sub:
|
||||
return sub
|
||||
try:
|
||||
candidate = SaleOrder.browse(int(external_ref))
|
||||
except (TypeError, ValueError):
|
||||
return SaleOrder
|
||||
# Don't let the integer fallback reach a DIFFERENT service's tagged subscription.
|
||||
# (Live, API-created subs carry no service tag and stay resolvable here; the caller
|
||||
# still enforces partner-is-linked-to-this-service authorization.)
|
||||
if candidate.exists() and candidate.x_fc_billing_service_id \
|
||||
and candidate.x_fc_billing_service_id != self:
|
||||
return SaleOrder
|
||||
return candidate
|
||||
|
||||
def _api_record_usage(self, payload):
|
||||
"""Ingest a batch of usage events.
|
||||
|
||||
Authorization (C2/C4): each event must target a subscription sale.order that
|
||||
(a) exists, (b) is actually a subscription, and (c) belongs to a customer THIS
|
||||
service is linked to. Any failing event is rejected and stops processing for
|
||||
that event without writing a usage row.
|
||||
|
||||
Validation (C3): a non-dict payload, a non-list ``events``, missing required
|
||||
keys, or non-numeric ``quantity``/ids return a 4xx-shaped error instead of
|
||||
raising (no 500s).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not isinstance(payload, dict):
|
||||
return {'status': 'error', 'error': 'invalid payload'}
|
||||
events = payload.get('events')
|
||||
if events is None:
|
||||
events = []
|
||||
if not isinstance(events, list):
|
||||
return {'status': 'error', 'error': 'events must be a list'}
|
||||
Usage = self.env['fusion.billing.usage']
|
||||
linked_partners = self.account_link_ids.mapped('partner_id')
|
||||
accepted = 0
|
||||
for ev in events:
|
||||
if not isinstance(ev, dict):
|
||||
return {'status': 'error', 'error': 'invalid event'}
|
||||
for key in ('subscription_external_id', 'metric_code', 'quantity',
|
||||
'period_start', 'period_end'):
|
||||
if ev.get(key) in (None, ''):
|
||||
return {'status': 'error', 'error': 'missing %s' % key}
|
||||
try:
|
||||
quantity = float(ev['quantity'])
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'error': 'invalid quantity'}
|
||||
sub = self._fc_resolve_subscription(ev['subscription_external_id'])
|
||||
if not sub.exists() or not sub.is_subscription \
|
||||
or sub.partner_id not in linked_partners:
|
||||
return {'status': 'error', 'error': 'unknown subscription'}
|
||||
try:
|
||||
Usage._record_usage(
|
||||
sub, ev['metric_code'], quantity,
|
||||
ev['period_start'], ev['period_end'], idem=ev.get('idempotency_key'))
|
||||
except ValueError as e:
|
||||
return {'status': 'error', 'error': str(e)}
|
||||
accepted += 1
|
||||
return {'status': 'ok', 'accepted': accepted}
|
||||
|
||||
def _api_catalog(self):
|
||||
self.ensure_one()
|
||||
charges = self.env['fusion.billing.charge'].search([('active', '=', True)])
|
||||
return {'status': 'ok', 'charges': [{
|
||||
'plan_code': c.plan_code, 'metric': c.metric_id.code,
|
||||
'included_quota': c.included_quota, 'price_per_unit': c.price_per_unit,
|
||||
'unit_batch': c.unit_batch, 'charge_model': c.charge_model,
|
||||
} for c in charges]}
|
||||
|
||||
def _api_create_subscription(self, payload):
|
||||
"""Create and confirm a subscription sale.order for an external customer.
|
||||
|
||||
The product on each line must have recurring_invoice=True so that
|
||||
Odoo recognises the order as a subscription with has_recurring_line and
|
||||
action_confirm() reaches subscription_state='3_progress'.
|
||||
|
||||
Validation (C3): a non-dict payload, a missing/unknown customer, a missing
|
||||
``plan_id``, a non-list ``lines``, or a non-numeric product id/quantity
|
||||
return a 4xx-shaped error instead of raising (no 500s).
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not isinstance(payload, dict):
|
||||
return {'status': 'error', 'error': 'invalid payload'}
|
||||
if not payload.get('external_customer_id'):
|
||||
return {'status': 'error', 'error': 'external_customer_id required'}
|
||||
if not payload.get('plan_id'):
|
||||
return {'status': 'error', 'error': 'plan_id required'}
|
||||
try:
|
||||
plan_id = int(payload['plan_id'])
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'error': 'invalid plan_id'}
|
||||
link = self.env['fusion.billing.account.link'].search([
|
||||
('service_id', '=', self.id),
|
||||
('external_id', '=', payload.get('external_customer_id')),
|
||||
], limit=1)
|
||||
if not link:
|
||||
return {'status': 'error', 'error': 'unknown customer'}
|
||||
lines = payload.get('lines')
|
||||
if lines is None:
|
||||
lines = []
|
||||
if not isinstance(lines, list):
|
||||
return {'status': 'error', 'error': 'lines must be a list'}
|
||||
order_lines = []
|
||||
for line in lines:
|
||||
if not isinstance(line, dict) or line.get('product_id') in (None, ''):
|
||||
return {'status': 'error', 'error': 'invalid line'}
|
||||
try:
|
||||
product_id = int(line['product_id'])
|
||||
quantity = float(line.get('quantity', 1))
|
||||
except (TypeError, ValueError):
|
||||
return {'status': 'error', 'error': 'invalid line'}
|
||||
order_lines.append((0, 0, {
|
||||
'product_id': product_id,
|
||||
'product_uom_qty': quantity,
|
||||
}))
|
||||
sub = self.env['sale.order'].sudo().create({
|
||||
'partner_id': link.partner_id.id,
|
||||
'plan_id': plan_id,
|
||||
'order_line': order_lines,
|
||||
})
|
||||
sub.action_confirm()
|
||||
return {'status': 'ok', 'subscription_id': sub.id,
|
||||
'subscription_state': sub.subscription_state}
|
||||
120
fusion_centralize_billing/models/usage.py
Normal file
120
fusion_centralize_billing/models/usage.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionBillingUsage(models.Model):
|
||||
"""Aggregated usage rollup for a (subscription, metric, period).
|
||||
|
||||
Aggregate-push model: apps send periodic counters (not raw events). The
|
||||
``idempotency_key`` makes re-sent counters safe — they never double-count.
|
||||
A pre-invoice cron sums these and feeds billable quantity onto the subscription.
|
||||
|
||||
NOTE (Odoo 19, verified): the subscription is a ``sale.order`` with
|
||||
``is_subscription=True`` — there is no ``sale.subscription`` model. See spec §5.2.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.usage"
|
||||
_description = "Fusion Billing — Aggregated Usage (period rollup)"
|
||||
_order = "period_start desc"
|
||||
|
||||
subscription_id = fields.Many2one(
|
||||
"sale.order", required=True, ondelete="cascade", index=True,
|
||||
string="Subscription", domain=[("is_subscription", "=", True)],
|
||||
)
|
||||
metric_id = fields.Many2one(
|
||||
"fusion.billing.metric", required=True, ondelete="restrict", index=True,
|
||||
)
|
||||
period_start = fields.Datetime(required=True)
|
||||
period_end = fields.Datetime(required=True)
|
||||
quantity = fields.Float(default=0.0)
|
||||
source = fields.Char(default="push")
|
||||
idempotency_key = fields.Char(
|
||||
index=True, help="Dedupe key so re-sent counters never double-count.",
|
||||
)
|
||||
|
||||
_idempotency_uniq = models.Constraint(
|
||||
"unique(subscription_id, metric_id, idempotency_key)",
|
||||
"Usage idempotency key must be unique per subscription and metric.",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _record_usage(self, subscription, metric_code, quantity, period_start, period_end, idem=None):
|
||||
"""Upsert one aggregated usage row. Same idempotency key (scoped to the same
|
||||
subscription + metric) updates in place (no double-count)."""
|
||||
metric = self.env['fusion.billing.metric'].search([('code', '=', metric_code)], limit=1)
|
||||
if not metric:
|
||||
raise ValueError("Unknown metric code: %s" % metric_code)
|
||||
vals = {
|
||||
'subscription_id': subscription.id,
|
||||
'metric_id': metric.id,
|
||||
'period_start': period_start,
|
||||
'period_end': period_end,
|
||||
'quantity': quantity,
|
||||
'idempotency_key': idem,
|
||||
}
|
||||
if idem:
|
||||
existing = self.search([
|
||||
('subscription_id', '=', subscription.id),
|
||||
('metric_id', '=', metric.id),
|
||||
('idempotency_key', '=', idem),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write({'quantity': quantity})
|
||||
return existing
|
||||
return self.create(vals)
|
||||
|
||||
@api.model
|
||||
def _cron_rate_open_periods(self):
|
||||
"""Hourly cron: for every active charge, aggregate usage and upsert overage lines
|
||||
on the in-progress subscriptions that are on the charge's own plan.
|
||||
|
||||
A charge only rates subscriptions whose ``plan_id`` matches the charge's
|
||||
``plan_id`` — never every subscription against every charge (C1/H4). The
|
||||
billing-period window is the subscription's real open period
|
||||
``[last_invoice_date or start_date, next_invoice_date)`` (H1)."""
|
||||
Charge = self.env['fusion.billing.charge'].search([('active', '=', True)])
|
||||
SaleOrder = self.env['sale.order']
|
||||
for charge in Charge:
|
||||
if not charge.plan_id:
|
||||
continue
|
||||
subs = SaleOrder.search([
|
||||
('is_subscription', '=', True),
|
||||
('subscription_state', '=', '3_progress'),
|
||||
('plan_id', '=', charge.plan_id.id),
|
||||
])
|
||||
for sub in subs:
|
||||
if not sub.next_invoice_date:
|
||||
continue
|
||||
period_end = fields.Datetime.to_datetime(sub.next_invoice_date)
|
||||
period_start = fields.Datetime.to_datetime(
|
||||
sub.last_invoice_date or sub.start_date)
|
||||
if not period_start:
|
||||
continue
|
||||
sub._fc_rate_usage(charge, period_start, period_end)
|
||||
|
||||
@api.model
|
||||
def _aggregate(self, subscription, metric, period_start, period_end):
|
||||
"""Aggregate stored usage for a subscription+metric over the half-open window
|
||||
``[period_start, period_end)``, anchored on each rollup's ``period_start``,
|
||||
using the metric's aggregation function."""
|
||||
rows = self.search([
|
||||
('subscription_id', '=', subscription.id),
|
||||
('metric_id', '=', metric.id),
|
||||
('period_start', '>=', period_start),
|
||||
('period_start', '<', period_end),
|
||||
])
|
||||
qtys = rows.mapped('quantity')
|
||||
if not qtys:
|
||||
return 0.0
|
||||
agg = metric.aggregation
|
||||
if agg == 'sum':
|
||||
return sum(qtys)
|
||||
if agg == 'max':
|
||||
return max(qtys)
|
||||
if agg == 'last':
|
||||
return rows.sorted('period_start')[-1].quantity
|
||||
if agg == 'unique_count':
|
||||
return float(len(set(qtys)))
|
||||
return sum(qtys)
|
||||
113
fusion_centralize_billing/models/webhook.py
Normal file
113
fusion_centralize_billing/models/webhook.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import requests
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_ATTEMPTS = 8
|
||||
|
||||
|
||||
class FusionBillingWebhook(models.Model):
|
||||
"""Outbound webhook queue: lifecycle events delivered to source apps.
|
||||
|
||||
Processed by a cron with exponential backoff + HMAC-SHA256 signing, dead-lettered
|
||||
after N attempts (mirror the proven retry pattern in NexaDesk's
|
||||
lago-payment-retry-job). Apps react: suspend / restore / deprovision. See spec §8.
|
||||
|
||||
TODO(spec §8): cron processor, HMAC signing, backoff schedule.
|
||||
"""
|
||||
|
||||
_name = "fusion.billing.webhook"
|
||||
_description = "Fusion Billing — Outbound Webhook Event"
|
||||
_order = "create_date desc"
|
||||
|
||||
service_id = fields.Many2one(
|
||||
"fusion.billing.service", required=True, ondelete="cascade", index=True,
|
||||
)
|
||||
event_type = fields.Char(
|
||||
required=True, index=True,
|
||||
help="invoice.payment_failed / invoice.payment_succeeded / "
|
||||
"subscription.terminated / subscription.reactivated / usage.threshold_reached",
|
||||
)
|
||||
payload = fields.Json()
|
||||
body = fields.Text(
|
||||
help="Canonical JSON body that was signed and is POSTed verbatim "
|
||||
"(so the signature always matches the bytes on the wire).",
|
||||
)
|
||||
state = fields.Selection(
|
||||
[
|
||||
("pending", "Pending"),
|
||||
("sent", "Sent"),
|
||||
("failed", "Failed"),
|
||||
("dead", "Dead-lettered"),
|
||||
],
|
||||
default="pending", required=True, index=True,
|
||||
)
|
||||
attempts = fields.Integer(default=0)
|
||||
next_retry_at = fields.Datetime()
|
||||
signature = fields.Char(help="HMAC-SHA256 of the payload using the service webhook_secret.")
|
||||
last_error = fields.Text()
|
||||
|
||||
@api.model
|
||||
def _sign(self, secret, body):
|
||||
return hmac.new((secret or '').encode(), body.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
@api.model
|
||||
def _enqueue(self, service, event_type, payload):
|
||||
# Serialize the canonical body ONCE, store it, and sign that exact string so
|
||||
# the dispatched bytes always match the signature (no re-serialization drift).
|
||||
body = json.dumps(payload, sort_keys=True, separators=(',', ':'))
|
||||
return self.create({
|
||||
'service_id': service.id,
|
||||
'event_type': event_type,
|
||||
'payload': payload,
|
||||
'body': body,
|
||||
'signature': self._sign(service.webhook_secret, body),
|
||||
'state': 'pending',
|
||||
'next_retry_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
@api.model
|
||||
def _cron_dispatch(self):
|
||||
now = fields.Datetime.now()
|
||||
due = self.search([
|
||||
('state', 'in', ('pending', 'failed')),
|
||||
('next_retry_at', '<=', now),
|
||||
], limit=100)
|
||||
for wh in due:
|
||||
# POST the exact bytes that were signed at enqueue time. Fall back to
|
||||
# re-serializing the payload only for legacy rows enqueued before `body`
|
||||
# existed (the signature was computed over the same canonical form).
|
||||
body = wh.body or json.dumps(wh.payload, sort_keys=True, separators=(',', ':'))
|
||||
try:
|
||||
resp = requests.post(
|
||||
wh.service_id.webhook_url,
|
||||
data=body,
|
||||
headers={'Content-Type': 'application/json',
|
||||
'X-Fusion-Signature': wh.signature,
|
||||
'X-Fusion-Event': wh.event_type,
|
||||
'X-Fusion-Event-Id': str(wh.id)},
|
||||
timeout=10,
|
||||
)
|
||||
ok = 200 <= resp.status_code < 300
|
||||
except Exception as e: # noqa: BLE001 - record and retry
|
||||
ok = False
|
||||
wh.last_error = str(e)[:500]
|
||||
wh.attempts += 1
|
||||
if ok:
|
||||
wh.state = 'sent'
|
||||
elif wh.attempts >= MAX_ATTEMPTS:
|
||||
wh.state = 'dead'
|
||||
else:
|
||||
wh.state = 'failed'
|
||||
# Cap the exponential backoff so the interval can't overflow.
|
||||
wh.next_retry_at = now + timedelta(minutes=2 ** min(wh.attempts, 10))
|
||||
13
fusion_centralize_billing/security/ir.model.access.csv
Normal file
13
fusion_centralize_billing/security/ir.model.access.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_billing_service_admin,fusion.billing.service admin,model_fusion_billing_service,base.group_system,1,1,1,1
|
||||
access_fusion_billing_account_link_admin,fusion.billing.account.link admin,model_fusion_billing_account_link,base.group_system,1,1,1,1
|
||||
access_fusion_billing_metric_admin,fusion.billing.metric admin,model_fusion_billing_metric,base.group_system,1,1,1,1
|
||||
access_fusion_billing_charge_admin,fusion.billing.charge admin,model_fusion_billing_charge,base.group_system,1,1,1,1
|
||||
access_fusion_billing_usage_admin,fusion.billing.usage admin,model_fusion_billing_usage,base.group_system,1,1,1,1
|
||||
access_fusion_billing_webhook_admin,fusion.billing.webhook admin,model_fusion_billing_webhook,base.group_system,1,1,1,1
|
||||
access_fusion_billing_reconciliation_admin,fusion.billing.reconciliation admin,model_fusion_billing_reconciliation,base.group_system,1,1,1,1
|
||||
access_fusion_billing_metric_acct,fusion.billing.metric accountant,model_fusion_billing_metric,account.group_account_manager,1,1,1,0
|
||||
access_fusion_billing_charge_acct,fusion.billing.charge accountant,model_fusion_billing_charge,account.group_account_manager,1,1,1,0
|
||||
access_fusion_billing_reconciliation_acct,fusion.billing.reconciliation accountant,model_fusion_billing_reconciliation,account.group_account_manager,1,1,1,0
|
||||
access_fusion_billing_import_wizard,fusion.billing.import.wizard,model_fusion_billing_import_wizard,base.group_system,1,1,1,1
|
||||
access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1
|
||||
|
8
fusion_centralize_billing/tests/__init__.py
Normal file
8
fusion_centralize_billing/tests/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from . import test_identity
|
||||
from . import test_charge
|
||||
from . import test_usage
|
||||
from . import test_api
|
||||
from . import test_webhook
|
||||
from . import test_importer
|
||||
from . import test_reconciliation
|
||||
from . import test_invoice_ledger
|
||||
139
fusion_centralize_billing/tests/test_api.py
Normal file
139
fusion_centralize_billing/tests/test_api.py
Normal file
@@ -0,0 +1,139 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestApiHandlers(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaMaps', 'code': 'nexamaps'})
|
||||
self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
|
||||
def test_api_upsert_customer(self):
|
||||
res = self.service._api_upsert_customer(
|
||||
{'external_id': 'client-9', 'name': 'Globex', 'email': 'billing@globex.test'})
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
link = self.env['fusion.billing.account.link'].search(
|
||||
[('service_id', '=', self.service.id), ('external_id', '=', 'client-9')])
|
||||
self.assertEqual(link.partner_id.name, 'Globex')
|
||||
|
||||
def test_api_record_usage_batch(self):
|
||||
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
|
||||
partner = self.env['fusion.billing.account.link'].search(
|
||||
[('external_id', '=', 'client-9')]).partner_id
|
||||
sub = self.env['sale.order'].sudo().create(
|
||||
{'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id})
|
||||
res = self.service._api_record_usage({'events': [{
|
||||
'subscription_external_id': str(sub.id), 'metric_code': 'api_calls',
|
||||
'quantity': 1234.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
|
||||
'idempotency_key': 'maps:client-9:2026-05-01',
|
||||
}]})
|
||||
self.assertEqual(res['accepted'], 1)
|
||||
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
|
||||
self.assertEqual(usage.quantity, 1234.0)
|
||||
|
||||
def test_api_catalog_lists_active_charges(self):
|
||||
self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'Maps overage', 'plan_code': 'maps-business',
|
||||
'metric_id': self.env['fusion.billing.metric'].search([('code', '=', 'api_calls')]).id,
|
||||
'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0})
|
||||
cat = self.service._api_catalog()
|
||||
codes = [c['plan_code'] for c in cat['charges']]
|
||||
self.assertIn('maps-business', codes)
|
||||
|
||||
def test_api_create_subscription(self):
|
||||
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
|
||||
product = self.env['product.product'].sudo().create(
|
||||
{'name': 'Maps Business', 'type': 'service', 'recurring_invoice': True,
|
||||
'list_price': 249.0})
|
||||
res = self.service._api_create_subscription({
|
||||
'external_customer_id': 'client-9',
|
||||
'plan_id': self.plan.id,
|
||||
'lines': [{'product_id': product.id, 'quantity': 1}],
|
||||
})
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
sub = self.env['sale.order'].browse(res['subscription_id'])
|
||||
self.assertTrue(sub.is_subscription)
|
||||
self.assertEqual(sub.plan_id, self.plan)
|
||||
self.assertEqual(sub.subscription_state, '3_progress')
|
||||
|
||||
# ── item 4 (C3): malformed input returns a 4xx-shaped error, never raises ──
|
||||
def test_record_usage_missing_metric_code_returns_error(self):
|
||||
self.service._api_upsert_customer({'external_id': 'client-9', 'name': 'Globex'})
|
||||
partner = self.env['fusion.billing.account.link'].search(
|
||||
[('external_id', '=', 'client-9')]).partner_id
|
||||
sub = self.env['sale.order'].sudo().create(
|
||||
{'partner_id': partner.id, 'is_subscription': True, 'plan_id': self.plan.id})
|
||||
# metric_code intentionally omitted — must return an error dict, not raise
|
||||
res = self.service._api_record_usage({'events': [{
|
||||
'subscription_external_id': str(sub.id),
|
||||
'quantity': 10.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
|
||||
}]})
|
||||
self.assertEqual(res['status'], 'error')
|
||||
# no usage row written
|
||||
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
|
||||
self.assertFalse(usage)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUsageAuthorization(TransactionCase):
|
||||
"""/usage must only accept events for subscriptions the calling service is linked
|
||||
to, and reject unknown / non-subscription targets (items 3/C2/C4)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.service_a = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'Service A', 'code': 'svc_a'})
|
||||
self.service_b = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'Service B', 'code': 'svc_b'})
|
||||
# Service A owns customer + subscription
|
||||
self.service_a._api_upsert_customer({'external_id': 'cust-a', 'name': 'Cust A'})
|
||||
self.partner_a = self.env['fusion.billing.account.link'].search(
|
||||
[('service_id', '=', self.service_a.id), ('external_id', '=', 'cust-a')]).partner_id
|
||||
self.sub_a = self.env['sale.order'].sudo().create(
|
||||
{'partner_id': self.partner_a.id, 'is_subscription': True, 'plan_id': self.plan.id})
|
||||
self.Usage = self.env['fusion.billing.usage'].sudo()
|
||||
|
||||
def _event(self, sub_id, idem):
|
||||
return {'events': [{
|
||||
'subscription_external_id': str(sub_id), 'metric_code': 'api_calls',
|
||||
'quantity': 42.0, 'period_start': '2026-05-01', 'period_end': '2026-06-01',
|
||||
'idempotency_key': idem,
|
||||
}]}
|
||||
|
||||
def test_cross_service_usage_rejected(self):
|
||||
"""Service B pushing usage onto Service A's subscription is rejected, no row."""
|
||||
res = self.service_b._api_record_usage(self._event(self.sub_a.id, 'cross-1'))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
self.assertFalse(self.Usage.search([('subscription_id', '=', self.sub_a.id)]))
|
||||
|
||||
def test_same_service_usage_accepted(self):
|
||||
"""Positive control: Service A pushing onto its own subscription is accepted."""
|
||||
res = self.service_a._api_record_usage(self._event(self.sub_a.id, 'ok-1'))
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(res['accepted'], 1)
|
||||
self.assertTrue(self.Usage.search([('subscription_id', '=', self.sub_a.id)]))
|
||||
|
||||
def test_nonexistent_subscription_rejected(self):
|
||||
res = self.service_a._api_record_usage(self._event(999_999_999, 'ghost-1'))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
|
||||
def test_non_subscription_order_rejected(self):
|
||||
"""A plain (non-subscription) sale.order owned by the linked customer is rejected."""
|
||||
plain = self.env['sale.order'].sudo().create({'partner_id': self.partner_a.id})
|
||||
self.assertFalse(plain.is_subscription)
|
||||
res = self.service_a._api_record_usage(self._event(plain.id, 'plain-1'))
|
||||
self.assertEqual(res['status'], 'error')
|
||||
self.assertEqual(res['error'], 'unknown subscription')
|
||||
self.assertFalse(self.Usage.search([('subscription_id', '=', plain.id)]))
|
||||
74
fusion_centralize_billing/tests/test_charge.py
Normal file
74
fusion_centralize_billing/tests/test_charge.py
Normal file
@@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from psycopg2 import IntegrityError
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.tools.misc import mute_logger
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestChargeMath(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'API Calls', 'code': 'api_calls', 'aggregation': 'sum'})
|
||||
|
||||
def _charge(self, **kw):
|
||||
vals = {
|
||||
'name': 'Maps overage', 'plan_code': 'maps-business',
|
||||
'metric_id': self.metric.id, 'charge_model': 'standard',
|
||||
'included_quota': 5_000_000.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fusion.billing.charge'].sudo().create(vals)
|
||||
|
||||
def test_under_quota_is_free(self):
|
||||
charge = self._charge()
|
||||
overage_units, amount = charge._compute_billable(4_000_000.0)
|
||||
self.assertEqual(overage_units, 0.0)
|
||||
self.assertEqual(amount, 0.0)
|
||||
|
||||
def test_standard_overage_per_1k(self):
|
||||
charge = self._charge()
|
||||
# 6,000,000 used - 5,000,000 quota = 1,000,000 overage = 1000 batches * $0.10
|
||||
overage_units, amount = charge._compute_billable(6_000_000.0)
|
||||
self.assertEqual(overage_units, 1_000_000.0)
|
||||
self.assertAlmostEqual(amount, 100.0, places=2)
|
||||
|
||||
def test_partial_batch_rounds_up(self):
|
||||
charge = self._charge(included_quota=0.0)
|
||||
# 1,500 units, batch 1000 -> 2 batches -> $0.20
|
||||
_, amount = charge._compute_billable(1_500.0)
|
||||
self.assertAlmostEqual(amount, 0.20, places=2)
|
||||
|
||||
def test_package_model_charges_whole_packages(self):
|
||||
charge = self._charge(charge_model='package', included_quota=0.0, unit_batch=1000.0, price_per_unit=2.0)
|
||||
# 2,001 units -> 3 packages -> $6.00
|
||||
_, amount = charge._compute_billable(2_001.0)
|
||||
self.assertAlmostEqual(amount, 6.0, places=2)
|
||||
|
||||
# ── item 10 (M7): only the two implemented charge models remain ──
|
||||
def test_charge_model_selection_limited(self):
|
||||
field = self.env['fusion.billing.charge']._fields['charge_model']
|
||||
keys = [k for k, _label in field.selection]
|
||||
self.assertEqual(sorted(keys), ['package', 'standard'])
|
||||
self.assertNotIn('graduated', keys)
|
||||
self.assertNotIn('volume', keys)
|
||||
|
||||
# ── item 11 (L1): currency is required and defaults to company currency ──
|
||||
def test_currency_required_and_defaulted(self):
|
||||
field = self.env['fusion.billing.charge']._fields['currency_id']
|
||||
self.assertTrue(field.required)
|
||||
charge = self._charge()
|
||||
self.assertEqual(charge.currency_id, self.env.company.currency_id)
|
||||
|
||||
# ── item 12 (L2): non-negative price + positive batch DB constraints ──
|
||||
def test_negative_price_rejected(self):
|
||||
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
|
||||
with self.env.cr.savepoint():
|
||||
self._charge(price_per_unit=-1.0)
|
||||
|
||||
def test_zero_batch_rejected(self):
|
||||
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
|
||||
with self.env.cr.savepoint():
|
||||
self._charge(unit_batch=0.0)
|
||||
55
fusion_centralize_billing/tests/test_identity.py
Normal file
55
fusion_centralize_billing/tests/test_identity.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestServiceApiKey(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Service = self.env['fusion.billing.service'].sudo()
|
||||
self.service = self.Service.create({'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
|
||||
def test_generate_and_match_api_key(self):
|
||||
raw = self.service.action_generate_api_key()
|
||||
self.assertTrue(raw and len(raw) >= 20)
|
||||
self.assertTrue(self.service.api_key_hash)
|
||||
self.assertNotEqual(raw, self.service.api_key_hash) # only the hash is stored
|
||||
matched = self.Service._match_api_key(raw)
|
||||
self.assertEqual(matched, self.service)
|
||||
|
||||
def test_match_api_key_rejects_unknown_and_inactive(self):
|
||||
raw = self.service.action_generate_api_key()
|
||||
self.assertFalse(self.Service._match_api_key('nope-not-a-key'))
|
||||
self.service.active = False
|
||||
self.assertFalse(self.Service._match_api_key(raw))
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestIdentityResolution(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaDesk', 'code': 'nexadesk'})
|
||||
self.Link = self.env['fusion.billing.account.link'].sudo()
|
||||
|
||||
def test_creates_partner_first_time(self):
|
||||
link = self.Link._resolve_or_create_partner(
|
||||
self.service, external_id='tenant-1', name='Acme Inc', email='ar@acme.test')
|
||||
self.assertTrue(link.partner_id)
|
||||
self.assertEqual(link.partner_id.name, 'Acme Inc')
|
||||
self.assertEqual(link.external_id, 'tenant-1')
|
||||
|
||||
def test_idempotent_same_external_id(self):
|
||||
a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test')
|
||||
b = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme Renamed', 'ar@acme.test')
|
||||
self.assertEqual(a, b) # same link row
|
||||
self.assertEqual(a.partner_id, b.partner_id) # same partner
|
||||
|
||||
def test_reuses_partner_by_email_across_services(self):
|
||||
other = self.env['fusion.billing.service'].sudo().create({'name': 'Maps', 'code': 'nexamaps'})
|
||||
a = self.Link._resolve_or_create_partner(self.service, 'tenant-1', 'Acme', 'ar@acme.test')
|
||||
b = self.Link._resolve_or_create_partner(other, 'client-9', 'Acme', 'ar@acme.test')
|
||||
self.assertEqual(a.partner_id, b.partner_id) # one unified customer
|
||||
self.assertNotEqual(a, b) # but distinct link rows
|
||||
279
fusion_centralize_billing/tests/test_importer.py
Normal file
279
fusion_centralize_billing/tests/test_importer.py
Normal file
@@ -0,0 +1,279 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
def _fixture():
|
||||
"""Two users, one plan, two subscriptions (monthly + yearly) — the canonical
|
||||
NexaCloud row dicts the importer consumes."""
|
||||
return {
|
||||
"users": [
|
||||
{"id": "u-1", "email": "ar@acme.test", "full_name": "Acme Inc",
|
||||
"company": "Acme", "billing_email": "billing@acme.test",
|
||||
"billing_address": "1 Main St", "billing_city": "Toronto",
|
||||
"billing_state": "ON", "billing_postal_code": "M1M1M1",
|
||||
"billing_country": "CA", "tax_id": "123456789RT0001",
|
||||
"stripe_customer_id": "cus_ACME"},
|
||||
{"id": "u-2", "email": "ops@globex.test", "full_name": "Globex",
|
||||
"company": "Globex", "billing_email": None, "billing_address": None,
|
||||
"billing_city": None, "billing_state": None, "billing_postal_code": None,
|
||||
"billing_country": None, "tax_id": None, "stripe_customer_id": "cus_GLBX"},
|
||||
],
|
||||
"plans": [
|
||||
{"id": "p-1", "name": "Starter", "price_monthly": 20.0,
|
||||
"price_yearly": 200.0, "cpu_seconds_quota": 18000.0, "is_active": True},
|
||||
],
|
||||
"subscriptions": [
|
||||
{"id": "s-1", "user_id": "u-1", "deployment_id": "d-1", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "monthly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"},
|
||||
{"id": "s-2", "user_id": "u-2", "deployment_id": "d-2", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "yearly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2027-05-01"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterIdentity(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
self.Link = self.env['fusion.billing.account.link'].sudo()
|
||||
|
||||
def test_imports_users_as_partners_and_links(self):
|
||||
self.Wizard._import_rows({'users': _fixture()['users']})
|
||||
svc = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')])
|
||||
self.assertTrue(svc, "importer must find-or-create the nexacloud service")
|
||||
link1 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-1')])
|
||||
self.assertEqual(len(link1), 1)
|
||||
self.assertEqual(link1.partner_id.email, 'billing@acme.test') # billing_email wins
|
||||
self.assertEqual(link1.partner_id.city, 'Toronto')
|
||||
self.assertEqual(link1.partner_id.vat, '123456789RT0001')
|
||||
self.assertEqual(link1.partner_id.x_fc_stripe_customer_id, 'cus_ACME')
|
||||
self.assertEqual(link1.partner_id.country_id.code, 'CA')
|
||||
link2 = self.Link.search([('service_id', '=', svc.id), ('external_id', '=', 'u-2')])
|
||||
self.assertEqual(link2.partner_id.email, 'ops@globex.test') # falls back to email
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterCatalog(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_imports_plan_as_charge_with_null_plan_id(self):
|
||||
self.Wizard._import_rows({'plans': _fixture()['plans']})
|
||||
metric = self.env['fusion.billing.metric'].search([('code', '=', 'cpu_seconds')])
|
||||
self.assertTrue(metric)
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
self.assertEqual(len(charge), 1)
|
||||
self.assertEqual(charge.metric_id, metric)
|
||||
self.assertEqual(charge.included_quota, 18000.0) # = plan.cpu_seconds_quota
|
||||
self.assertEqual(charge.unit_batch, 3600.0) # one core-hour
|
||||
self.assertAlmostEqual(charge.price_per_unit, 0.0075) # CAD per core-hour
|
||||
self.assertEqual(charge.charge_model, 'standard')
|
||||
self.assertFalse(charge.plan_id, "shadow: charge.plan_id must be NULL so the "
|
||||
"rating cron never auto-mutates order lines")
|
||||
self.assertTrue(charge.product_id, "charge needs an overage product")
|
||||
# the subscription product is a recurring product (so orders using it are subs)
|
||||
sub_product = self.env['product.product'].search(
|
||||
[('default_code', '=', 'NC-PLAN-p-1')])
|
||||
self.assertTrue(sub_product.recurring_invoice)
|
||||
|
||||
def test_charge_math_matches_nexacloud(self):
|
||||
# 18000 quota + 2 core-hours overage (7200s) -> 2 batches * $0.0075 = $0.015
|
||||
self.Wizard._import_rows({'plans': _fixture()['plans']})
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
_overage, amount = charge._compute_billable(18000.0 + 7200.0)
|
||||
self.assertAlmostEqual(amount, 0.015, places=4)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterSubscriptions(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_imports_one_draft_shadow_subscription_per_deployment(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
SaleOrder = self.env['sale.order']
|
||||
sub1 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
self.assertEqual(len(sub1), 1)
|
||||
self.assertTrue(sub1.is_subscription)
|
||||
self.assertTrue(sub1.x_fc_shadow)
|
||||
self.assertEqual(sub1.x_fc_nexacloud_deployment_id, 'd-1')
|
||||
self.assertNotEqual(sub1.subscription_state, '3_progress') # left in draft
|
||||
plan_line = sub1.order_line.filtered(
|
||||
lambda l: l.product_id.default_code == 'NC-PLAN-p-1')
|
||||
self.assertEqual(len(plan_line), 1)
|
||||
self.assertAlmostEqual(plan_line.price_unit, 20.0) # price_monthly
|
||||
sub2 = SaleOrder.search([('x_fc_nexacloud_subscription_id', '=', 's-2')])
|
||||
line2 = sub2.order_line.filtered(lambda l: l.product_id.default_code == 'NC-PLAN-p-1')
|
||||
self.assertAlmostEqual(line2.price_unit, 200.0) # price_yearly
|
||||
self.assertEqual(sub2.plan_id.billing_period_unit, 'year')
|
||||
|
||||
def test_subscription_records_nexacloud_plan_id(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
sub1 = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
self.assertEqual(sub1.x_fc_nexacloud_plan_id, 'p-1')
|
||||
|
||||
def test_subscription_skipped_when_user_or_plan_unresolved(self):
|
||||
data = _fixture()
|
||||
data['subscriptions'].append(
|
||||
{"id": "s-3", "user_id": "u-missing", "deployment_id": "d-3", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "monthly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"})
|
||||
summary = self.Wizard._import_rows(data)
|
||||
self.assertFalse(self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-3')]))
|
||||
self.assertTrue(any(s.get('id') == 's-3' for s in summary['skipped']))
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterIdempotencyDryRun(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def _counts(self):
|
||||
return (
|
||||
self.env['fusion.billing.account.link'].search_count([]),
|
||||
self.env['fusion.billing.charge'].search_count([]),
|
||||
self.env['sale.order'].search_count([('x_fc_shadow', '=', True)]),
|
||||
)
|
||||
|
||||
def test_rerun_updates_not_duplicates(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
before = self._counts()
|
||||
data = _fixture()
|
||||
data['plans'][0]['cpu_seconds_quota'] = 99999.0
|
||||
self.Wizard._import_rows(data)
|
||||
self.assertEqual(self._counts(), before, "re-run must upsert, not duplicate")
|
||||
charge = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
self.assertEqual(charge.included_quota, 99999.0)
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
summary = self.Wizard._import_rows(_fixture(), dry_run=True)
|
||||
self.assertTrue(summary.get('dry_run'))
|
||||
self.assertEqual(self._counts(), (0, 0, 0), "dry-run must not persist anything")
|
||||
self.assertFalse(
|
||||
self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')]))
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterShadowSafety(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_import_creates_no_invoice_and_no_payment_token(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
self.assertTrue(subs)
|
||||
partners = subs.mapped('partner_id')
|
||||
invoices = self.env['account.move'].search([
|
||||
('partner_id', 'in', partners.ids), ('move_type', '=', 'out_invoice')])
|
||||
self.assertFalse(invoices, "shadow import must not create any invoice")
|
||||
tokens = self.env['payment.token'].search([('partner_id', 'in', partners.ids)])
|
||||
self.assertFalse(tokens, "shadow import must not attach a payment token")
|
||||
charges = self.env['fusion.billing.charge'].search([('plan_code', '=', 'p-1')])
|
||||
self.assertTrue(charges)
|
||||
self.assertFalse(any(charges.mapped('plan_id')))
|
||||
|
||||
def test_rating_cron_leaves_shadow_subscriptions_untouched(self):
|
||||
self.Wizard._import_rows(_fixture())
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
lines_before = sum(len(s.order_line) for s in subs)
|
||||
self.env['fusion.billing.usage']._cron_rate_open_periods()
|
||||
subs.invalidate_recordset()
|
||||
lines_after = sum(len(s.order_line) for s in subs)
|
||||
self.assertEqual(lines_before, lines_after,
|
||||
"charges with NULL plan_id must keep the rating cron a no-op")
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterErrorIsolation(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
|
||||
def test_one_bad_user_does_not_abort_the_batch(self):
|
||||
data = _fixture()
|
||||
# a row with no id -> str(urow['id']) raises KeyError, must be caught per-row
|
||||
data['users'].insert(0, {"email": "broken@x.test"})
|
||||
summary = self.Wizard._import_rows(data)
|
||||
self.assertEqual(
|
||||
self.env['fusion.billing.account.link'].search_count([]), 2)
|
||||
self.assertTrue(summary['failed'], "the bad row must be recorded in failed[]")
|
||||
self.assertTrue(any(f['kind'] == 'user' for f in summary['failed']))
|
||||
|
||||
def test_unknown_billing_cycle_is_failed_not_silently_monthly(self):
|
||||
data = _fixture()
|
||||
data['subscriptions'][0]['billing_cycle'] = 'annual' # not monthly/yearly
|
||||
summary = self.Wizard._import_rows(data)
|
||||
self.assertFalse(self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-1')]),
|
||||
"an unrecognized billing_cycle must NOT silently create a monthly sub")
|
||||
self.assertTrue(any(f['kind'] == 'subscription' and f['id'] == 's-1'
|
||||
for f in summary['failed']))
|
||||
|
||||
def test_missing_price_for_cycle_is_failed_not_zero(self):
|
||||
data = _fixture()
|
||||
data['plans'][0]['price_yearly'] = None # s-2 is yearly -> no price for it
|
||||
summary = self.Wizard._import_rows(data)
|
||||
# the yearly sub fails (no silent $0 line); the monthly one still imports
|
||||
self.assertFalse(self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-2')]),
|
||||
"a missing price for the cycle must NOT silently create a $0 line")
|
||||
self.assertTrue(self.env['sale.order'].search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', 's-1')]))
|
||||
self.assertTrue(any(f['kind'] == 'subscription' and f['id'] == 's-2'
|
||||
for f in summary['failed']))
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestImporterReadGuard(TransactionCase):
|
||||
|
||||
def test_missing_dsn_raises_usererror(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||
with self.assertRaises(UserError):
|
||||
wiz._read_nexacloud_rows()
|
||||
|
||||
def test_test_connection_guards_missing_dsn(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
wiz = self.env['fusion.billing.import.wizard'].sudo().create({'dry_run': True})
|
||||
with self.assertRaises(UserError):
|
||||
wiz.action_test_connection()
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUsageApiSourceId(TransactionCase):
|
||||
"""The /usage API must resolve a subscription by NexaCloud's OWN id, so usage can be
|
||||
pushed against shadow subs the importer created from UUIDs (the flip-day gap)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.env['fusion.billing.import.wizard'].sudo()._import_rows(_fixture())
|
||||
self.service = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')])
|
||||
|
||||
def test_record_usage_resolves_by_nexacloud_subscription_id(self):
|
||||
res = self.service._api_record_usage({'events': [{
|
||||
'subscription_external_id': 's-1', # NexaCloud UUID, not the Odoo id
|
||||
'metric_code': 'cpu_seconds', 'quantity': 3600.0,
|
||||
'period_start': '2026-05-01', 'period_end': '2026-06-01',
|
||||
'idempotency_key': 'nc:s-1:2026-05'}]})
|
||||
self.assertEqual(res['status'], 'ok')
|
||||
self.assertEqual(res['accepted'], 1)
|
||||
sub = self.env['sale.order'].search([('x_fc_nexacloud_subscription_id', '=', 's-1')])
|
||||
usage = self.env['fusion.billing.usage'].search([('subscription_id', '=', sub.id)])
|
||||
self.assertEqual(usage.quantity, 3600.0)
|
||||
263
fusion_centralize_billing/tests/test_invoice_ledger.py
Normal file
263
fusion_centralize_billing/tests/test_invoice_ledger.py
Normal file
@@ -0,0 +1,263 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
def _inv_fixture():
|
||||
return [{
|
||||
'id': 'inv-1', 'stripe_invoice_id': 'in_test1', 'invoice_number': 'NEX-0001',
|
||||
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
||||
'subtotal': 100.0, 'tax': 13.0, 'amount_paid': 0.0, 'paid_at': None,
|
||||
'items': [{'description': 'Odoo ERP Hosting (2026-05-01 to 2026-06-01)',
|
||||
'quantity': 1.0, 'unit_price': 100.0, 'amount': 100.0}],
|
||||
}]
|
||||
|
||||
|
||||
@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)
|
||||
self.assertEqual(self.W._fc_income_account('hosting'), a_host) # idempotent
|
||||
|
||||
|
||||
@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')
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 13.0, places=2)
|
||||
|
||||
def test_tax_for_zero_is_zero_or_empty(self):
|
||||
tax = self.W._fc_tax_for(100.0, 0.0)
|
||||
if tax:
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 0.0, places=2)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerIngest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.Move = self.env['account.move']
|
||||
|
||||
def test_ingest_creates_draft_invoice_with_right_totals(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(len(mv), 1)
|
||||
self.assertEqual(mv.move_type, 'out_invoice')
|
||||
self.assertEqual(mv.state, 'draft')
|
||||
self.assertAlmostEqual(mv.amount_untaxed, 100.0, places=2)
|
||||
self.assertAlmostEqual(mv.amount_tax, 13.0, places=2) # equals source tax
|
||||
self.assertAlmostEqual(mv.amount_total, 113.0, places=2)
|
||||
self.assertEqual(mv.partner_id.email, 'ar@acme.test')
|
||||
self.assertEqual(mv.invoice_line_ids.account_id, self.W._fc_income_account('hosting'))
|
||||
|
||||
def test_ingest_is_idempotent(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.assertEqual(self.Move.search_count(
|
||||
[('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1)
|
||||
|
||||
def test_paid_invoice_is_reconciled_and_shows_paid(self):
|
||||
data = _inv_fixture()
|
||||
data[0].update({'status': 'paid', 'amount_paid': 113.0, 'paid_at': '2026-05-02'})
|
||||
self.W._ingest_invoices(data, post=True)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||
|
||||
def test_post_ingested_posts_drafts(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
n = self.W._post_ingested()
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertGreaterEqual(n, 1)
|
||||
|
||||
def test_read_invoices_guards_missing_dsn(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
with self.assertRaises(UserError):
|
||||
self.W._read_nexacloud_invoices()
|
||||
|
||||
def test_unitemized_subtotal_gets_reconciling_line(self):
|
||||
data = [{
|
||||
'id': 'inv-base', 'stripe_invoice_id': 'in_base', 'invoice_number': 'NEX-BASE',
|
||||
'user_external_id': 'u-2', 'partner_name': 'Globex', 'partner_email': 'ops@globex.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
||||
'subtotal': 200.0, 'tax': 0.0, 'amount_paid': 0.0, 'paid_at': None,
|
||||
'items': [], # base plan billed via Stripe only — no line items
|
||||
}]
|
||||
self.W._ingest_invoices(data, post=False)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-base')])
|
||||
self.assertAlmostEqual(mv.amount_untaxed, 200.0, places=2) # captured via reconciling line
|
||||
self.assertTrue(any('base/unitemized' in (l.name or '') for l in mv.invoice_line_ids))
|
||||
|
||||
def test_zero_amount_invoice_skipped(self):
|
||||
data = [{'id': 'inv-zero', 'stripe_invoice_id': 'in_z', 'invoice_number': 'NEX-ZERO',
|
||||
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'paid',
|
||||
'subtotal': 0.0, 'tax': 0.0, 'amount_paid': 0.0, 'paid_at': None, 'items': []}]
|
||||
summary = self.W._ingest_invoices(data, post=False)
|
||||
self.assertFalse(self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-zero')]))
|
||||
self.assertTrue(any(s.get('reason') == 'zero-amount invoice' for s in summary['skipped']))
|
||||
|
||||
def test_post_and_reconcile_paid_only(self):
|
||||
base = _inv_fixture()[0]
|
||||
paid = dict(base, id='inv-paid', invoice_number='NEX-PAID',
|
||||
status='paid', amount_paid=113.0, paid_at='2026-05-02',
|
||||
invoice_date='2026-05-01')
|
||||
unpaid = dict(base, id='inv-unpaid', invoice_number='NEX-UNPAID',
|
||||
status='open', amount_paid=0.0, invoice_date='2026-04-01')
|
||||
self.W._ingest_invoices([paid, unpaid], post=False)
|
||||
summary = self.W._post_and_reconcile_paid([paid, unpaid])
|
||||
pm = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-paid')])
|
||||
um = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-unpaid')])
|
||||
self.assertEqual(pm.state, 'posted')
|
||||
self.assertIn(pm.payment_state, ('paid', 'in_payment'))
|
||||
self.assertEqual(str(pm.invoice_date), '2026-05-01') # original invoice date kept
|
||||
self.assertEqual(um.state, 'draft') # unpaid stays draft
|
||||
self.assertEqual(summary['posted'], 1)
|
||||
self.assertEqual(summary['skipped_unpaid'], 1)
|
||||
|
||||
def test_partner_named_by_company_not_person(self):
|
||||
data = _inv_fixture()
|
||||
data[0]['partner_company'] = 'Acme Holdings Inc' # full_name is "Acme"; company wins
|
||||
self.W._ingest_invoices(data, post=False)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.partner_id.name, 'Acme Holdings Inc')
|
||||
self.assertTrue(mv.partner_id.is_company)
|
||||
|
||||
def test_prune_shadow_removes_shadow_subs_only(self):
|
||||
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
||||
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
||||
counts = self.W._fc_prune_metered_shadow()
|
||||
self.assertFalse(shadow.exists())
|
||||
self.assertGreaterEqual(counts.get('subscriptions', 0), 1)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerVerifiedSync(TransactionCase):
|
||||
"""The go-forward path: invoice date + paid status come from the SOURCE billing
|
||||
system (Stripe/Lago), never NexaCloud's own fields. HTTP is never hit in tests —
|
||||
routing short-circuits when no API credentials are configured, and the cron is
|
||||
exercised with _read_nexacloud_invoices / _fc_verify patched out."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.Move = self.env['account.move']
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
# ensure no real credentials -> verify helpers short-circuit, never touch network
|
||||
ICP.set_param('fusion_billing.stripe_api_key', '')
|
||||
ICP.set_param('fusion_billing.lago_api_url', '')
|
||||
ICP.set_param('fusion_billing.lago_api_key', '')
|
||||
|
||||
def test_ts_to_date_is_utc_and_none_safe(self):
|
||||
self.assertEqual(self.W._fc_ts_to_date(0), '1970-01-01')
|
||||
self.assertEqual(self.W._fc_ts_to_date(86400), '1970-01-02')
|
||||
self.assertIsNone(self.W._fc_ts_to_date(None))
|
||||
|
||||
def test_verify_routes_and_guards_without_network(self):
|
||||
# Stripe id with no key, Lago id with no config, and an unroutable id all -> None
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'in_abc'}))
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'lago:xyz'}))
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'mystery'}))
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': None}))
|
||||
|
||||
def test_verified_paid_uses_source_date_and_reconciles(self):
|
||||
v = {'inv-1': {'invoice_date': '2026-02-10', 'void': False, 'paid': True,
|
||||
'paid_at': '2026-02-12', 'amount_paid': 113.0}}
|
||||
self.W._ingest_invoices(_inv_fixture(), post=True, verified=v)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertEqual(str(mv.invoice_date), '2026-02-10') # source date, not NexaCloud's
|
||||
self.assertEqual(str(mv.date), str(mv.invoice_date)) # accounting date tracks it
|
||||
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||
|
||||
def test_verified_unpaid_posts_but_is_not_reconciled(self):
|
||||
v = {'inv-1': {'invoice_date': '2026-04-01', 'void': False, 'paid': False,
|
||||
'paid_at': None, 'amount_paid': 0.0}}
|
||||
self.W._ingest_invoices(_inv_fixture(), post=True, verified=v)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertEqual(str(mv.invoice_date), '2026-04-01')
|
||||
self.assertEqual(mv.payment_state, 'not_paid')
|
||||
|
||||
def test_cron_skips_void_draft_unverified_posts_only_finalized(self):
|
||||
base = _inv_fixture()[0]
|
||||
fixtures = [
|
||||
dict(base, id='inv-paid', invoice_number='NEX-P', stripe_invoice_id='in_paid'),
|
||||
dict(base, id='inv-void', invoice_number='NEX-V', stripe_invoice_id='in_void'),
|
||||
dict(base, id='inv-draft', invoice_number=None, stripe_invoice_id='in_draft'),
|
||||
dict(base, id='inv-unver', invoice_number='NEX-U', stripe_invoice_id='weird'),
|
||||
]
|
||||
verdicts = {
|
||||
'inv-paid': {'invoice_date': '2026-03-01', 'void': False, 'draft': False,
|
||||
'paid': True, 'paid_at': '2026-03-02', 'amount_paid': 113.0},
|
||||
'inv-void': {'invoice_date': '2026-03-01', 'void': True, 'draft': False,
|
||||
'paid': False, 'paid_at': None, 'amount_paid': 0.0},
|
||||
'inv-draft': {'invoice_date': '2026-03-01', 'void': False, 'draft': True,
|
||||
'paid': False, 'paid_at': None, 'amount_paid': 0.0},
|
||||
}
|
||||
cls = type(self.W)
|
||||
with patch.object(cls, '_read_nexacloud_invoices', return_value=fixtures), \
|
||||
patch.object(cls, '_fc_verify',
|
||||
side_effect=lambda inv: verdicts.get(str(inv.get('id')))):
|
||||
summary = self.W._cron_sync_verified()
|
||||
self.assertEqual(summary['skipped_void'], 1)
|
||||
self.assertEqual(summary['skipped_draft'], 1)
|
||||
self.assertEqual(summary['unverified'], ['inv-unver'])
|
||||
self.assertEqual(summary['posted'], 1)
|
||||
self.assertEqual(summary['reconciled'], 1)
|
||||
paid = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-paid')])
|
||||
self.assertEqual(paid.state, 'posted')
|
||||
self.assertEqual(str(paid.invoice_date), '2026-03-01')
|
||||
self.assertIn(paid.payment_state, ('paid', 'in_payment'))
|
||||
for skipped in ('inv-void', 'inv-draft', 'inv-unver'):
|
||||
self.assertFalse(self.Move.search([('x_fc_nexacloud_invoice_id', '=', skipped)]))
|
||||
|
||||
def test_cron_leaves_already_posted_untouched(self):
|
||||
# first run posts inv-paid; second run must not re-touch it (idempotent)
|
||||
base = _inv_fixture()[0]
|
||||
fixtures = [dict(base, id='inv-x', invoice_number='NEX-X', stripe_invoice_id='in_x')]
|
||||
verdict = {'invoice_date': '2026-03-01', 'void': False, 'paid': True,
|
||||
'paid_at': '2026-03-02', 'amount_paid': 113.0}
|
||||
cls = type(self.W)
|
||||
with patch.object(cls, '_read_nexacloud_invoices', return_value=fixtures), \
|
||||
patch.object(cls, '_fc_verify', side_effect=lambda inv: verdict):
|
||||
self.W._cron_sync_verified()
|
||||
summary2 = self.W._cron_sync_verified()
|
||||
self.assertEqual(summary2['already_posted'], 1)
|
||||
self.assertEqual(summary2['posted'], 0)
|
||||
self.assertEqual(self.Move.search_count(
|
||||
[('x_fc_nexacloud_invoice_id', '=', 'inv-x')]), 1)
|
||||
111
fusion_centralize_billing/tests/test_reconciliation.py
Normal file
111
fusion_centralize_billing/tests/test_reconciliation.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
from .test_importer import _fixture
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconciliationMath(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'CPU', 'plan_code': 'p-1', 'metric_id': self.metric.id,
|
||||
'included_quota': 18000.0, 'price_per_unit': 0.0075,
|
||||
'unit_batch': 3600.0, 'charge_model': 'standard'})
|
||||
|
||||
def test_match_within_tolerance(self):
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 10000.0, 20.0, 0.01) # under quota, no overage
|
||||
self.assertAlmostEqual(odoo_amt, 20.0)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
def test_overage_match(self):
|
||||
# flat 20 + 2 core-hours overage (7200s -> $0.015) = 20.015; external 20.02 (cent)
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 18000.0 + 7200.0, 20.02, 0.01)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
def test_delta_flags_mismatch(self):
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.charge, 18000.0, 25.0, 0.01) # external 25 vs odoo 20
|
||||
self.assertAlmostEqual(delta, -5.0, places=2)
|
||||
self.assertEqual(status, 'delta')
|
||||
|
||||
def test_no_charge_is_flat_only(self):
|
||||
odoo_amt, delta, status = self.Recon._compute_reconciliation(
|
||||
20.0, self.env['fusion.billing.charge'], 999999.0, 20.0, 0.01)
|
||||
self.assertAlmostEqual(odoo_amt, 20.0)
|
||||
self.assertEqual(status, 'match')
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestReconcileRows(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.Wizard = self.env['fusion.billing.import.wizard'].sudo()
|
||||
self.Wizard._import_rows(_fixture()) # shadow subs s-1/s-2 + p-1 charge
|
||||
self.Recon = self.env['fusion.billing.reconciliation'].sudo()
|
||||
self.SaleOrder = self.env['sale.order']
|
||||
|
||||
def _partner_of(self, sub_ext):
|
||||
return self.SaleOrder.search(
|
||||
[('x_fc_nexacloud_subscription_id', '=', sub_ext)]).partner_id
|
||||
|
||||
def test_creates_one_row_per_subscription_with_status(self):
|
||||
summary = self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0}, # flat 20 == 20 -> match
|
||||
{'subscription_external_id': 's-2', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 250.0}, # flat 200 vs 250 -> delta
|
||||
])
|
||||
rows = self.Recon.search([('period', '=', '2026-05')])
|
||||
self.assertEqual(len(rows), 2)
|
||||
s1 = rows.filtered(lambda r: r.odoo_amount == 20.0)
|
||||
self.assertEqual(s1.status, 'match')
|
||||
s2 = rows.filtered(lambda r: r.odoo_amount == 200.0)
|
||||
self.assertEqual(s2.status, 'delta')
|
||||
self.assertAlmostEqual(s2.delta, -50.0, places=2)
|
||||
self.assertEqual(summary['match'], 1)
|
||||
self.assertEqual(summary['delta'], 1)
|
||||
|
||||
def test_rerun_upserts(self):
|
||||
row = [{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0}]
|
||||
self.Recon._reconcile_rows(row)
|
||||
self.Recon._reconcile_rows(row)
|
||||
self.assertEqual(self.Recon.search_count([
|
||||
('period', '=', '2026-05'),
|
||||
('partner_id', '=', self._partner_of('s-1').id)]), 1)
|
||||
|
||||
def test_unknown_subscription_is_skipped(self):
|
||||
summary = self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 'nope', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 1.0}])
|
||||
self.assertTrue(any(s['id'] == 'nope' for s in summary['skipped']))
|
||||
|
||||
def test_two_subscriptions_same_partner_period_do_not_collide(self):
|
||||
# A customer with two deployments -> two subscriptions in the same period.
|
||||
data = _fixture()
|
||||
data['subscriptions'].append(
|
||||
{"id": "s-1b", "user_id": "u-1", "deployment_id": "d-1b", "plan_id": "p-1",
|
||||
"status": "active", "billing_cycle": "monthly",
|
||||
"current_period_start": "2026-05-01", "current_period_end": "2026-06-01"})
|
||||
self.env['fusion.billing.import.wizard'].sudo()._import_rows(data)
|
||||
self.Recon._reconcile_rows([
|
||||
{'subscription_external_id': 's-1', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 20.0},
|
||||
{'subscription_external_id': 's-1b', 'period': '2026-05',
|
||||
'cpu_seconds': 0.0, 'external_amount': 99.0},
|
||||
])
|
||||
partner = self._partner_of('s-1')
|
||||
rows = self.Recon.search(
|
||||
[('partner_id', '=', partner.id), ('period', '=', '2026-05')])
|
||||
self.assertEqual(len(rows), 2, "two subs for one partner must keep two rows")
|
||||
self.assertEqual(set(rows.mapped('external_subscription_id')), {'s-1', 's-1b'})
|
||||
171
fusion_centralize_billing/tests/test_usage.py
Normal file
171
fusion_centralize_billing/tests/test_usage.py
Normal file
@@ -0,0 +1,171 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestRatingCron(TransactionCase):
|
||||
"""The rating cron must only rate a subscription against charges on its OWN plan
|
||||
(items 1/C1/H4) and over the subscription's real open billing period (item 5/H1)."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.plan_b = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Plan B', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
|
||||
self.recurring_product = self.env['product.product'].sudo().create(
|
||||
{'name': 'Plan seat', 'type': 'service', 'recurring_invoice': True,
|
||||
'list_price': 10.0})
|
||||
self.overage_product = self.env['product.product'].sudo().create(
|
||||
{'name': 'CPU overage', 'type': 'service', 'list_price': 0.0})
|
||||
self.Usage = self.env['fusion.billing.usage'].sudo()
|
||||
|
||||
def _confirmed_sub(self, plan):
|
||||
sub = self.env['sale.order'].sudo().create({
|
||||
'partner_id': self.partner.id, 'plan_id': plan.id,
|
||||
'order_line': [(0, 0, {'product_id': self.recurring_product.id,
|
||||
'product_uom_qty': 1})],
|
||||
})
|
||||
sub.action_confirm()
|
||||
# widen the computed billing window so usage in May is in-period
|
||||
sub.write({'start_date': '2026-05-01', 'next_invoice_date': '2026-06-01'})
|
||||
return sub
|
||||
|
||||
def test_cron_rates_only_matching_plan(self):
|
||||
sub_a = self._confirmed_sub(self.plan_a)
|
||||
sub_b = self._confirmed_sub(self.plan_b)
|
||||
self.assertEqual(sub_a.subscription_state, '3_progress')
|
||||
self.assertEqual(sub_b.subscription_state, '3_progress')
|
||||
# one charge, linked to Plan A only
|
||||
charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'CPU overage', 'plan_code': 'plan-a', 'plan_id': self.plan_a.id,
|
||||
'metric_id': self.metric.id, 'product_id': self.overage_product.id,
|
||||
'included_quota': 100.0, 'price_per_unit': 0.10, 'unit_batch': 1000.0,
|
||||
'charge_model': 'standard'})
|
||||
# usage recorded on BOTH subs, in the open period
|
||||
self.Usage._record_usage(sub_a, 'cpu_seconds', 1100.0,
|
||||
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='a1')
|
||||
self.Usage._record_usage(sub_b, 'cpu_seconds', 1100.0,
|
||||
'2026-05-10 00:00:00', '2026-05-11 00:00:00', idem='b1')
|
||||
|
||||
self.Usage._cron_rate_open_periods()
|
||||
|
||||
# Plan A sub IS rated (window captured the usage → overage line present)
|
||||
line_a = sub_a.order_line.filtered(lambda l: l.product_id == self.overage_product)
|
||||
self.assertTrue(line_a, "Plan A subscription should be rated by the Plan A charge")
|
||||
self.assertAlmostEqual(line_a.price_unit, 0.10, places=2)
|
||||
# Plan B sub is NOT rated by the Plan A charge
|
||||
line_b = sub_b.order_line.filtered(lambda l: l.product_id == self.overage_product)
|
||||
self.assertFalse(line_b, "Plan B subscription must NOT be rated by the Plan A charge")
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUsageIngestion(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.metric = self.env['fusion.billing.metric'].sudo().create(
|
||||
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||
self.partner = self.env['res.partner'].sudo().create({'name': 'Acme'})
|
||||
self.sub = self.env['sale.order'].sudo().create({
|
||||
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
|
||||
})
|
||||
self.Usage = self.env['fusion.billing.usage'].sudo()
|
||||
|
||||
def test_record_usage_creates_row(self):
|
||||
u = self.Usage._record_usage(
|
||||
self.sub, 'cpu_seconds', 120.0,
|
||||
'2026-05-01 00:00:00', '2026-06-01 00:00:00', idem='nexacloud:cpu:sub1:2026-05-01')
|
||||
self.assertEqual(u.quantity, 120.0)
|
||||
self.assertEqual(u.metric_id, self.metric)
|
||||
|
||||
def test_idempotent_key_updates_not_duplicates(self):
|
||||
k = 'nexacloud:cpu:sub1:2026-05-01'
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 100.0, '2026-05-01', '2026-06-01', idem=k)
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 175.0, '2026-05-01', '2026-06-01', idem=k)
|
||||
rows = self.Usage.search([('idempotency_key', '=', k)])
|
||||
self.assertEqual(len(rows), 1) # no duplicate
|
||||
self.assertEqual(rows.quantity, 175.0) # last value wins for the same key
|
||||
|
||||
def test_aggregate_sum(self):
|
||||
for i, q in enumerate([10.0, 20.0, 30.0]):
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', q,
|
||||
'2026-05-01', '2026-06-01', idem='cpu-%d' % i)
|
||||
total = self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01')
|
||||
self.assertEqual(total, 60.0)
|
||||
|
||||
def test_aggregate_max(self):
|
||||
self.metric.aggregation = 'max'
|
||||
for i, q in enumerate([10.0, 55.0, 30.0]):
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', q,
|
||||
'2026-05-01', '2026-06-01', idem='m-%d' % i)
|
||||
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 55.0)
|
||||
|
||||
def test_aggregate_excludes_other_periods(self):
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 99.0, '2026-04-01', '2026-05-01', idem='apr')
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 5.0, '2026-05-01', '2026-06-01', idem='may')
|
||||
self.assertEqual(self.Usage._aggregate(self.sub, self.metric, '2026-05-01', '2026-06-01'), 5.0)
|
||||
|
||||
def test_rate_open_period_creates_overage_line(self):
|
||||
product = self.env['product.product'].sudo().create(
|
||||
{'name': 'API overage', 'type': 'service', 'list_price': 0.0})
|
||||
charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id,
|
||||
'product_id': product.id, 'included_quota': 100.0,
|
||||
'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'})
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', 1100.0,
|
||||
'2026-05-01', '2026-06-01', idem='r1')
|
||||
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
|
||||
# 1100 - 100 = 1000 overage = 1 batch * $0.10 = $0.10
|
||||
self.assertAlmostEqual(amount, 0.10, places=2)
|
||||
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
|
||||
self.assertTrue(line)
|
||||
|
||||
# ── item 6 (H2): half-open aggregation window anchored on period_start ──
|
||||
def test_aggregate_daily_rollups_in_window(self):
|
||||
"""Three DAILY rollups (period_start 05-01/-08/-15, each period_end +1 day)
|
||||
sum correctly for the half-open window ['2026-05-01', '2026-06-01')."""
|
||||
rollups = [
|
||||
('2026-05-01 00:00:00', '2026-05-02 00:00:00', 3.0),
|
||||
('2026-05-08 00:00:00', '2026-05-09 00:00:00', 5.0),
|
||||
('2026-05-15 00:00:00', '2026-05-16 00:00:00', 7.0),
|
||||
]
|
||||
for i, (ps, pe, q) in enumerate(rollups):
|
||||
self.Usage._record_usage(self.sub, 'cpu_seconds', q, ps, pe, idem='daily-%d' % i)
|
||||
total = self.Usage._aggregate(
|
||||
self.sub, self.metric, '2026-05-01 00:00:00', '2026-06-01 00:00:00')
|
||||
self.assertEqual(total, 15.0) # 3 + 5 + 7
|
||||
|
||||
# ── item 7 (H3): idempotency key is scoped per (subscription, metric) ──
|
||||
def test_same_idempotency_key_distinct_subscriptions(self):
|
||||
"""The SAME idempotency key on two DIFFERENT subscriptions creates TWO rows."""
|
||||
sub2 = self.env['sale.order'].sudo().create({
|
||||
'partner_id': self.partner.id, 'is_subscription': True, 'plan_id': self.plan.id,
|
||||
})
|
||||
key = 'shared-idem-key'
|
||||
a = self.Usage._record_usage(self.sub, 'cpu_seconds', 10.0, '2026-05-01', '2026-06-01', idem=key)
|
||||
b = self.Usage._record_usage(sub2, 'cpu_seconds', 20.0, '2026-05-01', '2026-06-01', idem=key)
|
||||
self.assertNotEqual(a, b) # distinct rows, no collision
|
||||
rows = self.Usage.search([('idempotency_key', '=', key)])
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(a.quantity, 10.0)
|
||||
self.assertEqual(b.quantity, 20.0)
|
||||
|
||||
# ── item 2 (C1): zero aggregated usage creates no overage line ──
|
||||
def test_zero_usage_creates_no_line(self):
|
||||
product = self.env['product.product'].sudo().create(
|
||||
{'name': 'API overage', 'type': 'service', 'list_price': 0.0})
|
||||
charge = self.env['fusion.billing.charge'].sudo().create({
|
||||
'name': 'overage', 'plan_code': 'p', 'metric_id': self.metric.id,
|
||||
'product_id': product.id, 'included_quota': 100.0,
|
||||
'price_per_unit': 0.10, 'unit_batch': 1000.0, 'charge_model': 'standard'})
|
||||
# no usage recorded → aggregate is 0 → amount 0 → no line created
|
||||
amount = self.sub._fc_rate_usage(charge, '2026-05-01', '2026-06-01')
|
||||
self.assertEqual(amount, 0.0)
|
||||
line = self.sub.order_line.filtered(lambda l: l.product_id == product)
|
||||
self.assertFalse(line)
|
||||
99
fusion_centralize_billing/tests/test_webhook.py
Normal file
99
fusion_centralize_billing/tests/test_webhook.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestWebhookEngine(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.service = self.env['fusion.billing.service'].sudo().create({
|
||||
'name': 'NexaCloud', 'code': 'nexacloud',
|
||||
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook',
|
||||
'webhook_secret': 'whsec_test',
|
||||
})
|
||||
self.Webhook = self.env['fusion.billing.webhook'].sudo()
|
||||
|
||||
def test_enqueue_signs_payload(self):
|
||||
wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-1'})
|
||||
self.assertEqual(wh.state, 'pending')
|
||||
body = json.dumps({'invoice': 'INV-1'}, sort_keys=True, separators=(',', ':'))
|
||||
expected = hmac.new(b'whsec_test', body.encode(), hashlib.sha256).hexdigest()
|
||||
self.assertEqual(wh.signature, expected)
|
||||
|
||||
def test_dispatch_marks_sent_on_2xx(self):
|
||||
wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-2'})
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = 'ok'
|
||||
|
||||
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
|
||||
return_value=_Resp()) as mock_post:
|
||||
self.Webhook._cron_dispatch()
|
||||
self.assertTrue(mock_post.called)
|
||||
self.assertEqual(wh.state, 'sent')
|
||||
|
||||
def test_dispatch_retries_then_deadletters(self):
|
||||
wh = self.Webhook._enqueue(self.service, 'invoice.paid', {'invoice': 'INV-3'})
|
||||
wh.write({'attempts': 7}) # already past max
|
||||
|
||||
class _Resp:
|
||||
status_code = 500
|
||||
text = 'err'
|
||||
|
||||
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
|
||||
return_value=_Resp()):
|
||||
self.Webhook._cron_dispatch()
|
||||
self.assertEqual(wh.state, 'dead')
|
||||
|
||||
# ── item 8 (H5): dispatch POSTs the stored body verbatim + event-id header ──
|
||||
def test_dispatch_posts_stored_body_and_event_id(self):
|
||||
wh = self.Webhook._enqueue(self.service, 'invoice.payment_failed', {'invoice': 'INV-9'})
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = 'ok'
|
||||
|
||||
with patch('odoo.addons.fusion_centralize_billing.models.webhook.requests.post',
|
||||
return_value=_Resp()) as mock_post:
|
||||
self.Webhook._cron_dispatch()
|
||||
self.assertTrue(mock_post.called)
|
||||
_args, kwargs = mock_post.call_args
|
||||
# the exact stored body is POSTed (not a re-serialized payload)
|
||||
self.assertEqual(kwargs['data'], wh.body)
|
||||
self.assertEqual(wh.body, json.dumps(
|
||||
{'invoice': 'INV-9'}, sort_keys=True, separators=(',', ':')))
|
||||
# signature matches the bytes on the wire
|
||||
expected = hmac.new(b'whsec_test', wh.body.encode(), hashlib.sha256).hexdigest()
|
||||
self.assertEqual(kwargs['headers']['X-Fusion-Signature'], expected)
|
||||
# event id header present and correct
|
||||
self.assertEqual(kwargs['headers']['X-Fusion-Event-Id'], str(wh.id))
|
||||
|
||||
# ── item 9 (H6): SSRF guard on webhook_url ──
|
||||
def test_webhook_url_rejects_loopback(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['fusion.billing.service'].sudo().create({
|
||||
'name': 'Evil', 'code': 'evil', 'webhook_url': 'http://127.0.0.1/x'})
|
||||
|
||||
def test_webhook_url_rejects_private_and_http(self):
|
||||
for bad in ('http://10.0.0.5/hook', # private + non-https
|
||||
'https://192.168.1.10/hook', # private
|
||||
'https://localhost/hook', # localhost host
|
||||
'https://169.254.169.254/latest', # link-local metadata
|
||||
'http://api.example.com/hook'): # non-https
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['fusion.billing.service'].sudo().create({
|
||||
'name': 'Bad', 'code': 'bad-%s' % bad, 'webhook_url': bad})
|
||||
|
||||
def test_webhook_url_allows_public_https(self):
|
||||
svc = self.env['fusion.billing.service'].sudo().create({
|
||||
'name': 'Good', 'code': 'good',
|
||||
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook'})
|
||||
self.assertTrue(svc.id)
|
||||
47
fusion_centralize_billing/views/import_wizard_views.xml
Normal file
47
fusion_centralize_billing/views/import_wizard_views.xml
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_billing_import_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.import.wizard.form</field>
|
||||
<field name="model">fusion.billing.import.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Import from NexaCloud">
|
||||
<div class="alert alert-danger" role="alert" invisible="failed_count == 0">
|
||||
<strong>Import completed with errors: </strong>
|
||||
<field name="failed_count" class="oe_inline" readonly="1"/> row(s) failed — see Result below.
|
||||
</div>
|
||||
<div class="alert alert-warning" role="alert" invisible="skipped_count == 0">
|
||||
<field name="skipped_count" class="oe_inline" readonly="1"/> row(s) skipped (unresolved customer/plan) — see Result below.
|
||||
</div>
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_test_connection" type="object"
|
||||
string="Test Connection" class="btn-secondary"/>
|
||||
<button name="action_run_import" type="object" string="Run Import"
|
||||
class="btn-primary"/>
|
||||
<button name="action_run_reconciliation" type="object"
|
||||
string="Run Reconciliation" class="btn-secondary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_billing_import_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Import from NexaCloud</field>
|
||||
<field name="res_model">fusion.billing.import.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_billing_root" name="Fusion Billing"
|
||||
parent="account.menu_finance" sequence="90"/>
|
||||
<menuitem id="menu_fusion_billing_import" name="Import from NexaCloud"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fusion_billing_import_wizard" sequence="10"
|
||||
groups="base.group_system"/>
|
||||
</odoo>
|
||||
44
fusion_centralize_billing/views/invoice_ledger_views.xml
Normal file
44
fusion_centralize_billing/views/invoice_ledger_views.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fc_invoice_ledger_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.invoice.ledger.wizard.form</field>
|
||||
<field name="model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Ingest NexaCloud Invoices">
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
<field name="auto_post"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_run" type="object" string="Run" class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fc_invoice_ledger_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Ingest NexaCloud Invoices</field>
|
||||
<field name="res_model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fc_invoice_ledger" name="Ingest NexaCloud Invoices"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fc_invoice_ledger_wizard" sequence="20"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Ingest NexaCloud invoices (daily)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
fusion_centralize_billing/wizards/__init__.py
Normal file
2
fusion_centralize_billing/wizards/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import import_wizard
|
||||
from . import invoice_ledger
|
||||
449
fusion_centralize_billing/wizards/import_wizard.py
Normal file
449
fusion_centralize_billing/wizards/import_wizard.py
Normal file
@@ -0,0 +1,449 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""NexaCloud → Odoo billing importer (sub-project #2a).
|
||||
|
||||
One-time, re-runnable, read-only backfill: read the NexaCloud Postgres and create the
|
||||
equivalent Odoo records (partners + links, a cpu_seconds charge catalog, one DRAFT
|
||||
shadow ``sale.order`` per deployment). Shadow-safe by construction — see the design spec
|
||||
``docs/superpowers/specs/2026-05-27-nexacloud-billing-importer-design.md``.
|
||||
|
||||
Logic lives in model methods so it is unit-testable headless; the wizard button only
|
||||
calls ``_read_nexacloud_rows()`` → ``_import_rows()``.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
NEXACLOUD_CODE = "nexacloud"
|
||||
CPU_METRIC_CODE = "cpu_seconds"
|
||||
CPU_RATE_PER_CORE_HOUR = 0.0075 # NexaCloud CPU rate, CAD per core-hour
|
||||
CPU_SECONDS_PER_CORE_HOUR = 3600.0 # one core-hour = 3600 cpu-seconds
|
||||
|
||||
|
||||
class FusionBillingImportWizard(models.TransientModel):
|
||||
_name = "fusion.billing.import.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Importer"
|
||||
|
||||
dry_run = fields.Boolean(
|
||||
default=True,
|
||||
help="Read and report what would be imported, without writing anything.")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
failed_count = fields.Integer(readonly=True)
|
||||
skipped_count = fields.Integer(readonly=True)
|
||||
|
||||
def action_run_import(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_rows()
|
||||
summary = self._import_rows(data, dry_run=self.dry_run)
|
||||
failed = summary.get("failed") or []
|
||||
skipped = summary.get("skipped") or []
|
||||
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||
self.failed_count = len(failed)
|
||||
self.skipped_count = len(skipped)
|
||||
# A partial billing import must be loud, not buried in the JSON. Log at ERROR
|
||||
# so it survives nexa's log_level=warn (INFO is suppressed there).
|
||||
if failed:
|
||||
_logger.error("NexaCloud import: %s row(s) FAILED%s: %s",
|
||||
len(failed), " (dry-run)" if self.dry_run else "", failed)
|
||||
if skipped:
|
||||
_logger.warning("NexaCloud import: %s row(s) skipped: %s", len(skipped), skipped)
|
||||
return {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": self._name,
|
||||
"res_id": self.id,
|
||||
"view_mode": "form",
|
||||
"target": "new",
|
||||
}
|
||||
|
||||
def action_test_connection(self):
|
||||
"""Read-only connectivity + schema check: connect, read the source tables, and
|
||||
report row counts WITHOUT importing anything. The safe first step before a
|
||||
dry-run — surfaces a bad DSN, no network route, or a schema drift up front."""
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_rows()
|
||||
msg = "Connected. Read %s user(s), %s plan(s), %s subscription(s)." % (
|
||||
len(data.get("users", [])), len(data.get("plans", [])),
|
||||
len(data.get("subscriptions", [])))
|
||||
return {
|
||||
"type": "ir.actions.client",
|
||||
"tag": "display_notification",
|
||||
"params": {"title": "NexaCloud connection OK", "message": msg,
|
||||
"type": "success", "sticky": False},
|
||||
}
|
||||
|
||||
def action_run_reconciliation(self):
|
||||
"""Read NexaCloud usage + invoice actuals and record per-subscription/period
|
||||
Odoo-vs-NexaCloud deltas in fusion.billing.reconciliation. Read-only on
|
||||
NexaCloud; writes only reconciliation rows (shadow-safe)."""
|
||||
self.ensure_one()
|
||||
rows = self._read_reconciliation_rows()
|
||||
summary = self.env["fusion.billing.reconciliation"]._reconcile_rows(rows)
|
||||
self.result_summary = json.dumps(summary, indent=2, default=str)
|
||||
self.failed_count = len(summary.get("failed") or [])
|
||||
self.skipped_count = len(summary.get("skipped") or [])
|
||||
if summary.get("delta") or summary.get("failed"):
|
||||
_logger.error(
|
||||
"NexaCloud reconciliation: %s delta, %s failed, %s skipped row(s): %s",
|
||||
summary.get("delta"), len(summary.get("failed") or []),
|
||||
len(summary.get("skipped") or []), summary)
|
||||
return {
|
||||
"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new",
|
||||
}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_rows(self):
|
||||
"""Open a READ-ONLY psycopg2 connection to the nexacloud Postgres (DSN in
|
||||
ir.config_parameter 'fusion_billing.nexacloud_dsn') and return rows as dicts.
|
||||
Raises UserError on a missing DSN or a failed connection."""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param(
|
||||
"fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError(
|
||||
"NexaCloud DSN not configured. Set the 'fusion_billing.nexacloud_dsn' "
|
||||
"system parameter to a read-only Postgres connection string.")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001 - surface as a user error
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
conn.set_client_encoding('UTF8')
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
data = {}
|
||||
cur.execute(
|
||||
"SELECT id, email, full_name, company, billing_email, billing_address, "
|
||||
"billing_city, billing_state, billing_postal_code, billing_country, "
|
||||
"tax_id, stripe_customer_id FROM users")
|
||||
data["users"] = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT id, name, price_monthly, price_yearly, cpu_seconds_quota, "
|
||||
"is_active FROM plans")
|
||||
data["plans"] = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute(
|
||||
"SELECT id, user_id, deployment_id, plan_id, status, billing_cycle, "
|
||||
"current_period_start, current_period_end FROM subscriptions")
|
||||
data["subscriptions"] = [dict(r) for r in cur.fetchall()]
|
||||
return data
|
||||
except psycopg2.Error as e:
|
||||
# A query/schema error (e.g. a renamed/missing column) gets the same clean
|
||||
# operator message as a connection failure — not a raw SQL traceback. We
|
||||
# never return a partial `data` (the return is the last statement in `try`).
|
||||
raise UserError(
|
||||
"Failed reading from the NexaCloud database — the source schema may "
|
||||
"have changed. Underlying error:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _read_reconciliation_rows(self):
|
||||
"""Read-only: per (subscription, YYYY-MM period), NexaCloud's CPU usage
|
||||
(cpu_hours*3600 = cpu_seconds) and its actual pre-tax invoice amount. Shaped for
|
||||
fusion.billing.reconciliation._reconcile_rows. Reuses the 2a DSN + guards.
|
||||
(Integration glue — validate the SQL against the live schema, like the importer
|
||||
reader; the reconciliation math itself is unit-tested.)"""
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param(
|
||||
"fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
conn.set_client_encoding('UTF8')
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"SELECT subscription_id::text AS sub, "
|
||||
"to_char(period_start, 'YYYY-MM') AS period, "
|
||||
"COALESCE(SUM(cpu_hours), 0) * 3600.0 AS cpu_seconds "
|
||||
"FROM usage_records "
|
||||
"GROUP BY subscription_id, to_char(period_start, 'YYYY-MM')")
|
||||
usage = {(r["sub"], r["period"]): float(r["cpu_seconds"] or 0.0)
|
||||
for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"SELECT i.subscription_id::text AS sub, "
|
||||
"to_char(ii.period_start, 'YYYY-MM') AS period, "
|
||||
"COALESCE(SUM(ii.amount), 0) AS external_amount "
|
||||
"FROM invoices i JOIN invoice_items ii ON ii.invoice_id = i.id "
|
||||
"GROUP BY i.subscription_id, to_char(ii.period_start, 'YYYY-MM')")
|
||||
rows = []
|
||||
for r in cur.fetchall():
|
||||
key = (r["sub"], r["period"])
|
||||
rows.append({
|
||||
"subscription_external_id": r["sub"], "period": r["period"],
|
||||
"cpu_seconds": usage.get(key, 0.0),
|
||||
"external_amount": float(r["external_amount"] or 0.0)})
|
||||
return rows
|
||||
except psycopg2.Error as e:
|
||||
raise UserError(
|
||||
"Failed reading NexaCloud actuals — the source schema may have changed. "
|
||||
"Underlying error:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ----- import side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _import_rows(self, data, dry_run=False):
|
||||
"""Upsert NexaCloud rows into Odoo. Idempotent. With dry_run=True the writes
|
||||
happen inside a savepoint that is rolled back, so nothing persists (the summary
|
||||
is still returned)."""
|
||||
if not dry_run:
|
||||
return self._do_import(data)
|
||||
result = {}
|
||||
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
result.update(self._do_import(data))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
result["dry_run"] = True
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _do_import(self, data):
|
||||
service = self._fc_service()
|
||||
metric = self._fc_cpu_metric()
|
||||
recurrence_plans = {
|
||||
"monthly": self._fc_recurrence_plan("month"),
|
||||
"yearly": self._fc_recurrence_plan("year"),
|
||||
}
|
||||
summary = {"created": {}, "updated": {}, "skipped": [], "failed": []}
|
||||
partner_by_user = {}
|
||||
plan_ctx_by_id = {}
|
||||
|
||||
for u in data.get("users", []):
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
link, created = self._import_user(service, u)
|
||||
partner_by_user[str(u["id"])] = link.partner_id
|
||||
self._bump(summary, created, "partners")
|
||||
except Exception as e: # noqa: BLE001 - per-row isolation
|
||||
_logger.exception("NexaCloud import: user row %s failed", u.get("id"))
|
||||
summary["failed"].append(
|
||||
{"kind": "user", "id": str(u.get("id")),
|
||||
"error": "%s: %s" % (type(e).__name__, e)})
|
||||
|
||||
for p in data.get("plans", []):
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
ctx, created = self._import_plan(metric, p)
|
||||
plan_ctx_by_id[str(p["id"])] = ctx
|
||||
self._bump(summary, created, "plans")
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.exception("NexaCloud import: plan row %s failed", p.get("id"))
|
||||
summary["failed"].append(
|
||||
{"kind": "plan", "id": str(p.get("id")),
|
||||
"error": "%s: %s" % (type(e).__name__, e)})
|
||||
|
||||
for s in data.get("subscriptions", []):
|
||||
partner = partner_by_user.get(str(s.get("user_id") or ""))
|
||||
ctx = plan_ctx_by_id.get(str(s.get("plan_id") or ""))
|
||||
if not partner or not ctx:
|
||||
summary["skipped"].append({
|
||||
"kind": "subscription", "id": str(s.get("id")),
|
||||
"reason": "unresolved %s" % ("user" if not partner else "plan")})
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
_order, created = self._import_subscription(
|
||||
service, partner, ctx, recurrence_plans, s)
|
||||
self._bump(summary, created, "subscriptions")
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.exception("NexaCloud import: subscription row %s failed", s.get("id"))
|
||||
summary["failed"].append(
|
||||
{"kind": "subscription", "id": str(s.get("id")),
|
||||
"error": "%s: %s" % (type(e).__name__, e)})
|
||||
|
||||
_logger.info("NexaCloud import summary: %s", summary)
|
||||
return summary
|
||||
|
||||
# ----- find-or-create helpers --------------------------------------------
|
||||
@api.model
|
||||
def _fc_service(self):
|
||||
Service = self.env["fusion.billing.service"]
|
||||
svc = Service.search([("code", "=", NEXACLOUD_CODE)], limit=1)
|
||||
return svc or Service.create({"name": "NexaCloud", "code": NEXACLOUD_CODE})
|
||||
|
||||
@api.model
|
||||
def _fc_cpu_metric(self):
|
||||
Metric = self.env["fusion.billing.metric"]
|
||||
m = Metric.search([("code", "=", CPU_METRIC_CODE)], limit=1)
|
||||
return m or Metric.create({
|
||||
"name": "CPU seconds", "code": CPU_METRIC_CODE,
|
||||
"aggregation": "sum", "unit_label": "CPU-seconds"})
|
||||
|
||||
@api.model
|
||||
def _fc_recurrence_plan(self, unit):
|
||||
Plan = self.env["sale.subscription.plan"]
|
||||
plan = Plan.search(
|
||||
[("billing_period_value", "=", 1), ("billing_period_unit", "=", unit)], limit=1)
|
||||
if plan:
|
||||
return plan
|
||||
label = "Monthly" if unit == "month" else "Yearly"
|
||||
return Plan.create(
|
||||
{"name": label, "billing_period_value": 1, "billing_period_unit": unit})
|
||||
|
||||
@api.model
|
||||
def _fc_resolve_country(self, value):
|
||||
Country = self.env["res.country"]
|
||||
if not value:
|
||||
return Country.browse()
|
||||
v = value.strip()
|
||||
return Country.search(
|
||||
["|", ("code", "=ilike", v), ("name", "=ilike", v)], limit=1)
|
||||
|
||||
@staticmethod
|
||||
def _bump(summary, created, key):
|
||||
bucket = "created" if created else "updated"
|
||||
summary[bucket][key] = summary[bucket].get(key, 0) + 1
|
||||
|
||||
# ----- per-entity import --------------------------------------------------
|
||||
@api.model
|
||||
def _import_user(self, service, urow):
|
||||
Link = self.env["fusion.billing.account.link"]
|
||||
ext = str(urow["id"])
|
||||
email = (urow.get("billing_email") or urow.get("email") or "").strip().lower() or None
|
||||
name = urow.get("full_name") or urow.get("company") or email or ext
|
||||
existed = bool(Link.search(
|
||||
[("service_id", "=", service.id), ("external_id", "=", ext)], limit=1))
|
||||
link = Link._resolve_or_create_partner(service, ext, name=name, email=email)
|
||||
vals = {}
|
||||
if urow.get("billing_address"):
|
||||
vals["street"] = urow["billing_address"]
|
||||
if urow.get("billing_city"):
|
||||
vals["city"] = urow["billing_city"]
|
||||
if urow.get("billing_postal_code"):
|
||||
vals["zip"] = urow["billing_postal_code"]
|
||||
if urow.get("tax_id"):
|
||||
vals["vat"] = urow["tax_id"]
|
||||
if urow.get("stripe_customer_id"):
|
||||
vals["x_fc_stripe_customer_id"] = urow["stripe_customer_id"]
|
||||
country = self._fc_resolve_country(urow.get("billing_country"))
|
||||
if country:
|
||||
vals["country_id"] = country.id
|
||||
if vals:
|
||||
link.partner_id.write(vals)
|
||||
return link, not existed
|
||||
|
||||
@api.model
|
||||
def _import_plan(self, metric, prow):
|
||||
Product = self.env["product.product"]
|
||||
Charge = self.env["fusion.billing.charge"]
|
||||
plan_code = str(prow["id"])
|
||||
name = prow.get("name") or plan_code
|
||||
# Preserve NULL vs 0.0: a missing price must NOT silently become a $0 line.
|
||||
# The subscription import raises on a missing price for its cycle (-> failed[]).
|
||||
raw_monthly = prow.get("price_monthly")
|
||||
raw_yearly = prow.get("price_yearly")
|
||||
price_monthly = float(raw_monthly) if raw_monthly is not None else None
|
||||
price_yearly = float(raw_yearly) if raw_yearly is not None else None
|
||||
created = False
|
||||
|
||||
sub_code = "NC-PLAN-%s" % plan_code
|
||||
sub_product = Product.search([("default_code", "=", sub_code)], limit=1)
|
||||
if not sub_product:
|
||||
sub_product = Product.create({
|
||||
"name": "NexaCloud %s" % name, "default_code": sub_code,
|
||||
"type": "service", "recurring_invoice": True,
|
||||
"list_price": price_monthly or 0.0})
|
||||
created = True
|
||||
|
||||
ov_code = "NC-CPU-OVG-%s" % plan_code
|
||||
ov_product = Product.search([("default_code", "=", ov_code)], limit=1)
|
||||
if not ov_product:
|
||||
ov_product = Product.create({
|
||||
"name": "NexaCloud CPU overage (%s)" % name, "default_code": ov_code,
|
||||
"type": "service", "list_price": 0.0})
|
||||
|
||||
charge_vals = {
|
||||
"name": "NexaCloud CPU overage — %s" % name,
|
||||
"plan_code": plan_code, "metric_id": metric.id, "product_id": ov_product.id,
|
||||
"included_quota": float(prow.get("cpu_seconds_quota") or 0.0),
|
||||
"price_per_unit": CPU_RATE_PER_CORE_HOUR,
|
||||
"unit_batch": CPU_SECONDS_PER_CORE_HOUR,
|
||||
"charge_model": "standard",
|
||||
# Shadow safety guarantee #3: plan_id MUST stay NULL so the rating cron
|
||||
# never auto-mutates order lines. Set it explicitly (not just omitted) so a
|
||||
# re-run re-asserts NULL even if someone set it on the charge between runs.
|
||||
"plan_id": False,
|
||||
}
|
||||
charge = Charge.search(
|
||||
[("plan_code", "=", plan_code), ("metric_id", "=", metric.id)], limit=1)
|
||||
if charge:
|
||||
charge.write(charge_vals)
|
||||
else:
|
||||
charge = Charge.create(charge_vals)
|
||||
created = True
|
||||
return {
|
||||
"sub_product": sub_product, "overage_product": ov_product, "charge": charge,
|
||||
"price_monthly": price_monthly, "price_yearly": price_yearly,
|
||||
}, created
|
||||
|
||||
@api.model
|
||||
def _import_subscription(self, service, partner, plan_ctx, recurrence_plans, srow):
|
||||
SaleOrder = self.env["sale.order"]
|
||||
SaleOrderLine = self.env["sale.order.line"]
|
||||
sub_ext = str(srow["id"])
|
||||
cycle = (srow.get("billing_cycle") or "").strip().lower()
|
||||
if cycle not in ("monthly", "yearly"):
|
||||
raise UserError(
|
||||
"Subscription %s has an unrecognized billing_cycle %r — cannot pick a "
|
||||
"plan/price." % (sub_ext, srow.get("billing_cycle")))
|
||||
rec_plan = recurrence_plans["yearly"] if cycle == "yearly" else recurrence_plans["monthly"]
|
||||
price = plan_ctx["price_yearly"] if cycle == "yearly" else plan_ctx["price_monthly"]
|
||||
if price is None:
|
||||
raise UserError(
|
||||
"Subscription %s is billed %s but its plan has no %s price." % (
|
||||
sub_ext, cycle, cycle))
|
||||
product = plan_ctx["sub_product"]
|
||||
# x_fc_* are always (re-)written; identity fields (partner_id/plan_id/order_line)
|
||||
# are set ONLY at creation, so a re-run never rewrites immutable fields on an
|
||||
# order that may since have been confirmed.
|
||||
shadow_vals = {
|
||||
"x_fc_nexacloud_deployment_id": str(srow.get("deployment_id") or ""),
|
||||
"x_fc_nexacloud_plan_id": str(srow.get("plan_id") or ""),
|
||||
"x_fc_billing_service_id": service.id, "x_fc_shadow": True,
|
||||
}
|
||||
existing = SaleOrder.search(
|
||||
[("x_fc_nexacloud_subscription_id", "=", sub_ext)], limit=1)
|
||||
if existing:
|
||||
existing.write(shadow_vals)
|
||||
line = existing.order_line.filtered(lambda l: l.product_id == product)
|
||||
line_vals = {"product_uom_qty": 1, "price_unit": price}
|
||||
if line:
|
||||
line.write(line_vals)
|
||||
else:
|
||||
SaleOrderLine.create(
|
||||
dict(order_id=existing.id, product_id=product.id, **line_vals))
|
||||
order = existing
|
||||
created = False
|
||||
else:
|
||||
order = SaleOrder.create({
|
||||
"partner_id": partner.id, "plan_id": rec_plan.id,
|
||||
"x_fc_nexacloud_subscription_id": sub_ext,
|
||||
"order_line": [(0, 0, {
|
||||
"product_id": product.id, "product_uom_qty": 1, "price_unit": price})],
|
||||
**shadow_vals,
|
||||
})
|
||||
created = True
|
||||
# guarantee the explicit price stuck (a pricelist compute may have overwritten it)
|
||||
line = order.order_line.filtered(lambda l: l.product_id == product)
|
||||
if line and line.price_unit != price:
|
||||
line.price_unit = price
|
||||
return order, created
|
||||
498
fusion_centralize_billing/wizards/invoice_ledger.py
Normal file
498
fusion_centralize_billing/wizards/invoice_ledger.py
Normal file
@@ -0,0 +1,498 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""NexaCloud → Odoo invoice ledger ingester.
|
||||
|
||||
Reads NexaCloud's real (Stripe-billed) invoices and creates native Odoo
|
||||
``account.move`` customer invoices — posted, with the Stripe payments reconciled and
|
||||
HST modelled — so Odoo is the accounting system of record. Revenue is split by service
|
||||
family into distinct income accounts. NexaCloud/Stripe keep doing the billing; Odoo
|
||||
ingests its output. See docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
_name = "fusion.billing.invoice.ledger.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Invoice Ledger Ingester"
|
||||
|
||||
dry_run = fields.Boolean(default=True)
|
||||
auto_post = fields.Boolean(
|
||||
default=False, help="Post invoices immediately (else leave draft for review).")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
|
||||
# description keyword -> service family (checked in order; hosting before managed)
|
||||
_FAMILY_KEYWORDS = [
|
||||
("hosting", ["odoo erp hosting", "wordpress website hosting"]),
|
||||
("managed", ["managed"]),
|
||||
("addons", ["daily backup", "whatsapp", "forms builder", "white label"]),
|
||||
]
|
||||
|
||||
def action_run(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_invoices()
|
||||
if self.dry_run:
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
|
||||
res = {}
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
res.update(self._ingest_invoices(data, post=False))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
res["dry_run"] = True
|
||||
else:
|
||||
res = self._ingest_invoices(data, post=self.auto_post)
|
||||
self.result_summary = json.dumps(res, indent=2, default=str)
|
||||
if res.get("failed"):
|
||||
_logger.error("Ledger ingest: %s failed: %s", len(res["failed"]), res["failed"])
|
||||
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_invoices(self, since=None):
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
conn.set_client_encoding('UTF8') # invoice descriptions contain non-ASCII (e.g. "×")
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
where = "WHERE i.created_at >= %(since)s" if since else ""
|
||||
cur.execute(
|
||||
"SELECT i.id, i.stripe_invoice_id, i.invoice_number, "
|
||||
"i.user_id AS user_external_id, u.full_name AS partner_name, "
|
||||
"u.company AS partner_company, "
|
||||
"COALESCE(u.billing_email, u.email) AS partner_email, "
|
||||
"i.created_at AS invoice_date, i.currency, i.status, i.subtotal, i.tax, "
|
||||
"i.amount_paid, i.paid_at "
|
||||
"FROM invoices i JOIN users u ON u.id = i.user_id " + where +
|
||||
" ORDER BY i.created_at", {"since": since})
|
||||
invoices = {str(r["id"]): dict(r, items=[]) for r in cur.fetchall()}
|
||||
if invoices:
|
||||
cur.execute(
|
||||
"SELECT ii.invoice_id, ii.description, ii.quantity, ii.unit_price, ii.amount "
|
||||
"FROM invoice_items ii WHERE ii.invoice_id::text = ANY(%(ids)s)",
|
||||
{"ids": list(invoices.keys())})
|
||||
for r in cur.fetchall():
|
||||
inv = invoices.get(str(r["invoice_id"]))
|
||||
if inv:
|
||||
inv["items"].append({
|
||||
"description": r["description"], "quantity": r["quantity"],
|
||||
"unit_price": r["unit_price"], "amount": r["amount"]})
|
||||
out = []
|
||||
for inv in invoices.values():
|
||||
inv["id"] = str(inv["id"])
|
||||
inv["user_external_id"] = str(inv["user_external_id"])
|
||||
out.append(inv)
|
||||
return out
|
||||
except psycopg2.Error as e:
|
||||
raise UserError(
|
||||
"Failed reading NexaCloud invoices — the source schema may have changed. "
|
||||
"Underlying error:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ----- ingest side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _ingest_invoices(self, data, post=False, verified=None):
|
||||
"""Upsert one account.move per NexaCloud invoice.
|
||||
|
||||
``verified`` (optional) maps nc_id -> the dict returned by ``_fc_verify``
|
||||
(date + paid status taken from the SOURCE billing system). When present for
|
||||
an invoice, the source invoice_date and paid status win over NexaCloud's own
|
||||
(unreliable) fields. Without it, the raw NexaCloud fields are used (manual
|
||||
backfill / dry-run path)."""
|
||||
verified = verified or {}
|
||||
Move = self.env["account.move"]
|
||||
cad = self.env.ref("base.CAD", raise_if_not_found=False) or self.env.company.currency_id
|
||||
summary = {"created": 0, "updated": 0, "posted": 0, "reconciled": 0,
|
||||
"skipped": [], "failed": [], "by_family": {}}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
v = verified.get(nc_id)
|
||||
inv_date = (v or {}).get("invoice_date") or inv.get("invoice_date")
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
existing = Move.search(
|
||||
[("x_fc_nexacloud_invoice_id", "=", nc_id)], limit=1)
|
||||
if existing and existing.state != "draft":
|
||||
summary["skipped"].append({"id": nc_id, "reason": "already posted"})
|
||||
continue
|
||||
partner = self._fc_partner_for(inv)
|
||||
if existing:
|
||||
existing.invoice_line_ids.unlink() # draft: replace lines
|
||||
if existing.partner_id != partner:
|
||||
existing.partner_id = partner.id
|
||||
if inv_date and str(existing.invoice_date) != str(inv_date):
|
||||
existing.invoice_date = inv_date
|
||||
move = existing
|
||||
else:
|
||||
move = Move.create({
|
||||
"move_type": "out_invoice",
|
||||
"partner_id": partner.id,
|
||||
"invoice_date": inv_date,
|
||||
"ref": inv.get("invoice_number"),
|
||||
"currency_id": cad.id,
|
||||
"x_fc_nexacloud_invoice_id": nc_id,
|
||||
"x_fc_stripe_invoice_id": inv.get("stripe_invoice_id"),
|
||||
})
|
||||
tax = self._fc_tax_for(inv.get("subtotal"), inv.get("tax"))
|
||||
line_vals = []
|
||||
for it in inv.get("items", []):
|
||||
fam = self._fc_family_for(it.get("description"))
|
||||
summary["by_family"][fam] = round(
|
||||
summary["by_family"].get(fam, 0.0) + float(it.get("amount") or 0.0), 2)
|
||||
line_vals.append((0, 0, {
|
||||
"name": it.get("description") or "NexaCloud",
|
||||
"quantity": float(it.get("quantity") or 1.0),
|
||||
"price_unit": float(it.get("unit_price") or it.get("amount") or 0.0),
|
||||
"account_id": self._fc_income_account(fam).id,
|
||||
"tax_ids": [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||
}))
|
||||
# Many NexaCloud base-plan invoices store the charge in `subtotal` with
|
||||
# NO invoice_items. Add a balancing line for any gap so the Odoo invoice
|
||||
# total matches what Stripe actually billed (captures un-itemized revenue
|
||||
# and absorbs proration credits where items exceed subtotal).
|
||||
items_total = round(sum(float(it.get("amount") or 0.0)
|
||||
for it in inv.get("items", [])), 2)
|
||||
gap = round(float(inv.get("subtotal") or 0.0) - items_total, 2)
|
||||
if abs(gap) > 0.01:
|
||||
summary["by_family"]["base"] = round(
|
||||
summary["by_family"].get("base", 0.0) + gap, 2)
|
||||
line_vals.append((0, 0, {
|
||||
"name": "NexaCloud base/unitemized charge",
|
||||
"quantity": 1.0, "price_unit": gap,
|
||||
"account_id": self._fc_income_account("base").id,
|
||||
"tax_ids": [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||
}))
|
||||
if not line_vals:
|
||||
# zero-amount invoice (no items, $0 subtotal) — nothing to record;
|
||||
# drop the empty move (whether just-created or a pre-existing draft).
|
||||
move.unlink()
|
||||
summary["skipped"].append({"id": nc_id, "reason": "zero-amount invoice"})
|
||||
continue
|
||||
move.write({"invoice_line_ids": line_vals})
|
||||
summary["updated" if existing else "created"] += 1
|
||||
if post:
|
||||
if v and inv_date:
|
||||
# accounting date = source invoice date (else Odoo stamps today)
|
||||
move.write({"date": inv_date})
|
||||
move.action_post()
|
||||
summary["posted"] += 1
|
||||
if self._fc_reconcile_payment(move, inv, verified=v):
|
||||
summary["reconciled"] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Ledger ingest: invoice %s failed", nc_id)
|
||||
summary["failed"].append({"id": nc_id, "error": "%s: %s" % (type(e).__name__, e)})
|
||||
return summary
|
||||
|
||||
@api.model
|
||||
def _post_ingested(self):
|
||||
moves = self.env["account.move"].search([
|
||||
("x_fc_nexacloud_invoice_id", "!=", False),
|
||||
("state", "=", "draft"), ("move_type", "=", "out_invoice")])
|
||||
posted = 0
|
||||
for mv in moves:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
mv.action_post()
|
||||
posted += 1
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("Ledger post: move %s failed", mv.id)
|
||||
return posted
|
||||
|
||||
@api.model
|
||||
def _post_and_reconcile_paid(self, data):
|
||||
"""Post + reconcile ONLY the invoices NexaCloud marks paid, dating the ledger entry
|
||||
to the ORIGINAL invoice date and the payment to the actual paid_at. Leaves unpaid
|
||||
invoices as draft. Per-invoice isolated."""
|
||||
Move = self.env["account.move"]
|
||||
summary = {"posted": 0, "reconciled": 0, "skipped_unpaid": 0,
|
||||
"skipped_missing": 0, "failed": []}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
paid = float(inv.get("amount_paid") or 0.0)
|
||||
if inv.get("status") != "paid" and paid <= 0:
|
||||
summary["skipped_unpaid"] += 1
|
||||
continue
|
||||
mv = Move.search([("x_fc_nexacloud_invoice_id", "=", nc_id),
|
||||
("move_type", "=", "out_invoice")], limit=1)
|
||||
if not mv or not mv.invoice_line_ids:
|
||||
summary["skipped_missing"] += 1
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
if mv.state == "draft":
|
||||
inv_date = inv.get("invoice_date")
|
||||
# keep the original invoice + accounting date (not today)
|
||||
mv.write({"invoice_date": inv_date, "date": inv_date})
|
||||
mv.action_post()
|
||||
summary["posted"] += 1
|
||||
if mv.payment_state not in ("paid", "in_payment", "reversed"):
|
||||
if self._fc_reconcile_payment(mv, inv):
|
||||
summary["reconciled"] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Post+pay: invoice %s failed", nc_id)
|
||||
summary["failed"].append({"id": nc_id, "error": "%s: %s" % (type(e).__name__, e)})
|
||||
return summary
|
||||
|
||||
def _cron_sync_verified(self):
|
||||
"""Daily go-forward sync (the only safe automatic path).
|
||||
|
||||
Reads NexaCloud invoices, then for each one not already in the ledger verifies
|
||||
it against its SOURCE billing system (Stripe / Lago) and ingests + posts only
|
||||
verified data: the real invoice date, and a reconciled payment ONLY when the
|
||||
source confirms it is paid. Voids are skipped; anything that cannot be verified
|
||||
is logged and left for the next run (never posted on NexaCloud's own unreliable
|
||||
created_at / status / paid_at). Idempotent — already-posted invoices are left
|
||||
untouched."""
|
||||
Move = self.env["account.move"]
|
||||
data = self._read_nexacloud_invoices()
|
||||
to_ingest, verified = [], {}
|
||||
summary = {"verified": 0, "skipped_void": 0, "skipped_draft": 0,
|
||||
"already_posted": 0, "unverified": []}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
existing = Move.search(
|
||||
[("x_fc_nexacloud_invoice_id", "=", nc_id),
|
||||
("move_type", "=", "out_invoice")], limit=1)
|
||||
if existing and existing.state == "posted":
|
||||
summary["already_posted"] += 1
|
||||
continue
|
||||
v = self._fc_verify(inv)
|
||||
if v is None:
|
||||
summary["unverified"].append(nc_id)
|
||||
continue
|
||||
if v.get("void"):
|
||||
summary["skipped_void"] += 1
|
||||
continue
|
||||
if v.get("draft"):
|
||||
# not finalized at the source yet — will be picked up once it finalizes
|
||||
summary["skipped_draft"] += 1
|
||||
continue
|
||||
verified[nc_id] = v
|
||||
to_ingest.append(inv)
|
||||
summary["verified"] += 1
|
||||
res = self._ingest_invoices(to_ingest, post=True, verified=verified)
|
||||
for k in ("created", "updated", "posted", "reconciled", "failed"):
|
||||
summary[k] = res.get(k)
|
||||
if summary["unverified"]:
|
||||
_logger.warning("Ledger sync: %s invoice(s) unverified, will retry next run: %s",
|
||||
len(summary["unverified"]), summary["unverified"])
|
||||
_logger.info("Ledger sync summary: %s", summary)
|
||||
return summary
|
||||
|
||||
# ----- source-of-truth verification (Stripe / Lago) ----------------------
|
||||
@api.model
|
||||
def _fc_ts_to_date(self, ts):
|
||||
"""Unix timestamp (Stripe) -> 'YYYY-MM-DD' (UTC). None/blank-safe (0 = epoch)."""
|
||||
if ts is None or ts == "":
|
||||
return None
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc).date().isoformat()
|
||||
|
||||
@api.model
|
||||
def _fc_verify(self, inv):
|
||||
"""Route an invoice to its source billing system for verification.
|
||||
Returns a dict {invoice_date, void, paid, paid_at, amount_paid} or None if the
|
||||
source can't be determined / reached (caller then leaves it for the next run)."""
|
||||
sid = (inv.get("stripe_invoice_id") or "").strip()
|
||||
if sid.startswith("in_"):
|
||||
return self._fc_verify_stripe(sid)
|
||||
if sid.startswith("lago:"):
|
||||
return self._fc_verify_lago(sid[len("lago:"):])
|
||||
return None
|
||||
|
||||
@api.model
|
||||
def _fc_verify_stripe(self, stripe_invoice_id):
|
||||
key = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.stripe_api_key")
|
||||
if not key:
|
||||
return None
|
||||
import requests
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://api.stripe.com/v1/invoices/%s" % stripe_invoice_id,
|
||||
auth=(key, ""), timeout=20)
|
||||
except Exception: # noqa: BLE001 - network failure: treat as unverifiable
|
||||
_logger.exception("Stripe verify failed for %s", stripe_invoice_id)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning("Stripe verify %s -> HTTP %s", stripe_invoice_id, resp.status_code)
|
||||
return None
|
||||
d = resp.json()
|
||||
status = d.get("status")
|
||||
paid_ts = (d.get("status_transitions") or {}).get("paid_at")
|
||||
return {
|
||||
"invoice_date": self._fc_ts_to_date(d.get("created")),
|
||||
"void": status == "void",
|
||||
"draft": status == "draft", # not finalized in Stripe -> not a real invoice yet
|
||||
"paid": status == "paid" or float(d.get("amount_paid") or 0) > 0,
|
||||
"paid_at": self._fc_ts_to_date(paid_ts),
|
||||
"amount_paid": float(d.get("amount_paid") or 0) / 100.0,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _fc_verify_lago(self, lago_invoice_id):
|
||||
cp = self.env["ir.config_parameter"].sudo()
|
||||
url = cp.get_param("fusion_billing.lago_api_url")
|
||||
key = cp.get_param("fusion_billing.lago_api_key")
|
||||
if not url or not key:
|
||||
return None
|
||||
import requests
|
||||
try:
|
||||
resp = requests.get(
|
||||
"%s/v1/invoices/%s" % (url.rstrip("/"), lago_invoice_id),
|
||||
headers={"Authorization": "Bearer %s" % key}, timeout=20)
|
||||
except Exception: # noqa: BLE001 - network failure: treat as unverifiable
|
||||
_logger.exception("Lago verify failed for %s", lago_invoice_id)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning("Lago verify %s -> HTTP %s", lago_invoice_id, resp.status_code)
|
||||
return None
|
||||
d = (resp.json() or {}).get("invoice") or {}
|
||||
issuing = d.get("issuing_date") # already 'YYYY-MM-DD'
|
||||
return {
|
||||
"invoice_date": issuing,
|
||||
"void": d.get("status") == "voided",
|
||||
"draft": d.get("status") == "draft", # not finalized in Lago yet
|
||||
"paid": d.get("payment_status") == "succeeded",
|
||||
"paid_at": issuing, # Lago exposes no clean paid-at; issuing date is the proxy
|
||||
"amount_paid": float(d.get("total_paid_amount_cents") or 0) / 100.0,
|
||||
}
|
||||
|
||||
# ----- helpers ------------------------------------------------------------
|
||||
@api.model
|
||||
def _fc_family_for(self, description):
|
||||
d = (description or "").lower()
|
||||
m = re.match(r"remaining time on (.+?)(?: after| from |\s*\()", d)
|
||||
if m:
|
||||
d = m.group(1) # classify proration by the prorated item
|
||||
for fam, kws in self._FAMILY_KEYWORDS:
|
||||
if any(k in d for k in kws):
|
||||
return fam
|
||||
return "other"
|
||||
|
||||
@api.model
|
||||
def _fc_income_account(self, family):
|
||||
Account = self.env["account.account"]
|
||||
# Odoo 19 account codes allow only alphanumerics + dots (no hyphen).
|
||||
code = "NCR." + family.upper()
|
||||
acc = Account.search([("code", "=", code)], limit=1)
|
||||
if not acc:
|
||||
acc = Account.create({
|
||||
"code": code, "name": "NexaCloud %s Revenue" % family.title(),
|
||||
"account_type": "income"})
|
||||
return acc
|
||||
|
||||
@api.model
|
||||
def _fc_tax_for(self, subtotal, tax_amount):
|
||||
"""Map a NexaCloud invoice's (subtotal, tax) to the Odoo sale tax whose computed
|
||||
tax equals it. Picks by effective percent; falls back to a 0% sale tax."""
|
||||
Tax = self.env["account.tax"]
|
||||
sub = float(subtotal or 0.0)
|
||||
amt = float(tax_amount or 0.0)
|
||||
if sub <= 0 or amt <= 0:
|
||||
return Tax.search([("type_tax_use", "=", "sale"), ("amount", "=", 0.0)], limit=1)
|
||||
rate = round(100.0 * amt / sub)
|
||||
tax = Tax.search([("type_tax_use", "=", "sale"), ("amount_type", "=", "percent"),
|
||||
("amount", "=", float(rate))], limit=1)
|
||||
if not tax:
|
||||
tax = Tax.search([("type_tax_use", "=", "sale"), ("name", "ilike", "%s" % rate)], limit=1)
|
||||
return tax
|
||||
|
||||
@api.model
|
||||
def _fc_partner_for(self, inv):
|
||||
"""Resolve the unified partner via the nexacloud account.link (by user id);
|
||||
create partner+link if missing (covers NULL-subscription invoices)."""
|
||||
service = self.env["fusion.billing.service"].search([("code", "=", "nexacloud")], limit=1)
|
||||
if not service:
|
||||
service = self.env["fusion.billing.service"].create(
|
||||
{"name": "NexaCloud", "code": "nexacloud"})
|
||||
company = (inv.get("partner_company") or "").strip()
|
||||
name = company or inv.get("partner_name") or str(inv.get("user_external_id"))
|
||||
link = self.env["fusion.billing.account.link"]._resolve_or_create_partner(
|
||||
service, str(inv.get("user_external_id")), name=name, email=inv.get("partner_email"))
|
||||
partner = link.partner_id
|
||||
# Name the partner for the BUSINESS (company), not the NexaCloud user's full_name —
|
||||
# one person (e.g. "Gurpreet Singh") can manage several distinct customer businesses.
|
||||
# Rewrite an existing partner so earlier full_name-based names get corrected.
|
||||
if company and (partner.name != company or not partner.is_company):
|
||||
partner.write({"name": company, "is_company": True})
|
||||
return partner
|
||||
|
||||
@api.model
|
||||
def _fc_stripe_journal(self):
|
||||
Journal = self.env["account.journal"]
|
||||
j = Journal.search([("code", "=", "NCSTR")], limit=1)
|
||||
if not j:
|
||||
j = Journal.create({"name": "NexaCloud Stripe", "code": "NCSTR", "type": "bank"})
|
||||
return j
|
||||
|
||||
@api.model
|
||||
def _fc_reconcile_payment(self, move, inv, verified=None):
|
||||
"""Register + reconcile a Stripe payment against a posted invoice.
|
||||
|
||||
When ``verified`` is given, paid status / amount / date come from the SOURCE
|
||||
system (Stripe/Lago); a payment is created ONLY if the source confirms paid.
|
||||
Without it, NexaCloud's own (unreliable) fields are used (manual/backfill path)."""
|
||||
if move.state != "posted":
|
||||
return False
|
||||
if verified is not None:
|
||||
if not verified.get("paid"):
|
||||
return False
|
||||
amount = verified.get("amount_paid") or move.amount_total
|
||||
payment_date = verified.get("paid_at") or move.invoice_date or fields.Date.today()
|
||||
else:
|
||||
paid = float(inv.get("amount_paid") or 0.0)
|
||||
if inv.get("status") != "paid" and paid <= 0:
|
||||
return False
|
||||
amount = paid or move.amount_total
|
||||
payment_date = inv.get("paid_at") or move.invoice_date or fields.Date.today()
|
||||
reg = self.env["account.payment.register"].with_context(
|
||||
active_model="account.move", active_ids=move.ids).create({
|
||||
"journal_id": self._fc_stripe_journal().id,
|
||||
"payment_date": payment_date,
|
||||
"amount": amount,
|
||||
})
|
||||
reg._create_payments()
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _fc_prune_metered_shadow(self):
|
||||
"""Delete the superseded metered shadow data (shadow sale.orders, NC-* products,
|
||||
NexaCloud charges, reconciliation rows)."""
|
||||
counts = {}
|
||||
subs = self.env["sale.order"].search([("x_fc_shadow", "=", True)])
|
||||
counts["subscriptions"] = len(subs)
|
||||
subs.unlink()
|
||||
ch = self.env["fusion.billing.charge"].search([]) # before products (charge -> product)
|
||||
counts["charges"] = len(ch)
|
||||
ch.unlink()
|
||||
rec = self.env["fusion.billing.reconciliation"].search([])
|
||||
counts["reconciliations"] = len(rec)
|
||||
rec.unlink()
|
||||
prods = self.env["product.product"].search([("default_code", "=like", "NC-%")])
|
||||
counts["products"] = len(prods)
|
||||
try:
|
||||
prods.unlink()
|
||||
except Exception: # noqa: BLE001 - undeletable (referenced) products: archive instead
|
||||
prods.write({"active": False})
|
||||
counts["products_archived"] = len(prods)
|
||||
return counts
|
||||
358
fusion_clock/CLAUDE.md
Normal file
358
fusion_clock/CLAUDE.md
Normal file
@@ -0,0 +1,358 @@
|
||||
# Fusion Clock - Claude Code Instructions
|
||||
|
||||
> Read together with the repo-root `../CLAUDE.md` for global Odoo 19 rules, asset-cache handling, Supabase KB notes, and shared Fusion conventions. This file is only for the `fusion_clock` module.
|
||||
|
||||
## 1. What This Module Is
|
||||
|
||||
- **Name**: Fusion Clock.
|
||||
- **Version**: `19.0.3.3.0`.
|
||||
- **Category**: Human Resources/Attendances.
|
||||
- **License**: OPL-1, Nexa Systems Inc.
|
||||
- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`.
|
||||
- **Top-level menu**: `Fusion Clock`.
|
||||
- **Main surfaces**:
|
||||
- Portal clock page at `/my/clock`.
|
||||
- Portal timesheets at `/my/clock/timesheets`.
|
||||
- Portal reports at `/my/clock/reports`.
|
||||
- Shared PIN kiosk at `/fusion_clock/kiosk`.
|
||||
- NFC tap kiosk at `/fusion_clock/kiosk/nfc`.
|
||||
- Backend systray clock widget.
|
||||
- Backend manager/team-lead dashboard client action.
|
||||
|
||||
Core behaviours: geofenced clock-in/out, IP whitelist fallback, shift scheduling, break deduction, penalties, overtime, auto clock-out, absence detection, leave requests, correction workflow, payroll CSV export, PDF reports, weekly summaries, shared kiosk, NFC kiosk with photo capture, and activity audit logs.
|
||||
|
||||
## 2. Dependencies
|
||||
|
||||
Declared in `__manifest__.py`:
|
||||
|
||||
```
|
||||
hr_attendance, hr, portal, mail, resource
|
||||
```
|
||||
|
||||
External Python used directly:
|
||||
|
||||
- `pytz` for timezone-safe local day boundaries.
|
||||
- `requests` for Google Geocoding, OpenStreetMap/Nominatim fallback, and IP metadata.
|
||||
- `dateutil.relativedelta` inside pay-period calculations.
|
||||
|
||||
External browser APIs:
|
||||
|
||||
- Browser geolocation.
|
||||
- `ipapi.co` fallback geolocation in frontend/backend clock widgets.
|
||||
- Google Maps/Places when `fusion_clock.google_maps_api_key` is configured.
|
||||
- Web NFC and camera APIs for the NFC kiosk.
|
||||
|
||||
## 3. Naming And Field Prefixes
|
||||
|
||||
This module uses the module-specific prefix **`x_fclk_*`** on inherited Odoo models, not `x_fc_*`.
|
||||
|
||||
Examples:
|
||||
|
||||
- `hr.employee.x_fclk_enable_clock`
|
||||
- `hr.employee.x_fclk_nfc_card_uid`
|
||||
- `hr.attendance.x_fclk_clock_source`
|
||||
- `res.company.x_fclk_nfc_kiosk_location_id`
|
||||
|
||||
New inherited fields in this module should keep the `x_fclk_*` prefix unless there is a strong migration reason not to.
|
||||
|
||||
## 4. Model Map
|
||||
|
||||
Custom models:
|
||||
|
||||
| Model | File | Purpose |
|
||||
|---|---|---|
|
||||
| `fusion.clock.location` | `models/clock_location.py` | Geofenced/IP-whitelisted clock locations. |
|
||||
| `fusion.clock.shift` | `models/clock_shift.py` | Shift start/end/break schedule assigned to employees. |
|
||||
| `fusion.clock.penalty` | `models/clock_penalty.py` | Late clock-in / early clock-out penalty records. |
|
||||
| `fusion.clock.activity.log` | `models/clock_activity_log.py` | Append-style audit log for clock activity, geofence misses, absences, NFC enrolment, corrections. |
|
||||
| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. |
|
||||
| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. |
|
||||
| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. |
|
||||
| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. |
|
||||
|
||||
Inherited models:
|
||||
|
||||
- `hr.employee`: enable clock, default location, shift, kiosk PIN, NFC UID, pending reason flag, streaks, absence/overtime counters, and One2many links.
|
||||
- `hr.attendance`: clock source, location, distances, photos, break minutes, net hours, penalties, auto clock-out flag, overtime fields.
|
||||
- `res.config.settings`: all `fusion_clock.*` settings.
|
||||
- `res.company`: NFC kiosk location binding.
|
||||
|
||||
Timezone helpers live in `models/tz_utils.py`. Use `get_local_today()` and `get_local_day_boundaries()` for attendance domains instead of comparing UTC dates directly.
|
||||
|
||||
## 5. Clocking Flow
|
||||
|
||||
Primary API endpoint: `/fusion_clock/clock_action` in `controllers/clock_api.py`.
|
||||
|
||||
Clock-in flow:
|
||||
|
||||
1. Resolve current user to `hr.employee`.
|
||||
2. Block if `x_fclk_enable_clock` is false.
|
||||
3. If `x_fclk_pending_reason` is true, return `requires_reason`.
|
||||
4. Verify location against allowed active `fusion.clock.location` records.
|
||||
5. Call Odoo's `_attendance_action_change()`.
|
||||
6. Write location, distance, source, and optional photo to `hr.attendance`.
|
||||
7. Log `clock_in`.
|
||||
8. Create `late_in` penalty when outside grace.
|
||||
9. Increment/reset on-time streak; log milestone at 5, 10, 20, 50, 100.
|
||||
10. Notify office user for very-late clock-ins.
|
||||
|
||||
Clock-out flow:
|
||||
|
||||
1. Verify location again.
|
||||
2. Call `_attendance_action_change()`.
|
||||
3. Write out-distance.
|
||||
4. Apply break deduction when configured.
|
||||
5. Create `early_out` penalty when outside grace.
|
||||
6. Log `clock_out`.
|
||||
7. Log overtime if computed overtime is positive.
|
||||
|
||||
Location verification uses GPS when coordinates are available and geocoded locations exist. IP whitelist matching is attempted when a client IP is available. Error types include `no_locations`, `gps_unavailable`, `no_geocoded`, and `outside`.
|
||||
|
||||
## 6. Kiosk And NFC
|
||||
|
||||
Classic kiosk:
|
||||
|
||||
- Page: `/fusion_clock/kiosk`
|
||||
- JSON routes:
|
||||
- `/fusion_clock/kiosk/search`
|
||||
- `/fusion_clock/kiosk/verify_pin`
|
||||
- `/fusion_clock/kiosk/clock`
|
||||
- Requires `fusion_clock.group_fusion_clock_manager`.
|
||||
- Controlled by `fusion_clock.enable_kiosk` and `fusion_clock.kiosk_pin_required`.
|
||||
- Uses `hr.employee.x_fclk_kiosk_pin`.
|
||||
|
||||
NFC kiosk:
|
||||
|
||||
- Page: `/fusion_clock/kiosk/nfc`
|
||||
- JSON routes:
|
||||
- `/fusion_clock/kiosk/nfc/enroll`
|
||||
- `/fusion_clock/kiosk/nfc/tap`
|
||||
- `/fusion_clock/kiosk/nfc/employee_search`
|
||||
- Requires `fusion_clock.group_fusion_clock_manager`.
|
||||
- Controlled by:
|
||||
- `fusion_clock.enable_nfc_kiosk`
|
||||
- `fusion_clock.nfc_photo_required`
|
||||
- `fusion_clock.nfc_enroll_password`
|
||||
- `fusion_clock.nfc_kiosk_debug`
|
||||
- `res.company.x_fclk_nfc_kiosk_location_id`
|
||||
- Card UID canonical format is uppercase colon-separated hex, e.g. `04:A2:B5:62:C1:80`.
|
||||
- Normalization lives in `FusionClockNfcKiosk._normalize_uid()` and is reused by the backend wizard.
|
||||
- Tap debounce is module-level memory in `controllers/clock_nfc_kiosk.py`: same UID within 5 seconds returns `debounce`.
|
||||
- Photo data URLs are stripped before writing binary fields.
|
||||
- NFC clock-ins write `x_fclk_check_in_photo`; NFC clock-outs write `x_fclk_check_out_photo`.
|
||||
|
||||
Important: unknown-card taps currently return `card_unknown`; the `unknown_card_tap` log type exists but is not written by the endpoint.
|
||||
|
||||
## 7. Reports And Payroll Export
|
||||
|
||||
`fusion.clock.report` supports:
|
||||
|
||||
- Employee reports when `employee_id` is set.
|
||||
- Batch reports when `employee_id` is empty.
|
||||
- PDF generation through QWeb reports:
|
||||
- `fusion_clock.action_report_clock_employee`
|
||||
- `fusion_clock.action_report_clock_batch`
|
||||
- CSV export via `action_export_csv()`.
|
||||
- Custom CSV headings via JSON in `fusion_clock.csv_column_mapping`.
|
||||
- Email send with generated PDF attached.
|
||||
|
||||
Pay period types:
|
||||
|
||||
```
|
||||
weekly, biweekly, semi_monthly, monthly
|
||||
```
|
||||
|
||||
The anchor date setting is `fusion_clock.pay_period_start` as a string in `YYYY-MM-DD` format.
|
||||
|
||||
Historical report generation is exposed through the `Generate Historical Reports` menu action and creates draft reports for completed attendance periods. The scheduled report cron only generates when yesterday is the period end.
|
||||
|
||||
## 8. Scheduled Automation
|
||||
|
||||
Configured in `data/ir_cron_data.xml`:
|
||||
|
||||
| Cron | Model method | Frequency |
|
||||
|---|---|---|
|
||||
| Fusion Clock: Auto Clock-Out | `hr.attendance._cron_fusion_auto_clock_out()` | Every 15 minutes |
|
||||
| Fusion Clock: Generate Period Reports | `fusion.clock.report._cron_generate_period_reports()` | Daily |
|
||||
| Fusion Clock: Daily Absence Check | `hr.attendance._cron_fusion_check_absences()` | Daily |
|
||||
| Fusion Clock: Employee Reminders | `hr.attendance._cron_fusion_employee_reminders()` | Every 15 minutes |
|
||||
| Fusion Clock: Weekly Summary | `hr.attendance._cron_fusion_weekly_summary()` | Daily, internally sends Mondays |
|
||||
|
||||
Auto clock-out closes open attendances after scheduled end plus grace, capped by max shift hours. It sets `x_fclk_pending_reason` so the employee must explain before clocking in again.
|
||||
|
||||
Absence detection checks enabled employees, skips weekends and global resource calendar leaves, and logs `absent` when no attendance or leave request exists.
|
||||
|
||||
## 9. Security
|
||||
|
||||
Groups:
|
||||
|
||||
- `group_fusion_clock_user`
|
||||
- `group_fusion_clock_team_lead`
|
||||
- `group_fusion_clock_manager`
|
||||
|
||||
Admin is auto-assigned to manager in `security/security.xml`.
|
||||
|
||||
Access pattern:
|
||||
|
||||
- Users and portal users can read their own clock data.
|
||||
- Team leads can read direct reports for penalties, activity logs, corrections, and dashboard data.
|
||||
- Managers have full model access and all configuration/kiosk/report menus.
|
||||
- Portal rules are defined for `hr.attendance`, `fusion.clock.location`, `fusion.clock.report`, `fusion.clock.penalty`, `fusion.clock.activity.log`, `fusion.clock.leave.request`, `fusion.clock.correction`, and `fusion.clock.shift`.
|
||||
|
||||
Backend dashboard access is checked in `/fusion_clock/dashboard_data`: manager sees all enabled employees; team lead sees employees where `parent_id` is the current user's employee.
|
||||
|
||||
## 10. Frontend Assets
|
||||
|
||||
Frontend bundle:
|
||||
|
||||
- `static/src/css/portal_clock.css`
|
||||
- `static/src/scss/nfc_kiosk.scss`
|
||||
- `static/src/js/fusion_clock_portal.js`
|
||||
- `static/src/js/fusion_clock_kiosk.js`
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js`
|
||||
|
||||
Backend bundle:
|
||||
|
||||
- `static/src/scss/fusion_clock.scss`
|
||||
- `static/src/js/fusion_clock_systray.js`
|
||||
- `static/src/xml/systray_clock.xml`
|
||||
- `static/src/js/fusion_clock_dashboard.js`
|
||||
- `static/src/xml/fusion_clock_dashboard.xml`
|
||||
- `static/src/js/fusion_clock_location_map.js`
|
||||
- `static/src/js/fusion_clock_location_places.js`
|
||||
- `static/src/xml/fusion_clock_location.xml`
|
||||
|
||||
Patterns:
|
||||
|
||||
- Public portal/kiosk JS should use `Interaction` from `@web/public/interaction` and register in `registry.category("public.interactions")`.
|
||||
- Backend OWL client actions and field widgets use standalone `rpc()` from `@web/core/network/rpc`.
|
||||
- `fusion_clock_systray.js` is a systray OWL component registered as `fusion_clock.ClockSystray`.
|
||||
- `fusion_clock_dashboard.js` is a client action registered as `fusion_clock.Dashboard`.
|
||||
- Location widgets are registered field widgets: `fclk_location_map` and `fclk_places_autocomplete`.
|
||||
|
||||
Known technical debt:
|
||||
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js` is currently an isolated IIFE. If touching it, prefer migrating to an Odoo 19 `Interaction` instead of expanding the IIFE pattern.
|
||||
- `static/src/css/portal_clock.css` and `static/src/scss/fusion_clock.scss` contain runtime dark-mode selectors/media rules. For backend SCSS changes, follow the repo-root Odoo 19 compile-time dark bundle guidance.
|
||||
- `fusion_clock.scss` uses some Bootstrap CSS vars for status accents. Avoid relying on Bootstrap vars for card/background/border surfaces in new dashboard work.
|
||||
|
||||
## 11. Settings Keys
|
||||
|
||||
Important `ir.config_parameter` keys:
|
||||
|
||||
```
|
||||
fusion_clock.default_clock_in_time
|
||||
fusion_clock.default_clock_out_time
|
||||
fusion_clock.default_break_minutes
|
||||
fusion_clock.auto_deduct_break
|
||||
fusion_clock.break_threshold_hours
|
||||
fusion_clock.enable_auto_clockout
|
||||
fusion_clock.grace_period_minutes
|
||||
fusion_clock.max_shift_hours
|
||||
fusion_clock.enable_penalties
|
||||
fusion_clock.penalty_grace_minutes
|
||||
fusion_clock.penalty_deduction_minutes
|
||||
fusion_clock.enable_overtime
|
||||
fusion_clock.daily_overtime_threshold
|
||||
fusion_clock.weekly_overtime_threshold
|
||||
fusion_clock.office_user_id
|
||||
fusion_clock.very_late_threshold_minutes
|
||||
fusion_clock.max_monthly_absences
|
||||
fusion_clock.enable_employee_notifications
|
||||
fusion_clock.reminder_before_shift_minutes
|
||||
fusion_clock.reminder_before_end_minutes
|
||||
fusion_clock.send_weekly_summary
|
||||
fusion_clock.enable_ip_fallback
|
||||
fusion_clock.enable_photo_verification
|
||||
fusion_clock.google_maps_api_key
|
||||
fusion_clock.enable_kiosk
|
||||
fusion_clock.kiosk_pin_required
|
||||
fusion_clock.enable_correction_requests
|
||||
fusion_clock.enable_sounds
|
||||
fusion_clock.pay_period_type
|
||||
fusion_clock.pay_period_start
|
||||
fusion_clock.auto_generate_reports
|
||||
fusion_clock.send_employee_reports
|
||||
fusion_clock.report_recipient_user_ids
|
||||
fusion_clock.report_recipient_emails
|
||||
fusion_clock.csv_column_mapping
|
||||
fusion_clock.enable_nfc_kiosk
|
||||
fusion_clock.nfc_photo_required
|
||||
fusion_clock.nfc_enroll_password
|
||||
fusion_clock.nfc_kiosk_debug
|
||||
```
|
||||
|
||||
`fclk_report_recipient_user_ids` is a Many2many on settings but is persisted manually as comma-separated user IDs in `fusion_clock.report_recipient_user_ids`.
|
||||
|
||||
## 12. Routes
|
||||
|
||||
HTTP pages:
|
||||
|
||||
```
|
||||
/my/clock
|
||||
/my/clock/timesheets
|
||||
/my/clock/reports
|
||||
/my/clock/reports/<report_id>/download
|
||||
/fusion_clock/kiosk
|
||||
/fusion_clock/kiosk/nfc
|
||||
```
|
||||
|
||||
JSON-RPC endpoints:
|
||||
|
||||
```
|
||||
/fusion_clock/verify_location
|
||||
/fusion_clock/clock_action
|
||||
/fusion_clock/submit_reason
|
||||
/fusion_clock/request_leave
|
||||
/fusion_clock/request_correction
|
||||
/fusion_clock/get_status
|
||||
/fusion_clock/get_locations
|
||||
/fusion_clock/get_settings
|
||||
/fusion_clock/dashboard_data
|
||||
/fusion_clock/kiosk/search
|
||||
/fusion_clock/kiosk/verify_pin
|
||||
/fusion_clock/kiosk/clock
|
||||
/fusion_clock/kiosk/nfc/enroll
|
||||
/fusion_clock/kiosk/nfc/tap
|
||||
/fusion_clock/kiosk/nfc/employee_search
|
||||
```
|
||||
|
||||
All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`.
|
||||
|
||||
## 13. Gotchas
|
||||
|
||||
- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets.
|
||||
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
|
||||
- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field.
|
||||
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes.
|
||||
- Daily overtime currently compares net hours to employee scheduled hours or daily threshold; weekly threshold is configured but not used in `hr.attendance._compute_overtime_hours()`.
|
||||
- `fusion_clock.enable_ip_fallback` exists in settings, but server-side `_verify_location()` attempts IP whitelist matching whenever a client IP is present.
|
||||
- NFC kiosk needs a company-level `x_fclk_nfc_kiosk_location_id`; without it taps return `no_location_configured`.
|
||||
- Kiosk routes are authenticated (`auth='user'`) and manager-gated; wall tablets need a manager-authorised kiosk user.
|
||||
- Portal report download manually streams the PDF binary rather than using `fusion_pdf_preview`.
|
||||
- If CSS/assets change, bump `__manifest__.py` version so Odoo rebuilds bundles.
|
||||
|
||||
## 14. Tests
|
||||
|
||||
Tests are post-install tagged:
|
||||
|
||||
```
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
```
|
||||
|
||||
Coverage currently focuses on NFC:
|
||||
|
||||
- `tests/test_nfc_models.py`: employee UID uniqueness, attendance NFC source/photo fields, company kiosk location field.
|
||||
- `tests/test_clock_nfc_kiosk.py`: kiosk page gating, UID normalization, enroll endpoint, tap happy path, tap errors, photo-required handling, employee search.
|
||||
|
||||
Run locally:
|
||||
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --test-tags fusion_clock --stop-after-init
|
||||
```
|
||||
|
||||
For a normal module upgrade:
|
||||
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init
|
||||
```
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Clock',
|
||||
'version': '19.0.3.3.0',
|
||||
'version': '19.0.3.14.2',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||
'description': """
|
||||
@@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/clock_correction_views.xml',
|
||||
'views/clock_dashboard_views.xml',
|
||||
'views/hr_employee_views.xml',
|
||||
'views/clock_schedule_views.xml',
|
||||
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
||||
'wizard/clock_nfc_enrollment_views.xml',
|
||||
'views/clock_menus.xml',
|
||||
@@ -77,6 +78,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/portal_clock_templates.xml',
|
||||
'views/portal_timesheet_templates.xml',
|
||||
'views/portal_report_templates.xml',
|
||||
'views/portal_payslip_templates.xml',
|
||||
'views/kiosk_templates.xml',
|
||||
'views/kiosk_nfc_templates.xml',
|
||||
],
|
||||
@@ -89,15 +91,22 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss',
|
||||
'fusion_clock/static/src/scss/fusion_clock_shift_planner.scss',
|
||||
'fusion_clock/static/src/scss/fusion_clock.scss',
|
||||
'fusion_clock/static/src/js/fusion_clock_systray.js',
|
||||
'fusion_clock/static/src/xml/systray_clock.xml',
|
||||
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
|
||||
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml',
|
||||
'fusion_clock/static/src/js/fusion_clock_shift_planner.js',
|
||||
'fusion_clock/static/src/xml/fusion_clock_shift_planner.xml',
|
||||
'fusion_clock/static/src/js/fusion_clock_location_map.js',
|
||||
'fusion_clock/static/src/js/fusion_clock_location_places.js',
|
||||
'fusion_clock/static/src/xml/fusion_clock_location.xml',
|
||||
],
|
||||
'web.assets_web_dark': [
|
||||
'fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
|
||||
BIN
fusion_clock/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
@@ -4,3 +4,4 @@ from . import portal_clock
|
||||
from . import clock_api
|
||||
from . import clock_kiosk
|
||||
from . import clock_nfc_kiosk
|
||||
from . import shift_planner
|
||||
|
||||
BIN
fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/controllers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc
Normal file
BIN
fusion_clock/controllers/__pycache__/clock_kiosk.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,6 +5,7 @@
|
||||
import base64
|
||||
import math
|
||||
import logging
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
@@ -108,6 +109,10 @@ class FusionClockAPI(http.Controller):
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
||||
return
|
||||
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
|
||||
if not day_plan.get('scheduled'):
|
||||
# No late/early penalties on days the employee isn't scheduled to work.
|
||||
return
|
||||
|
||||
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
||||
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
|
||||
@@ -161,7 +166,16 @@ class FusionClockAPI(http.Controller):
|
||||
worked = attendance.worked_hours or 0.0
|
||||
|
||||
if worked >= threshold:
|
||||
break_min = employee._get_fclk_break_minutes()
|
||||
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)
|
||||
@@ -268,6 +282,9 @@ class FusionClockAPI(http.Controller):
|
||||
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
# "Unscheduled" = a posted OFF day OR a day with no schedule at all.
|
||||
is_scheduled_off = not day_plan.get('scheduled')
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
@@ -307,6 +324,34 @@ class FusionClockAPI(http.Controller):
|
||||
source=source,
|
||||
)
|
||||
|
||||
if is_scheduled_off:
|
||||
self._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"Clocked in on an unscheduled day at {location.name}.",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source=source,
|
||||
)
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
if office_user_id:
|
||||
request.env['hr.attendance'].sudo()._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Unscheduled Shift: {employee.name}",
|
||||
f"{employee.name} clocked in on an unscheduled day.",
|
||||
'hr.attendance',
|
||||
attendance.id,
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
'attendance_id': attendance.id,
|
||||
'check_in': fields.Datetime.to_string(attendance.check_in),
|
||||
'location_name': location.name,
|
||||
'location_address': location.address or '',
|
||||
'message': f'Clocked in at {location.name} (unscheduled shift)',
|
||||
'streak': employee.x_fclk_ontime_streak,
|
||||
}
|
||||
|
||||
# Check for late clock-in penalty
|
||||
scheduled_in, _ = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
@@ -359,8 +404,9 @@ class FusionClockAPI(http.Controller):
|
||||
self._apply_break_deduction(attendance, employee)
|
||||
|
||||
# Check for early clock-out penalty
|
||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
|
||||
# Log clock-out
|
||||
self._log_activity(
|
||||
@@ -436,35 +482,47 @@ class FusionClockAPI(http.Controller):
|
||||
return {'success': True, 'message': 'Reason submitted. You may now clock in.'}
|
||||
|
||||
@http.route('/fusion_clock/request_leave', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def request_leave(self, leave_date='', reason='', **kw):
|
||||
"""Submit a leave request from the portal."""
|
||||
def request_leave(self, date_from='', date_to='', reason='', leave_date='', **kw):
|
||||
"""Submit a (possibly multi-day) leave request from the portal."""
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found for current user.'}
|
||||
|
||||
if not leave_date or not reason:
|
||||
return {'error': 'Please provide both a date and a reason.'}
|
||||
date_from = date_from or leave_date # back-compat with the old single-date payload
|
||||
date_to = date_to or date_from
|
||||
if not date_from or not reason:
|
||||
return {'error': 'Please provide a start date and a reason.'}
|
||||
|
||||
try:
|
||||
date_obj = fields.Date.from_string(leave_date)
|
||||
from_obj = fields.Date.from_string(date_from)
|
||||
to_obj = fields.Date.from_string(date_to)
|
||||
except Exception:
|
||||
return {'error': 'Invalid date format. Use YYYY-MM-DD.'}
|
||||
if to_obj < from_obj:
|
||||
return {'error': 'The end date cannot be before the start date.'}
|
||||
|
||||
# Reject if an existing request overlaps the requested range.
|
||||
existing = request.env['fusion.clock.leave.request'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('leave_date', '=', date_obj),
|
||||
('leave_date', '<=', to_obj),
|
||||
('date_to', '>=', from_obj),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {'error': 'A leave request already exists for this date.'}
|
||||
return {'error': 'A leave request already overlaps these dates.'}
|
||||
|
||||
request.env['fusion.clock.leave.request'].sudo().create({
|
||||
'employee_id': employee.id,
|
||||
'leave_date': date_obj,
|
||||
'leave_date': from_obj,
|
||||
'date_to': to_obj,
|
||||
'reason': reason,
|
||||
'created_from': 'portal',
|
||||
})
|
||||
|
||||
return {'success': True, 'message': f'Leave request for {leave_date} submitted.'}
|
||||
if from_obj == to_obj:
|
||||
msg = f'Leave request for {date_from} submitted.'
|
||||
else:
|
||||
msg = f'Leave request for {date_from} to {date_to} submitted.'
|
||||
return {'success': True, 'message': msg}
|
||||
|
||||
@http.route('/fusion_clock/request_correction', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def request_correction(self, attendance_id=0, check_in='', check_out='', reason='', **kw):
|
||||
@@ -518,6 +576,13 @@ class FusionClockAPI(http.Controller):
|
||||
'pending_reason': employee.x_fclk_pending_reason,
|
||||
'ontime_streak': employee.x_fclk_ontime_streak,
|
||||
}
|
||||
local_today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(local_today)
|
||||
result.update({
|
||||
'scheduled_shift': day_plan.get('label') or '',
|
||||
'scheduled_hours': round(day_plan.get('hours') or 0.0, 2),
|
||||
'scheduled_off': bool(day_plan.get('is_off')),
|
||||
})
|
||||
|
||||
if is_checked_in:
|
||||
att = request.env['hr.attendance'].sudo().search([
|
||||
@@ -533,7 +598,6 @@ class FusionClockAPI(http.Controller):
|
||||
'location_id': att.x_fclk_location_id.id or False,
|
||||
})
|
||||
|
||||
local_today = get_local_today(request.env, employee)
|
||||
today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
|
||||
today_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
@@ -606,78 +670,216 @@ class FusionClockAPI(http.Controller):
|
||||
'enable_corrections': ICP.get_param('fusion_clock.enable_correction_requests', 'True') == 'True',
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/dashboard_data', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def dashboard_data(self, **kw):
|
||||
"""Return dashboard data for managers."""
|
||||
user = request.env.user
|
||||
is_manager = user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
is_team_lead = user.has_group('fusion_clock.group_fusion_clock_team_lead')
|
||||
def _dashboard_personal(self, employee):
|
||||
"""Build the always-present personal block. Caller's own employee
|
||||
only — never another employee's data."""
|
||||
env = request.env
|
||||
local_today = get_local_today(env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(local_today)
|
||||
|
||||
if not is_manager and not is_team_lead:
|
||||
return {'error': 'Access denied.'}
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
check_in = False
|
||||
location_name = ''
|
||||
if is_checked_in:
|
||||
att = env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '=', False),
|
||||
], limit=1)
|
||||
if att:
|
||||
check_in = fields.Datetime.to_string(att.check_in)
|
||||
location_name = att.x_fclk_location_id.name or ''
|
||||
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env)
|
||||
today_start, _ = get_local_day_boundaries(request.env, today)
|
||||
today_start_utc, today_end_utc = get_local_day_boundaries(env, local_today, employee)
|
||||
today_atts = env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', fields.Datetime.to_string(today_start_utc)),
|
||||
('check_in', '<', fields.Datetime.to_string(today_end_utc)),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
today_hours = round(sum(a.x_fclk_net_hours or 0 for a in today_atts), 2)
|
||||
|
||||
Attendance = request.env['hr.attendance'].sudo()
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
week_start = local_today - timedelta(days=local_today.weekday())
|
||||
week_start_utc, _ignore = get_local_day_boundaries(env, week_start, employee)
|
||||
week_atts = env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', fields.Datetime.to_string(week_start_utc)),
|
||||
('check_in', '<', fields.Datetime.to_string(today_end_utc)),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
week_hours = round(sum(a.x_fclk_net_hours or 0 for a in week_atts), 2)
|
||||
|
||||
# Filter employees by access
|
||||
if is_manager:
|
||||
employees = Employee.search([('x_fclk_enable_clock', '=', True)])
|
||||
if not employee.x_fclk_enable_clock:
|
||||
status_note = 'Clock disabled'
|
||||
elif day_plan.get('is_off'):
|
||||
status_note = 'Day off'
|
||||
elif not day_plan.get('scheduled'):
|
||||
status_note = 'Not scheduled today'
|
||||
elif is_checked_in:
|
||||
status_note = 'Clocked in'
|
||||
else:
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found.'}
|
||||
employees = Employee.search([
|
||||
('parent_id', '=', employee.id),
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
status_note = 'Not clocked in'
|
||||
|
||||
emp_ids = employees.ids
|
||||
recent = env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '!=', False),
|
||||
], order='check_in desc', limit=6)
|
||||
recent_activity = [{
|
||||
'check_in': fields.Datetime.to_string(a.check_in),
|
||||
'check_out': fields.Datetime.to_string(a.check_out),
|
||||
'worked_hours': round(a.worked_hours or 0, 2),
|
||||
'overtime_hours': round(a.x_fclk_overtime_hours or 0, 2),
|
||||
'location': a.x_fclk_location_id.name or '',
|
||||
} for a in recent]
|
||||
|
||||
leaves = env['fusion.clock.leave.request'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('leave_date', '>=', local_today),
|
||||
], order='leave_date asc', limit=5)
|
||||
leave_sel = dict(env['fusion.clock.leave.request']._fields['state'].selection)
|
||||
leave_list = [{
|
||||
'label': lv._fclk_date_label(),
|
||||
'state': leave_sel.get(lv.state, lv.state),
|
||||
} for lv in leaves]
|
||||
|
||||
month_start = local_today.replace(day=1)
|
||||
penalties = env['fusion.clock.penalty'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('date', '>=', month_start),
|
||||
], order='date desc', limit=5)
|
||||
pen_sel = dict(env['fusion.clock.penalty']._fields['penalty_type'].selection)
|
||||
penalty_list = [{
|
||||
'type': pen_sel.get(p.penalty_type, p.penalty_type),
|
||||
'date': fields.Date.to_string(p.date),
|
||||
'minutes': round(p.penalty_minutes or 0, 1),
|
||||
} for p in penalties]
|
||||
|
||||
return {
|
||||
'employee_name': employee.name,
|
||||
'enable_clock': employee.x_fclk_enable_clock,
|
||||
'is_checked_in': is_checked_in,
|
||||
'check_in': check_in,
|
||||
'location_name': location_name,
|
||||
'pending_reason': employee.x_fclk_pending_reason,
|
||||
'today_hours': today_hours,
|
||||
'week_hours': week_hours,
|
||||
'overtime_week': round(employee.x_fclk_overtime_this_week or 0, 2),
|
||||
'ontime_streak': employee.x_fclk_ontime_streak,
|
||||
'shift': {
|
||||
'label': day_plan.get('label') or '',
|
||||
'hours': round(day_plan.get('hours') or 0.0, 2),
|
||||
'source': day_plan.get('source') or 'none',
|
||||
'scheduled_off': bool(day_plan.get('is_off')),
|
||||
'scheduled': bool(day_plan.get('scheduled')),
|
||||
'status_note': status_note,
|
||||
},
|
||||
'recent_activity': recent_activity,
|
||||
'leaves': leave_list,
|
||||
'penalties': penalty_list,
|
||||
}
|
||||
|
||||
def _dashboard_team(self, emp_ids, scope):
|
||||
"""Build the team/org block for the given (already role-scoped)
|
||||
employee ids. ``scope`` is 'team' (lead's direct reports) or 'org'."""
|
||||
env = request.env
|
||||
today = get_local_today(env)
|
||||
today_start, _ignore = get_local_day_boundaries(env, today)
|
||||
Attendance = env['hr.attendance'].sudo()
|
||||
|
||||
# Currently clocked in
|
||||
open_atts = Attendance.search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('check_out', '=', False),
|
||||
])
|
||||
clocked_in = [{
|
||||
'employee': a.employee_id.name,
|
||||
'check_in': fields.Datetime.to_string(a.check_in),
|
||||
'location': a.x_fclk_location_id.name or '',
|
||||
} for a in open_atts]
|
||||
|
||||
# Today stats
|
||||
today_atts = Attendance.search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
present_ids = set(a.employee_id.id for a in today_atts)
|
||||
|
||||
ActivityLog = request.env['fusion.clock.activity.log'].sudo()
|
||||
late_count = ActivityLog.search_count([
|
||||
ActivityLog = env['fusion.clock.activity.log'].sudo()
|
||||
late_logs = ActivityLog.search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('log_type', '=', 'late_clock_in'),
|
||||
('log_date', '>=', today_start),
|
||||
])
|
||||
late_emp_ids = set(late_logs.mapped('employee_id').ids)
|
||||
|
||||
# Pending alerts
|
||||
pending_reasons = Employee.search_count([
|
||||
clocked_in = [{
|
||||
'employee': a.employee_id.name,
|
||||
'check_in': fields.Datetime.to_string(a.check_in),
|
||||
'location': a.x_fclk_location_id.name or '',
|
||||
'late': a.employee_id.id in late_emp_ids,
|
||||
} for a in open_atts]
|
||||
|
||||
today_atts = Attendance.search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
present_ids = set(today_atts.mapped('employee_id').ids)
|
||||
|
||||
# employees on an approved leave covering today
|
||||
leave_recs = env['fusion.clock.leave.request'].sudo().search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('leave_date', '<=', today),
|
||||
])
|
||||
on_leave_ids = set()
|
||||
for lv in leave_recs:
|
||||
end = lv.date_to or lv.leave_date
|
||||
if lv.leave_date and lv.leave_date <= today <= end:
|
||||
on_leave_ids.add(lv.employee_id.id)
|
||||
|
||||
present_count = len(present_ids)
|
||||
on_leave_count = len(on_leave_ids - present_ids)
|
||||
absent_count = max(len(emp_ids) - present_count - on_leave_count, 0)
|
||||
|
||||
pending_reasons = env['hr.employee'].sudo().search_count([
|
||||
('id', 'in', emp_ids),
|
||||
('x_fclk_pending_reason', '=', True),
|
||||
])
|
||||
pending_corrections = request.env['fusion.clock.correction'].sudo().search_count([
|
||||
pending_approvals = env['fusion.clock.correction'].sudo().search_count([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('state', '=', 'pending'),
|
||||
])
|
||||
|
||||
return {
|
||||
'clocked_in': clocked_in,
|
||||
'scope': scope,
|
||||
'total_employees': len(emp_ids),
|
||||
'present_count': len(present_ids),
|
||||
'absent_count': len(emp_ids) - len(present_ids),
|
||||
'late_count': late_count,
|
||||
'present_count': present_count,
|
||||
'on_leave_count': on_leave_count,
|
||||
'absent_count': absent_count,
|
||||
'late_count': len(late_emp_ids),
|
||||
'pending_reasons': pending_reasons,
|
||||
'pending_corrections': pending_corrections,
|
||||
'pending_approvals': pending_approvals,
|
||||
'clocked_in': clocked_in,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/dashboard_data', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def dashboard_data(self, **kw):
|
||||
"""Layered, role-aware dashboard payload.
|
||||
|
||||
Everyone gets their own ``personal`` block. The ``team`` block is
|
||||
added ONLY for team leads (their direct reports) and managers
|
||||
(org-wide). A regular employee's payload never contains another
|
||||
employee's data.
|
||||
"""
|
||||
user = request.env.user
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee profile is linked to your account.'}
|
||||
|
||||
is_manager = user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
is_team_lead = user.has_group('fusion_clock.group_fusion_clock_team_lead')
|
||||
role = 'manager' if is_manager else ('team_lead' if is_team_lead else 'employee')
|
||||
|
||||
result = {
|
||||
'role': role,
|
||||
'personal': self._dashboard_personal(employee),
|
||||
'team': None,
|
||||
}
|
||||
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
if is_manager:
|
||||
emp_ids = Employee.search([('x_fclk_enable_clock', '=', True)]).ids
|
||||
result['team'] = self._dashboard_team(emp_ids, 'org')
|
||||
elif is_team_lead:
|
||||
emp_ids = Employee.search([
|
||||
('parent_id', '=', employee.id),
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
]).ids
|
||||
result['team'] = self._dashboard_team(emp_ids, 'team')
|
||||
|
||||
return result
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
import logging
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_kiosk_operator(user):
|
||||
"""Kiosk surfaces accept a full Clock Manager OR a dedicated Kiosk Operator."""
|
||||
return (user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
or user.has_group('fusion_clock.group_fusion_clock_kiosk_app'))
|
||||
|
||||
|
||||
class FusionClockKiosk(http.Controller):
|
||||
"""Kiosk mode controller for shared-device clock-in/out."""
|
||||
|
||||
@@ -16,7 +23,7 @@ class FusionClockKiosk(http.Controller):
|
||||
def kiosk_page(self, **kw):
|
||||
"""Kiosk clock-in/out page for shared tablets."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return request.redirect('/my')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
@@ -33,7 +40,7 @@ class FusionClockKiosk(http.Controller):
|
||||
def kiosk_search(self, query='', **kw):
|
||||
"""Search employees for kiosk identification."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employees = request.env['hr.employee'].sudo().search([
|
||||
@@ -47,6 +54,7 @@ class FusionClockKiosk(http.Controller):
|
||||
'name': emp.name,
|
||||
'department': emp.department_id.name or '',
|
||||
'is_checked_in': emp.attendance_state == 'checked_in',
|
||||
'card_uid': emp.x_fclk_nfc_card_uid or '',
|
||||
} for emp in employees],
|
||||
}
|
||||
|
||||
@@ -54,7 +62,7 @@ class FusionClockKiosk(http.Controller):
|
||||
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
|
||||
"""Verify employee PIN for kiosk mode."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||
@@ -74,7 +82,7 @@ class FusionClockKiosk(http.Controller):
|
||||
def kiosk_clock(self, employee_id=0, latitude=0, longitude=0, **kw):
|
||||
"""Perform clock action from kiosk on behalf of an employee."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||
@@ -93,7 +101,9 @@ class FusionClockKiosk(http.Controller):
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
now = fields.Datetime.now()
|
||||
today = now.date()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = not day_plan.get('scheduled')
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
@@ -120,8 +130,17 @@ class FusionClockKiosk(http.Controller):
|
||||
source='kiosk',
|
||||
)
|
||||
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
if is_scheduled_off:
|
||||
api._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"Kiosk clock-in on an unscheduled day at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source='kiosk',
|
||||
)
|
||||
else:
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
@@ -135,8 +154,9 @@ class FusionClockKiosk(http.Controller):
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
|
||||
api._log_activity(
|
||||
employee, 'clock_out',
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
|
||||
@@ -43,6 +45,12 @@ def _strip_data_url_prefix(b64):
|
||||
return b64.encode('ascii', errors='ignore') if isinstance(b64, str) else b64
|
||||
|
||||
|
||||
def _is_kiosk_operator(user):
|
||||
"""Kiosk surfaces accept a full Clock Manager OR a dedicated Kiosk Operator."""
|
||||
return (user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
or user.has_group('fusion_clock.group_fusion_clock_kiosk_app'))
|
||||
|
||||
|
||||
class FusionClockNfcKiosk(http.Controller):
|
||||
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
|
||||
|
||||
@@ -65,14 +73,14 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
def nfc_kiosk_page(self, **kw):
|
||||
"""Render the NFC kiosk page for a wall-mounted tablet."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return request.redirect('/my')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
|
||||
return request.redirect('/my')
|
||||
|
||||
company = request.env.company
|
||||
company = request.env.company.sudo()
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
company_logo_url = (
|
||||
'/web/image/res.company/%s/logo' % company.id if company.logo else ''
|
||||
@@ -85,9 +93,46 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
'location_configured': bool(location),
|
||||
'photo_required': ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True',
|
||||
'debug_enabled': ICP.get_param('fusion_clock.nfc_kiosk_debug', 'False') == 'True',
|
||||
'sounds_enabled': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True',
|
||||
}
|
||||
return request.render('fusion_clock.nfc_kiosk_page', values)
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/manifest.webmanifest', type='http', auth='public')
|
||||
def nfc_kiosk_manifest(self, **kw):
|
||||
"""Web App Manifest so the NFC kiosk installs as a full-screen home-screen app.
|
||||
|
||||
On a wall tablet, 'Install' (Chrome) / 'Add to Home Screen' (Safari) then
|
||||
launches the kiosk standalone -- no address bar or browser tabs, like Odoo's
|
||||
own PWA. Public so the icon/splash can load without a session.
|
||||
"""
|
||||
company = request.env.company.sudo()
|
||||
# Square icons via Odoo's on-the-fly resizer (placeholder if the company has no logo).
|
||||
icon_192 = '/web/image/res.company/%s/logo/192x192' % company.id
|
||||
icon_512 = '/web/image/res.company/%s/logo/512x512' % company.id
|
||||
manifest = {
|
||||
'name': 'Fusion Clock Kiosk',
|
||||
'short_name': 'Clock Kiosk',
|
||||
'description': 'Tap-to-clock NFC kiosk',
|
||||
'start_url': '/fusion_clock/kiosk/nfc',
|
||||
'scope': '/',
|
||||
'display': 'fullscreen',
|
||||
'display_override': ['fullscreen', 'standalone'],
|
||||
'background_color': '#0e1116',
|
||||
'theme_color': '#0e1116',
|
||||
'orientation': 'any',
|
||||
'icons': [
|
||||
{'src': icon_192, 'sizes': '192x192', 'type': 'image/png'},
|
||||
{'src': icon_512, 'sizes': '512x512', 'type': 'image/png'},
|
||||
],
|
||||
}
|
||||
return request.make_response(
|
||||
json.dumps(manifest),
|
||||
headers=[
|
||||
('Content-Type', 'application/manifest+json; charset=utf-8'),
|
||||
('Cache-Control', 'public, max-age=3600'),
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _check_enroll_password(env, supplied):
|
||||
"""Verify the enroll-mode password. Empty config = always-allow for managers."""
|
||||
@@ -97,10 +142,11 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
return (supplied or '') == configured
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/enroll', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', **kw):
|
||||
"""Bind an NFC card UID to an employee. Manager-gated, password-gated."""
|
||||
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', force=False, **kw):
|
||||
"""Bind an NFC card UID to an employee. Manager-gated, password-gated.
|
||||
With force=True, a card already held by another employee is moved (reassigned)."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
@@ -120,10 +166,12 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
('id', '!=', target.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {
|
||||
'error': 'card_already_assigned',
|
||||
'existing_employee': existing.name,
|
||||
}
|
||||
if not force:
|
||||
return {
|
||||
'error': 'card_already_assigned',
|
||||
'existing_employee': existing.name,
|
||||
}
|
||||
existing.x_fclk_nfc_card_uid = False # reassign: clear the previous holder
|
||||
|
||||
target.x_fclk_nfc_card_uid = normalized
|
||||
|
||||
@@ -137,15 +185,97 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'employee_id': target.id,
|
||||
'employee_name': target.name,
|
||||
'card_uid': normalized,
|
||||
'needs_photo': not target.image_1920,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/create_employee', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_create_employee(self, name='', enroll_password='', **kw):
|
||||
"""Create a minimal hr.employee from the kiosk; the caller then enrolls the card.
|
||||
|
||||
Manager/Kiosk-Operator gated + enroll-password gated. Creates the employee via
|
||||
sudo with just a name, clock enabled, and the current company — HR fills in the
|
||||
rest (department, contract, etc.) later.
|
||||
"""
|
||||
user = request.env.user
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
clean = (name or '').strip()
|
||||
if len(clean) < 2:
|
||||
return {'error': 'invalid_name'}
|
||||
employee = request.env['hr.employee'].sudo().create({
|
||||
'name': clean,
|
||||
'x_fclk_enable_clock': True,
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {'employee_id': employee.id, 'employee_name': employee.name}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/clear_tag', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_clear_tag(self, employee_id=0, enroll_password='', **kw):
|
||||
"""Unbind the NFC card from an employee. Manager/operator + password gated."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
|
||||
if not emp.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
emp.x_fclk_nfc_card_uid = False
|
||||
return {'success': True, 'employee_name': emp.name}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/delete_employee', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_delete_employee(self, employee_id=0, enroll_password='', **kw):
|
||||
"""Archive an employee (active=False) and clear their tag — a safe 'delete' that
|
||||
preserves attendance history. Manager/operator + password gated."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
|
||||
if not emp.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
name = emp.name
|
||||
emp.x_fclk_nfc_card_uid = False
|
||||
emp.active = False
|
||||
return {'success': True, 'employee_name': name}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/save_profile_photo', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_save_profile_photo(self, employee_id=0, photo_b64='', **kw):
|
||||
"""Save a captured photo to the employee's profile image. Operator-gated (the
|
||||
trusted kiosk device); no separate PIN, so it also works on self clock-in."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'access_denied'}
|
||||
photo = _strip_data_url_prefix(photo_b64)
|
||||
if not photo:
|
||||
return {'error': 'no_photo'}
|
||||
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
|
||||
if not emp.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
emp.image_1920 = photo
|
||||
# Also push to the linked user's partner image, which is the image Odoo
|
||||
# shows on the user's profile/preferences avatar (res.users delegates
|
||||
# image_1920 to res.partner). Employees with no user are HR-only photos.
|
||||
if emp.user_id and emp.user_id.partner_id:
|
||||
emp.user_id.partner_id.sudo().write({'image_1920': photo})
|
||||
return {'success': True}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/verify_pin', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_verify_pin(self, pin='', **kw):
|
||||
"""Verify the Manager PIN (enroll password) — used to unlock the kiosk screen.
|
||||
Returns only a boolean so the PIN itself never reaches the client."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'ok': False}
|
||||
return {'ok': self._check_enroll_password(request.env, pin)}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/tap', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_tap(self, card_uid='', photo_b64='', **kw):
|
||||
"""Toggle attendance state for the employee owning this card UID."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
@@ -164,7 +294,7 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
return {'error': 'photo_required', 'message': 'Camera unavailable. Ask IT to check the kiosk.'}
|
||||
photo_bytes = _strip_data_url_prefix(photo_b64) if photo_b64 else b''
|
||||
|
||||
company = request.env.company
|
||||
company = request.env.company.sudo()
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
if not location:
|
||||
return {'error': 'no_location_configured'}
|
||||
@@ -182,8 +312,19 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
api = FusionClockAPI()
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
# Cache-buster: /web/image is browser-cached, so without a unique token a
|
||||
# freshly-saved profile photo never shows. write_date bumps on every
|
||||
# write (incl. saving image_1920), so it refreshes exactly when needed.
|
||||
avatar_unique = employee.write_date.strftime('%Y%m%d%H%M%S') if employee.write_date else ''
|
||||
# PUBLIC model: the kiosk runs as a non-HR operator who can't read
|
||||
# hr.employee images (ACL) — /web/image would serve a placeholder.
|
||||
# hr.employee.public exposes the same avatar to any internal user
|
||||
# (verified readable as the kiosk operator, uid 141).
|
||||
avatar_url = f'/web/image/hr.employee.public/{employee.id}/avatar_128?unique={avatar_unique}'
|
||||
now = fields.Datetime.now()
|
||||
today = now.date()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = not day_plan.get('scheduled')
|
||||
|
||||
geo_info = {
|
||||
'latitude': 0,
|
||||
@@ -208,15 +349,26 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
latitude=0, longitude=0, distance=0,
|
||||
source='nfc_kiosk',
|
||||
)
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
if is_scheduled_off:
|
||||
api._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"NFC kiosk clock-in on an unscheduled day at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0,
|
||||
source='nfc_kiosk',
|
||||
)
|
||||
else:
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
'employee_id': employee.id,
|
||||
'employee_name': employee.name,
|
||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||
'employee_avatar_url': avatar_url,
|
||||
'message': f'{employee.name} clocked in at {location.name}',
|
||||
'net_hours_today': 0.0,
|
||||
'worked_hours': 0.0,
|
||||
'needs_photo': not employee.image_1920,
|
||||
}
|
||||
else:
|
||||
attendance.sudo().write({
|
||||
@@ -224,8 +376,9 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
api._log_activity(
|
||||
employee, 'clock_out',
|
||||
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||
@@ -236,10 +389,15 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_out',
|
||||
'employee_id': employee.id,
|
||||
'employee_name': employee.name,
|
||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||
'employee_avatar_url': avatar_url,
|
||||
'message': f'{employee.name} clocked out',
|
||||
'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2),
|
||||
# GROSS time between clock-in and clock-out (what the employee
|
||||
# expects to see). x_fclk_net_hours subtracts break + early-out
|
||||
# penalty minutes, which zeroed short shifts — that's for payroll.
|
||||
'worked_hours': attendance.worked_hours or 0.0,
|
||||
'needs_photo': not employee.image_1920,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST'])
|
||||
|
||||
@@ -65,6 +65,20 @@ class FusionClockPortal(CustomerPortal):
|
||||
], limit=1)
|
||||
return employee
|
||||
|
||||
def _payroll_available(self):
|
||||
"""True when fusion_payroll (hr.payslip) is installed on this DB."""
|
||||
return 'hr.payslip' in request.env
|
||||
|
||||
def _get_my_payslips(self, employee):
|
||||
"""Finalized payslips for this employee, newest first.
|
||||
|
||||
Caller must ensure payroll is installed (see _payroll_available).
|
||||
"""
|
||||
return request.env['hr.payslip'].sudo().search(
|
||||
[('employee_id', '=', employee.id), ('state', 'in', ('done', 'paid'))],
|
||||
order='date_to desc, id desc',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Clock Page
|
||||
# =========================================================================
|
||||
@@ -100,7 +114,9 @@ class FusionClockPortal(CustomerPortal):
|
||||
], limit=1)
|
||||
|
||||
# Today stats
|
||||
today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee)
|
||||
today = get_local_today(request.env, employee)
|
||||
today_schedule = employee._get_fclk_day_plan(today)
|
||||
today_start, _ = get_local_day_boundaries(request.env, today, employee)
|
||||
today_atts = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', today_start),
|
||||
@@ -109,7 +125,6 @@ class FusionClockPortal(CustomerPortal):
|
||||
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
|
||||
|
||||
# Week stats
|
||||
today = get_local_today(request.env, employee)
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
|
||||
week_atts = request.env['hr.attendance'].sudo().search([
|
||||
@@ -151,10 +166,12 @@ class FusionClockPortal(CustomerPortal):
|
||||
'current_attendance': current_attendance,
|
||||
'today_hours': round(today_hours, 1),
|
||||
'week_hours': round(week_hours, 1),
|
||||
'today_schedule': today_schedule,
|
||||
'recent_attendances': recent,
|
||||
'google_maps_key': google_maps_key,
|
||||
'enable_sounds': enable_sounds,
|
||||
'locations_json': locations_json,
|
||||
'show_payslips': self._payroll_available(),
|
||||
'page_name': 'clock',
|
||||
}
|
||||
return request.render('fusion_clock.portal_clock_page', values)
|
||||
@@ -232,6 +249,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
'total_hours': round(total_hours, 1),
|
||||
'net_hours': round(net_hours, 1),
|
||||
'total_breaks': round(total_breaks, 0),
|
||||
'show_payslips': self._payroll_available(),
|
||||
'page_name': 'timesheets',
|
||||
}
|
||||
return request.render('fusion_clock.portal_timesheet_page', values)
|
||||
@@ -255,6 +273,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
values = {
|
||||
'employee': employee,
|
||||
'reports': reports,
|
||||
'show_payslips': self._payroll_available(),
|
||||
'page_name': 'clock_reports',
|
||||
}
|
||||
return request.render('fusion_clock.portal_report_page', values)
|
||||
@@ -283,3 +302,64 @@ class FusionClockPortal(CustomerPortal):
|
||||
('Content-Disposition', f'attachment; filename="{filename}"'),
|
||||
],
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Payslips
|
||||
# =========================================================================
|
||||
|
||||
@http.route('/my/clock/payslips', type='http', auth='user', website=True)
|
||||
def portal_payslips(self, **kw):
|
||||
"""List the employee's finalized pay slips."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
values = {
|
||||
'employee': employee,
|
||||
'payslips': self._get_my_payslips(employee),
|
||||
'show_payslips': True,
|
||||
'page_name': 'payslips',
|
||||
}
|
||||
return request.render('fusion_clock.portal_payslip_list_page', values)
|
||||
|
||||
@http.route('/my/clock/payslips/<int:payslip_id>', type='http', auth='user', website=True)
|
||||
def portal_payslip_detail(self, payslip_id, **kw):
|
||||
"""Inline paystub for one finalized slip the employee owns."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
|
||||
if not payslip.exists() or payslip.employee_id.id != employee.id \
|
||||
or payslip.state not in ('done', 'paid'):
|
||||
return request.redirect('/my/clock/payslips')
|
||||
pdf_report = request.env['ir.actions.report'].sudo().search(
|
||||
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
|
||||
values = {
|
||||
'employee': employee,
|
||||
'payslip': payslip,
|
||||
'has_pdf': bool(pdf_report),
|
||||
'show_payslips': True,
|
||||
'page_name': 'payslips',
|
||||
}
|
||||
return request.render('fusion_clock.portal_payslip_detail_page', values)
|
||||
|
||||
@http.route('/my/clock/payslips/<int:payslip_id>/pdf', type='http', auth='user', website=True)
|
||||
def portal_payslip_pdf(self, payslip_id, **kw):
|
||||
"""Render the standard payslip PDF (sudo) for a slip the employee owns."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
|
||||
if not payslip.exists() or payslip.employee_id.id != employee.id \
|
||||
or payslip.state not in ('done', 'paid'):
|
||||
return request.redirect('/my/clock/payslips')
|
||||
report = request.env['ir.actions.report'].sudo().search(
|
||||
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
|
||||
if not report:
|
||||
return request.redirect('/my/clock/payslips/%s' % payslip_id)
|
||||
pdf_content, _ctype = report._render_qweb_pdf(report.id, [payslip.id])
|
||||
slip_ref = payslip.number if 'number' in payslip._fields else False
|
||||
filename = 'Payslip-%s.pdf' % (slip_ref or payslip.id)
|
||||
return request.make_response(pdf_content, headers=[
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Disposition', 'attachment; filename="%s"' % filename),
|
||||
])
|
||||
|
||||
304
fusion_clock/controllers/shift_planner.py
Normal file
304
fusion_clock/controllers/shift_planner.py
Normal file
@@ -0,0 +1,304 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import io
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import fields, http, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class FusionClockShiftPlanner(http.Controller):
|
||||
"""Backend JSON-RPC API for the Excel-style weekly shift planner."""
|
||||
|
||||
def _check_manager(self):
|
||||
return request.env.user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
|
||||
def _week_start(self, week_start=None):
|
||||
date_obj = fields.Date.to_date(week_start) if week_start else fields.Date.today()
|
||||
return date_obj - timedelta(days=date_obj.weekday())
|
||||
|
||||
def _manager_employees(self):
|
||||
return request.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
('company_id', 'in', request.env.user.company_ids.ids),
|
||||
], order='department_id, name')
|
||||
|
||||
def _load_week_data(self, week_start=None):
|
||||
start = self._week_start(week_start)
|
||||
days = [start + timedelta(days=i) for i in range(7)]
|
||||
employees = self._manager_employees()
|
||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||
|
||||
schedules = Schedule.search([
|
||||
('employee_id', 'in', employees.ids),
|
||||
('schedule_date', '>=', start),
|
||||
('schedule_date', '<=', days[-1]),
|
||||
])
|
||||
schedule_map = {
|
||||
(schedule.employee_id.id, schedule.schedule_date): schedule
|
||||
for schedule in schedules
|
||||
}
|
||||
|
||||
grouped = defaultdict(list)
|
||||
for employee in employees:
|
||||
grouped[employee.department_id.id or 0].append(employee)
|
||||
|
||||
departments = []
|
||||
employee_rows = []
|
||||
for department_id, department_employees in grouped.items():
|
||||
department = department_employees[0].department_id
|
||||
departments.append({
|
||||
'id': department_id,
|
||||
'name': department.name if department else _('No Department'),
|
||||
'employee_ids': [emp.id for emp in department_employees],
|
||||
})
|
||||
for employee in department_employees:
|
||||
cells = {}
|
||||
for day in days:
|
||||
cells[str(day)] = Schedule.fclk_cell_payload(
|
||||
employee,
|
||||
day,
|
||||
schedule_map.get((employee.id, day)),
|
||||
)
|
||||
employee_rows.append({
|
||||
'id': employee.id,
|
||||
'name': employee.name,
|
||||
'department_id': department_id,
|
||||
'department_name': department.name if department else _('No Department'),
|
||||
'job_title': employee.job_title or '',
|
||||
'cells': cells,
|
||||
})
|
||||
|
||||
shifts = request.env['fusion.clock.shift'].sudo().search([
|
||||
('active', '=', True),
|
||||
('company_id', 'in', request.env.user.company_ids.ids),
|
||||
], order='sequence, name')
|
||||
|
||||
return {
|
||||
'week_start': str(start),
|
||||
'week_end': str(days[-1]),
|
||||
'days': [{
|
||||
'date': str(day),
|
||||
'weekday': day.strftime('%a').upper(),
|
||||
'label': day.strftime('%d-%b'),
|
||||
} for day in days],
|
||||
'departments': departments,
|
||||
'employees': employee_rows,
|
||||
'shifts': [{
|
||||
'id': shift.id,
|
||||
'name': shift.name,
|
||||
'start_time': shift.start_time,
|
||||
'end_time': shift.end_time,
|
||||
'break_minutes': shift.break_minutes,
|
||||
'hours': shift.scheduled_hours,
|
||||
'hours_display': Schedule.fclk_hours_display(shift.scheduled_hours),
|
||||
'label': '%s - %s' % (
|
||||
Schedule.fclk_float_to_display(shift.start_time),
|
||||
Schedule.fclk_float_to_display(shift.end_time),
|
||||
),
|
||||
'option_label': '%s (%s - %s)' % (
|
||||
shift.name,
|
||||
Schedule.fclk_float_to_display(shift.start_time),
|
||||
Schedule.fclk_float_to_display(shift.end_time),
|
||||
),
|
||||
} for shift in shifts],
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/load', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def load(self, week_start=None, **kw):
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
return self._load_week_data(week_start)
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/save', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def save(self, week_start=None, changes=None, **kw):
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employees = self._manager_employees()
|
||||
employee_map = {employee.id: employee for employee in employees}
|
||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||
errors = []
|
||||
saved = 0
|
||||
|
||||
for change in changes or []:
|
||||
employee_id = int(change.get('employee_id') or 0)
|
||||
employee = employee_map.get(employee_id)
|
||||
date_str = change.get('date')
|
||||
if not employee:
|
||||
errors.append({
|
||||
'employee_id': employee_id,
|
||||
'date': date_str,
|
||||
'message': 'Employee not found or not allowed.',
|
||||
})
|
||||
continue
|
||||
try:
|
||||
Schedule.fclk_apply_planner_cell(employee, date_str, change, request.env.user)
|
||||
saved += 1
|
||||
except ValidationError as exc:
|
||||
errors.append({
|
||||
'employee_id': employee_id,
|
||||
'date': date_str,
|
||||
'message': str(exc.args[0] if exc.args else exc),
|
||||
})
|
||||
|
||||
if errors:
|
||||
return {'success': False, 'saved': saved, 'errors': errors}
|
||||
return {
|
||||
'success': True,
|
||||
'saved': saved,
|
||||
'data': self._load_week_data(week_start),
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/post_week', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def post_week(self, week_start=None, **kw):
|
||||
"""Publish (post) the viewed week's draft entries so automation acts on
|
||||
them, and email each newly-affected employee their posted shifts."""
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
start = self._week_start(week_start)
|
||||
end = start + timedelta(days=6)
|
||||
employees = self._manager_employees()
|
||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||
|
||||
entries = Schedule.search([
|
||||
('employee_id', 'in', employees.ids),
|
||||
('schedule_date', '>=', start),
|
||||
('schedule_date', '<=', end),
|
||||
('state', '!=', 'posted'),
|
||||
])
|
||||
posted_count = len(entries)
|
||||
affected = entries.mapped('employee_id')
|
||||
if entries:
|
||||
entries.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
|
||||
|
||||
notified = 0
|
||||
for employee in affected:
|
||||
if Schedule.fclk_email_posted_week(employee, start, end):
|
||||
notified += 1
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'posted': posted_count,
|
||||
'notified': notified,
|
||||
'data': self._load_week_data(start),
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def copy_previous_week(self, week_start=None, **kw):
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
start = self._week_start(week_start)
|
||||
prev_start = start - timedelta(days=7)
|
||||
employees = self._manager_employees()
|
||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||
prev_schedules = Schedule.search([
|
||||
('employee_id', 'in', employees.ids),
|
||||
('schedule_date', '>=', prev_start),
|
||||
('schedule_date', '<=', prev_start + timedelta(days=6)),
|
||||
])
|
||||
prev_map = {
|
||||
(schedule.employee_id.id, schedule.schedule_date): schedule
|
||||
for schedule in prev_schedules
|
||||
}
|
||||
|
||||
before_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
|
||||
for employee in employees:
|
||||
for offset in range(7):
|
||||
source_date = prev_start + timedelta(days=offset)
|
||||
target_date = start + timedelta(days=offset)
|
||||
source = prev_map.get((employee.id, source_date))
|
||||
if not source:
|
||||
payload = {'input': ''}
|
||||
elif source.is_off:
|
||||
payload = {'input': 'OFF'}
|
||||
elif source.shift_id:
|
||||
payload = {'shift_id': source.shift_id.id, 'input': source.fclk_display_value()}
|
||||
else:
|
||||
payload = {
|
||||
'input': source.fclk_display_value(),
|
||||
'start_time': source.start_time,
|
||||
'end_time': source.end_time,
|
||||
'break_minutes': source.break_minutes,
|
||||
}
|
||||
Schedule.fclk_apply_planner_cell(employee, target_date, payload, request.env.user)
|
||||
|
||||
after_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
|
||||
return {
|
||||
'success': True,
|
||||
'changed': after_count - before_count,
|
||||
'data': self._load_week_data(start),
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def export_xlsx(self, week_start=None, **kw):
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
data = self._load_week_data(week_start)
|
||||
output = io.BytesIO()
|
||||
import xlsxwriter
|
||||
|
||||
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
|
||||
sheet = workbook.add_worksheet('Shift Planner')
|
||||
|
||||
fmt_day = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#b7dff5', 'border': 1})
|
||||
fmt_sub = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#d8e9bd', 'border': 1})
|
||||
fmt_employee = workbook.add_format({'bold': True, 'border': 1})
|
||||
fmt_shift = workbook.add_format({'border': 1})
|
||||
fmt_hours = workbook.add_format({'border': 1, 'align': 'center', 'bg_color': '#f5d39b'})
|
||||
fmt_department = workbook.add_format({'bold': True, 'bg_color': '#eeeeee', 'border': 1})
|
||||
|
||||
sheet.set_column(0, 0, 22)
|
||||
for col in range(1, 15, 2):
|
||||
sheet.set_column(col, col, 24)
|
||||
sheet.set_column(col + 1, col + 1, 9)
|
||||
|
||||
sheet.write(0, 0, 'EMPLOYEE', fmt_day)
|
||||
col = 1
|
||||
for day in data['days']:
|
||||
sheet.merge_range(0, col, 0, col + 1, day['weekday'], fmt_day)
|
||||
sheet.merge_range(1, col, 1, col + 1, day['label'], fmt_day)
|
||||
sheet.write(2, col, 'Shift', fmt_sub)
|
||||
sheet.write(2, col + 1, 'Hours', fmt_sub)
|
||||
col += 2
|
||||
sheet.write(2, 0, 'EMPLOYEE', fmt_sub)
|
||||
|
||||
row = 3
|
||||
employee_by_id = {emp['id']: emp for emp in data['employees']}
|
||||
for department in data['departments']:
|
||||
sheet.merge_range(row, 0, row, 14, department['name'], fmt_department)
|
||||
row += 1
|
||||
for employee_id in department['employee_ids']:
|
||||
employee = employee_by_id[employee_id]
|
||||
sheet.write(row, 0, employee['name'], fmt_employee)
|
||||
col = 1
|
||||
for day in data['days']:
|
||||
cell = employee['cells'][day['date']]
|
||||
sheet.write(row, col, cell.get('label') or '', fmt_shift)
|
||||
sheet.write(row, col + 1, cell.get('hours_display') or '0:00', fmt_hours)
|
||||
col += 2
|
||||
row += 1
|
||||
|
||||
workbook.close()
|
||||
output.seek(0)
|
||||
filename = 'shift_planner_%s.xlsx' % data['week_start']
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': filename,
|
||||
'type': 'binary',
|
||||
'datas': base64.b64encode(output.read()),
|
||||
'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
})
|
||||
return {
|
||||
'success': True,
|
||||
'attachment_id': attachment.id,
|
||||
'filename': filename,
|
||||
'url': '/web/content/%s?download=true' % attachment.id,
|
||||
}
|
||||
@@ -61,4 +61,16 @@
|
||||
<field name="priority">80</field>
|
||||
</record>
|
||||
|
||||
<!-- Photo Wipe Cron: runs daily, deletes clock photos past the retention window -->
|
||||
<record id="cron_wipe_old_photos" model="ir.cron">
|
||||
<field name="name">Fusion Clock: Wipe Old Clock Photos</field>
|
||||
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_fusion_wipe_old_photos()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">65</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# NFC Kiosk — Enrollment UX / PIN fix / Speed / Clock-out Hours — Implementation Plan
|
||||
|
||||
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax. Executed inline this session.
|
||||
|
||||
**Goal:** Make NFC-tag enrollment programmable from an unknown tap (with create-new-employee), fix the per-digit PIN re-render, speed up clock-in/out for lines, and clearly show shift hours on clock-out.
|
||||
|
||||
**Architecture:** Extend the existing IIFE kiosk state machine (`fusion_clock_nfc_kiosk.js`) — no Interaction migration. Add one sudo controller endpoint for kiosk employee-create. SCSS-only changes for animation timing. Spec: `docs/superpowers/specs/2026-05-30-nfc-kiosk-enroll-speed-design.md`.
|
||||
|
||||
**Tech Stack:** Odoo 19 HTTP controller (jsonrpc), vanilla JS IIFE, SCSS. Verify: `pyflakes`, `xmllint`, manifest `ast.literal_eval`, on-device deploy on entech (LXC 111 / pve-worker5).
|
||||
|
||||
**XSS note:** the kiosk uses `innerHTML`; every dynamic value (employee names, the typed new-employee name, errors) MUST go through the existing `escapeHtml()`. The new-employee name is user input — escape it everywhere it renders.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — `nfc_create_employee` endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `controllers/clock_nfc_kiosk.py` (add route after `nfc_enroll`)
|
||||
- Test: `tests/test_clock_nfc_kiosk.py` (add a method)
|
||||
|
||||
- [ ] **Step 1: Add the endpoint.** Manager/Kiosk-Operator gated (`_is_kiosk_operator`) + password gated (`_check_enroll_password`). Create `hr.employee` via sudo with name + `x_fclk_enable_clock=True` + `company_id`. Return `{employee_id, employee_name}` or `{error}`.
|
||||
|
||||
```python
|
||||
@http.route('/fusion_clock/kiosk/nfc/create_employee', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_create_employee(self, name='', enroll_password='', **kw):
|
||||
"""Create a minimal hr.employee from the kiosk (manager+password gated)."""
|
||||
user = request.env.user
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
clean = (name or '').strip()
|
||||
if len(clean) < 2:
|
||||
return {'error': 'invalid_name'}
|
||||
employee = request.env['hr.employee'].sudo().create({
|
||||
'name': clean,
|
||||
'x_fclk_enable_clock': True,
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {'employee_id': employee.id, 'employee_name': employee.name}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a unit test** (runs when a test env is available; mirrors existing tests in the file).
|
||||
|
||||
```python
|
||||
def test_nfc_create_employee_creates_clock_enabled(self):
|
||||
Ctrl = self._controller() # follow existing pattern in this file for instantiating
|
||||
# password gate: wrong password rejected
|
||||
bad = Ctrl.nfc_create_employee(name='Test Person', enroll_password='wrong')
|
||||
self.assertEqual(bad.get('error'), 'invalid_password')
|
||||
# happy path (set the configured password in the test env first)
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_clock.nfc_enroll_password', '1120')
|
||||
res = Ctrl.nfc_create_employee(name='Test Person', enroll_password='1120')
|
||||
emp = self.env['hr.employee'].browse(res['employee_id'])
|
||||
self.assertTrue(emp.exists())
|
||||
self.assertTrue(emp.x_fclk_enable_clock)
|
||||
```
|
||||
> If the existing test file doesn't instantiate controllers directly, adapt to its harness (or assert via model behaviour). Keep parity with existing tests.
|
||||
|
||||
- [ ] **Step 3: Verify.** `docker exec ... pyflakes controllers/clock_nfc_kiosk.py` (locally: `python3 -m pyflakes`). Expected: clean. Unit test runs in the next test invocation / on a Community dev box.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: JS — reusable fixed PIN-pad component (fixes per-digit re-render)
|
||||
|
||||
**Files:** Modify `static/src/js/fusion_clock_nfc_kiosk.js`
|
||||
|
||||
- [ ] **Step 1:** Add a `mountPinPad({title, onOk, onCancel})` helper that sets `stateContainer.innerHTML` **once** (title, `.pin-display`, numpad, cancel), keeps a local `let pin = ""`, and on digit/back/ok updates **only** `displayEl.textContent = "•".repeat(pin.length)` — never re-renders the panel. `ok` calls `onOk(pin)`; cancel calls `onCancel()`. Resets the enroll idle timer on each press.
|
||||
- [ ] **Step 2:** Rewrite `renderEnroll(phase:"password")` to call `mountPinPad({title:"Enter Manager PIN", onOk:(pin)=>{enrollPassword=pin; renderEnroll({phase:"search"});}, onCancel:exitEnrollMode})`. Remove the old per-digit `renderEnroll(...)` rebuild.
|
||||
- [ ] **Step 3: Verify.** Manual on device: digits append with no flicker/screen refresh; backspace works; OK advances.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: JS+SCSS — program-a-tag from an unknown tap (with create-new-employee)
|
||||
|
||||
**Files:** Modify `fusion_clock_nfc_kiosk.js`, `static/src/scss/nfc_kiosk.scss`
|
||||
|
||||
- [ ] **Step 1:** Add module var `let pendingEnrollUid = null;`. In `handleTap`, when `result.error === "card_unknown"`, call `renderUnknownCard(uid)` instead of the generic error result.
|
||||
- [ ] **Step 2:** `renderUnknownCard(uid)` renders an **amber** panel: "This card isn't programmed yet" + buttons "Program this card" / "Cancel". Auto-cancel to IDLE after 8s. "Program this card" → `pendingEnrollUid = uid; enrollPassword=""; setState(STATE.ENROLL,{phase:"program_pin"})`.
|
||||
- [ ] **Step 3:** Add enroll phases:
|
||||
- `program_pin` → `mountPinPad({title:"Manager PIN", onOk:(pin)=>{enrollPassword=pin; renderEnroll({phase:"employee"});}, onCancel:exitEnrollMode})`.
|
||||
- `employee` → search box (reuse existing `employee_search` debounced fetch) + a **"+ New employee"** button. Picking an existing row → `assignPendingCard(emp)`. "+ New employee" → `renderEnroll({phase:"new_employee"})`.
|
||||
- `new_employee` → a name input + "Create & assign" / back. On submit → POST `create_employee` {name, enroll_password}; on success → `assignPendingCard({id, name})`; on error → inline message (escape).
|
||||
- [ ] **Step 4:** `assignPendingCard(emp)`: POST `nfc/enroll` {employee_id: emp.id, card_uid: pendingEnrollUid, enroll_password}. Render enroll `result` phase (reuse existing). On done/another → reset `pendingEnrollUid`, back to IDLE.
|
||||
- [ ] **Step 5:** SCSS — add `.nfc-kiosk__result--warn` (amber: `#e0a83e`-ish border/glow) and a `.employee-create` styling block (reuse `.nfc-kiosk__enroll-panel` patterns). Escape all dynamic strings.
|
||||
- [ ] **Step 6: Verify.** `xmllint`/sass compile via deploy; device: unknown tap → program existing + new employee, card binds with no re-tap.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Speed — "Fast" timers + animation durations
|
||||
|
||||
**Files:** Modify `fusion_clock_nfc_kiosk.js`, `static/src/scss/nfc_kiosk.scss`
|
||||
|
||||
- [ ] **Step 1 (JS):** In `renderResult`: success `setTimeout(... , 3000)` → `1800`; error `4000` → `3000`.
|
||||
- [ ] **Step 2 (SCSS):** `nfc-state-in` 400ms→200ms (the `#nfc_state_container > *` rule + keyframe usages); `.nfc-kiosk__result--success` `nfc-success-burst` 700ms→350ms; `.nfc-kiosk__avatar` `nfc-avatar-in` 600ms→300ms. Leave idle wave/chip + mesh drift unchanged. Keep `prefers-reduced-motion` block.
|
||||
- [ ] **Step 3: Verify.** Device: noticeably snappier; result clears ~1.8s.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Clock-out shift hours — prominent + correct label
|
||||
|
||||
**Files:** Modify `fusion_clock_nfc_kiosk.js`, `static/src/scss/nfc_kiosk.scss`
|
||||
|
||||
- [ ] **Step 1 (JS):** In `renderResult` success branch, for `action === "clock_out"`: compute `const mins = Math.round((payload.net_hours_today || 0) * 60); const h = Math.floor(mins/60); const m = mins%60;` and always render `<div class="hours">Worked ${h}h ${m}m this shift</div>` (show even at 0). Clock-in: no hours line.
|
||||
- [ ] **Step 2 (SCSS):** Bump `.nfc-kiosk__result-text .hours` prominence (e.g. `font-size: 1.35rem; opacity: 0.9; margin-top: 0.6rem;`).
|
||||
- [ ] **Step 3: Verify.** Device: clock-out shows "Worked Xh Ym this shift".
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Version bump + deploy + verify
|
||||
|
||||
- [ ] **Step 1:** Bump `__manifest__.py` `version` `19.0.3.6.0` → `19.0.3.7.0` (assets changed).
|
||||
- [ ] **Step 2:** Local pre-flight: `pyflakes` controller, `xmllint`? (JS has no linter here — read carefully), manifest `ast.literal_eval`.
|
||||
- [ ] **Step 3:** Deploy to entech (backup → push 4 files → `-u fusion_clock` stop/upgrade/start). Bump asset cache (version bump handles it; `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` + restart if the bundle doesn't refresh).
|
||||
- [ ] **Step 4:** Verify: service active, version 19.0.3.7.0, manifest route 200. On tablet (hard refresh): PIN no flicker; unknown tap → program (existing + new); faster; clock-out hours.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
- **Spec coverage:** PIN fix (T2), unknown-tap+create-new (T1,T3), speed (T4), clock-out hours (T5), deploy (T6). All covered.
|
||||
- **Placeholders:** none (test harness instantiation noted as adapt-to-existing — acceptable, file-specific).
|
||||
- **Consistency:** `pendingEnrollUid`, `enrollPassword`, `mountPinPad`, `assignPendingCard`, `_is_kiosk_operator`, `_check_enroll_password`, `net_hours_today` used consistently with the existing code read.
|
||||
1142
fusion_clock/docs/superpowers/plans/2026-05-31-dashboard-redesign.md
Normal file
1142
fusion_clock/docs/superpowers/plans/2026-05-31-dashboard-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
# NFC Kiosk — Enrollment UX, PIN fix, Speed, Clock-out Hours
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Module:** `fusion_clock` (NFC tap kiosk at `/fusion_clock/kiosk/nfc`)
|
||||
**Status:** Approved design, ready for implementation plan.
|
||||
|
||||
## Context
|
||||
|
||||
The NFC kiosk (`static/src/js/fusion_clock_nfc_kiosk.js`, an IIFE state machine) handles
|
||||
tap-to-clock on a wall tablet at the entech client. Four issues to address, all driven by
|
||||
real shop-floor use (lines of 10–20 people).
|
||||
|
||||
**Implementation approach:** extend the existing IIFE in place. A migration to an Odoo 19
|
||||
`Interaction` (per repo CLAUDE.md guidance) is deliberately out of scope — the file is a
|
||||
large, working state machine on a live client device and the four changes here are
|
||||
surgical; a rewrite would be high-risk for no functional gain. Noted deviation.
|
||||
|
||||
## Requirements & Design
|
||||
|
||||
### 1. PIN entry: stop the per-digit full re-render
|
||||
**Problem:** in `renderEnroll(phase:"password")`, every numpad press calls
|
||||
`renderEnroll(...)` which rebuilds the whole panel via `stateContainer.innerHTML = ...` and
|
||||
replays the 400ms `nfc-state-in` entrance animation → the screen visibly "refreshes" on each
|
||||
digit (entry is preserved, but it flickers).
|
||||
**Design:** a reusable PIN-pad component that renders the panel **once**, then on
|
||||
digit/backspace mutates only the masked `.pin-display` text node + an in-memory buffer.
|
||||
No `innerHTML` rebuild, no re-animation. Used by both the ⚙ enroll PIN and the new
|
||||
Manager-PIN step (§2). OK/Cancel callbacks are parameters.
|
||||
|
||||
### 2. Program a tag from an unknown tap
|
||||
**Problem:** an unknown card tap returns `{error:"card_unknown"}` and shows a red error that
|
||||
auto-dismisses. Programming requires the separate ⚙ flow (enter password → search → **re-tap**).
|
||||
**Design:** the tapped UID is already captured, so program *that* card with no re-tap:
|
||||
1. Unknown tap → **amber** "This card isn't programmed yet" panel with **"Program this card"**
|
||||
and **"Cancel"** buttons. Auto-cancel to idle after ~8s of inactivity.
|
||||
2. **"Program this card"** → **Manager PIN** step (reuses §1 component; credential =
|
||||
`fusion_clock.nfc_enroll_password`, currently `1120`; labelled "Manager PIN" in UI).
|
||||
3. **Employee step**: search-and-pick an existing employee **or** "+ New employee" →
|
||||
enter a name → create a minimal `hr.employee`.
|
||||
4. **Assign**: bind the captured UID to that employee → success confirmation.
|
||||
- The ⚙ enroll mode stays as a proactive path, reusing the same fixed PIN component.
|
||||
|
||||
**Backend:**
|
||||
- Reuse `POST /fusion_clock/kiosk/nfc/enroll` (`employee_id`, `card_uid`, `enroll_password`)
|
||||
for the bind. Already manager/Kiosk-Operator + password gated, sudo data ops.
|
||||
- **New endpoint** `POST /fusion_clock/kiosk/nfc/create_employee` (`name`, `enroll_password`):
|
||||
Kiosk-Operator-gated + password-gated; creates `hr.employee` via **sudo** with
|
||||
`name`, `x_fclk_enable_clock=True`, `company_id = request.env.company.id`; returns
|
||||
`{employee_id, employee_name}` (or `{error}`). JS then calls `enroll` with the captured UID.
|
||||
Minimal fields only — department/contract/etc. are completed later in HR.
|
||||
|
||||
### 3. Faster clock-in/out ("Fast")
|
||||
**Problem:** result card lingers 3s (errors 4s) and entrance animations are 0.4–0.7s →
|
||||
slow throughput for long lines.
|
||||
**Design (JS timers):** success result display **3000 → 1800 ms**; error **4000 → 3000 ms**.
|
||||
**Design (SCSS durations):** `nfc-state-in` 400→200ms; `nfc-success-burst` 700→350ms;
|
||||
`nfc-avatar-in` 600→300ms. Ambient idle wave/chip loop unchanged (does not gate throughput).
|
||||
`prefers-reduced-motion` fallback preserved.
|
||||
|
||||
### 4. Clock-out shows shift hours, clearly
|
||||
**Problem:** clock-out shows `${net_hours_today.toFixed(1)}h today` — mislabelled "today",
|
||||
small, and hidden when it rounds to 0.
|
||||
**Design:** on clock-out always show a prominent **"Worked Xh Ym this shift"** computed from
|
||||
`net_hours_today` (the just-closed attendance's net hours = worked − break). Render h+m;
|
||||
show even when 0 (e.g. "Worked 0h 4m this shift"). Backend already returns the value; this is
|
||||
a JS label/format + SCSS prominence change. Clock-in unchanged.
|
||||
|
||||
## Files
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js` — PIN component; unknown-tap → program flow;
|
||||
create-employee call; result timers; clock-out hours formatting.
|
||||
- `static/src/scss/nfc_kiosk.scss` — animation durations; amber "unknown card" panel +
|
||||
create-employee styles; prominent clock-out hours.
|
||||
- `controllers/clock_nfc_kiosk.py` — new `nfc_create_employee` endpoint.
|
||||
- `__manifest__.py` — version bump (assets changed).
|
||||
|
||||
## Out of scope / non-goals
|
||||
- No migration of the kiosk JS to an `Interaction`.
|
||||
- No new employee fields beyond name/clock-enabled/company at kiosk-create time.
|
||||
- Classic PIN kiosk (`/fusion_clock/kiosk`) untouched (disabled at entech).
|
||||
|
||||
## Test / verify
|
||||
- Local: `pyflakes` the controller; `xmllint`/manifest parse; review the JS by hand
|
||||
(no local Odoo container available this session).
|
||||
- entech: deploy, upgrade, then on the tablet — PIN entry no longer flickers; unknown tap →
|
||||
program (existing + new employee) binds without re-tap; clock-in/out visibly faster;
|
||||
clock-out shows "Worked Xh Ym this shift".
|
||||
@@ -0,0 +1,152 @@
|
||||
# Fusion Clock — Dashboard Redesign (Layered, Role-Aware) Design
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The current backend dashboard (`fusion_clock.Dashboard` client action) is **manager/team-lead only** and shows nothing but org/team aggregate counts. A regular employee who opens it gets `Access denied.` It is plain Bootstrap (4 flat summary cards + a roster table + an alerts column), uses a runtime `.o_dark_mode` selector for dark mode (against the repo's compile-time rule), and surfaces none of the per-person information an employee actually wants (their hours, shift, streak, leaves).
|
||||
|
||||
We want one modern dashboard that:
|
||||
- Works for **every** role, showing **only** what that role is permitted to see.
|
||||
- Leads with vibrant gradient KPI cards (Style A, chosen during brainstorming).
|
||||
- Supports **both light and dark** mode correctly (compile-time, per repo rule).
|
||||
- Puts "the most information at fingertips" without leaking other employees' data.
|
||||
|
||||
## 2. Permission Model (the core requirement)
|
||||
|
||||
Three existing groups, already in an implied chain (`security/security.xml`):
|
||||
|
||||
```
|
||||
group_fusion_clock_user ← group_fusion_clock_team_lead ← group_fusion_clock_manager
|
||||
```
|
||||
|
||||
The dashboard renders **bands**, gated by role. The hard rule: **a regular employee's payload contains only their own data — the server never sends another employee's data to a non-lead/non-manager.**
|
||||
|
||||
| Band | Employee | Team lead | Manager |
|
||||
|---|---|---|---|
|
||||
| Header (greeting, date, own clock status) | ✅ own | ✅ own | ✅ own |
|
||||
| Personal KPIs — Today, This Week, OT (week), On-time Streak | ✅ own | ✅ own | ✅ own |
|
||||
| Today's Shift (scheduled window, status, source) | ✅ own | ✅ own | ✅ own |
|
||||
| My Recent Activity / My Leave & Penalties | ✅ own | ✅ own | ✅ own |
|
||||
| **— employee view ends here —** | | | |
|
||||
| Team KPIs — Present / Absent / Late / Pending | ❌ | ✅ direct reports | ✅ org-wide |
|
||||
| Currently Clocked In roster | ❌ | ✅ direct reports | ✅ everyone |
|
||||
| Needs Attention (genuine absences, pending reasons, pending corrections) | ❌ | ✅ their team | ✅ org-wide |
|
||||
| Quick Actions | own (clock/leave/correction/timesheets) | + team views | + Reports / Settings |
|
||||
|
||||
**Scoping rule (server-side, never client-trusted):**
|
||||
- `manager` → `emp_ids = all employees where x_fclk_enable_clock = True`.
|
||||
- `team_lead` → `emp_ids = employees where parent_id == current user's employee` (their direct reports). Their own personal band is computed from their own employee record.
|
||||
- `employee` → `emp_ids = [own employee]`; the **team band is omitted entirely** (`team: null`).
|
||||
|
||||
**Approvals decision:** team leads **see** their team's pending corrections/leaves (counts + an alert row that links to a filtered list) but the **approve action stays manager-gated** by the existing ACL/record rules. Managers see org-wide and can approve. The dashboard adds no new approval capability; it only surfaces and links.
|
||||
|
||||
## 3. Look & Feel (decided in brainstorming)
|
||||
|
||||
- **Card style A — Vibrant full-gradient:** each KPI is its own bold `linear-gradient(135deg, …)` card with white text and a translucent icon chip. Same gradients in light and dark (white-on-gradient reads in both).
|
||||
- **Layout A — Stacked sections:** single column, top-to-bottom: Header → Personal KPI row → Personal detail (2 cards) → `Team / Org` divider → Team KPI row → roster + Needs Attention (2 cards) → Quick Actions. Degrades gracefully: a regular employee simply has nothing rendered below the divider.
|
||||
- **Responsive:** KPI rows are a CSS grid that collapses 4→2→1 columns; the two-up detail rows collapse to one column on narrow screens. Mobile/tablet-first since this is the same view everyone opens.
|
||||
|
||||
### Dark / light (compile-time, per repo rule)
|
||||
|
||||
Branch on `$o-webclient-color-scheme` at SCSS compile time — **no** `.o_dark_mode` / `[data-bs-theme]` / `prefers-color-scheme`. The existing runtime `.o_dark_mode` block for `.fclk-dash-card` is removed.
|
||||
|
||||
- **Gradient KPI cards:** identical hex in both bundles (white text).
|
||||
- **Page background, section cards, borders, body/heading text, muted text:** light vs dark hex chosen via `@if $o-webclient-color-scheme == dark { … !global }`, exposed through CSS custom properties (e.g. `--fclk-dash-page`, `--fclk-dash-card`, `--fclk-dash-border`, `--fclk-dash-text`, `--fclk-dash-muted`) following the repo `_tokens` pattern. Three-layer contrast: page (grayest) → section card → KPI card (brightest).
|
||||
|
||||
## 4. Data Contract
|
||||
|
||||
Single endpoint, reworked: **`POST /fusion_clock/dashboard_data`** (`type='jsonrpc'`, `auth='user'`). Gate changes from manager/lead-only to **any** `group_fusion_clock_user`. Response:
|
||||
|
||||
```python
|
||||
{
|
||||
"role": "employee" | "team_lead" | "manager",
|
||||
"personal": {
|
||||
"employee_name": str,
|
||||
"enable_clock": bool,
|
||||
"is_checked_in": bool,
|
||||
"check_in": str | False, # ISO, when checked in
|
||||
"location_name": str,
|
||||
"pending_reason": bool, # owes an auto-clock-out explanation
|
||||
"today_hours": float, # sum x_fclk_net_hours today
|
||||
"week_hours": float, # sum x_fclk_net_hours this week
|
||||
"overtime_week": float, # employee.x_fclk_overtime_this_week
|
||||
"ontime_streak": int, # employee.x_fclk_ontime_streak
|
||||
"shift": { # from employee._get_fclk_day_plan(local_today)
|
||||
"label": str, # "7:00 AM – 3:30 PM" or ""
|
||||
"hours": float,
|
||||
"source": "schedule"|"shift"|"none",
|
||||
"scheduled_off": bool,
|
||||
"status_note": str # "On time", "Late", "Not scheduled today", "Clock disabled"
|
||||
},
|
||||
"recent_activity": [ # last ~6 closed attendances
|
||||
{"check_in": str, "check_out": str, "worked_hours": float,
|
||||
"overtime_hours": float, "location": str}
|
||||
],
|
||||
"leaves": [ # own, leave_date >= today, soonest first, ~5
|
||||
{"label": str, "state": str} # label via _fclk_date_label()
|
||||
],
|
||||
"penalties": [ # own, current month, recent first, ~5
|
||||
{"type": str, "date": str, "minutes": float}
|
||||
]
|
||||
},
|
||||
"team": null | { # present ONLY for team_lead / manager
|
||||
"scope": "team" | "org",
|
||||
"total_employees": int,
|
||||
"present_count": int, # distinct employees with an attendance today
|
||||
"on_leave_count": int, # approved leave covering today (leave_date <= today <= date_to)
|
||||
"absent_count": int, # genuine no-shows = total - present - on_leave (matches absence cron)
|
||||
"late_count": int, # late_clock_in logs today, scoped
|
||||
"pending_reasons": int, # scoped (owe an auto-clock-out explanation)
|
||||
"pending_approvals": int, # scoped: fusion.clock.correction state='pending'
|
||||
# (leaves are auto-approved — nothing to approve)
|
||||
"clocked_in": [
|
||||
{"employee": str, "check_in": str, "location": str, "late": bool}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation note:** factor two private helpers on the controller — `_dashboard_personal(employee)` (builds the `personal` block above; reuses the same per-employee computations the existing `get_status` already performs for today_hours / week_hours / streak / shift / recent_activity) and `_dashboard_team(emp_ids, scope)` (extracted from the existing `dashboard_data` aggregate logic). `get_status` keeps its **current public response keys unchanged** (the portal `/my/clock` consumes them) — share computation via a small internal helper if convenient, but do not alter `get_status`'s output contract. The public endpoint resolves role → builds `personal` always → builds `team` only for lead/manager. Team/org reads use `sudo()` but are constrained to the server-computed `emp_ids`; personal reads use the caller's own employee. No client input selects scope.
|
||||
|
||||
## 5. Files Touched
|
||||
|
||||
- `controllers/clock_api.py` — rework `dashboard_data`; add `_dashboard_personal` + `_dashboard_team`; `get_status` refactored to reuse `_dashboard_personal` (no behavioural change to the portal).
|
||||
- `static/src/js/fusion_clock_dashboard.js` — state holds `role` / `personal` / `team`; conditional render; action handlers: `onOpenClock` (act_url `/my/clock`), `onRequestLeave`/`onRequestCorrection` (act_url to portal), `onViewTimesheets`, plus existing `onViewAttendances`/`onViewCorrections`/`onViewActivityLogs`/`onViewPenalties`, and manager-only `onViewReports`/`onViewShiftPlanner`. Header is **status display + "Open My Clock"** button — clocking itself stays in the existing systray widget / portal (we do not re-implement the clock flow here).
|
||||
- `static/src/xml/fusion_clock_dashboard.xml` — full rewrite to the stacked layout with `t-if="state.team"` gating the team band.
|
||||
- `static/src/scss/fusion_clock.scss` — replace the `.fclk-dash-card*` block with gradient KPI cards + stacked layout + section-card tokens; add compile-time dark branching; delete the runtime `.o_dark_mode` dash block.
|
||||
- `views/clock_menus.xml` — Dashboard `menuitem` groups: `group_fusion_clock_manager,group_fusion_clock_team_lead` → **`group_fusion_clock_user`**.
|
||||
- `__manifest__.py` — version bump (3.13.2 → 3.14.0) to rebuild asset bundles.
|
||||
- `tests/test_dashboard.py` — **new**, permission-focused.
|
||||
|
||||
## 6. Error Handling & Edge Cases
|
||||
|
||||
- **No employee record** for the user → `{"error": "No employee profile is linked to your account."}`; client shows a friendly empty state (not a raw error).
|
||||
- **`x_fclk_enable_clock = False`** → dashboard still renders; shift card `status_note = "Clock disabled"`, KPIs show 0/own values; no team band unless lead/manager.
|
||||
- **Not scheduled / day off today** → shift card shows "Not scheduled today" (ties into the already-shipped schedule-driven resolver `_get_fclk_day_plan`). This is also why we never nag — consistent with the schedule-driven attendance work.
|
||||
- **Team lead with no direct reports** → `team` present, roster empty, counts 0, friendly "No direct reports yet."
|
||||
- **Manager with employees but none clocked in** → roster empty state "No one is clocked in right now."
|
||||
|
||||
## 7. Testing
|
||||
|
||||
`tests/test_dashboard.py`, tagged `@tagged('-at_install','post_install','fusion_clock')`. Create a manager, a team lead, two direct reports of that lead, and one unrelated employee; give each enabled clock + an attendance.
|
||||
|
||||
- **Employee payload** → `role == 'employee'`, `team is None`, `personal.employee_name` is their own, and the payload contains **no** other employee's name (assert the unrelated employee's name is absent anywhere in the JSON).
|
||||
- **Team lead payload** → `role == 'team_lead'`, `team.scope == 'team'`, roster/counts include **only** the two direct reports, exclude the unrelated employee and the manager.
|
||||
- **Manager payload** → `role == 'manager'`, `team.scope == 'org'`, counts cover all enabled employees.
|
||||
- **Personal stats** → today_hours / week_hours / streak / shift label reflect the caller's own records.
|
||||
- **No-employee user** → returns the `error` key, not a traceback.
|
||||
|
||||
Run: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0`.
|
||||
|
||||
## 8. Out of Scope (YAGNI)
|
||||
|
||||
Charts/trend graphs, date-range pickers, CSV export from the dashboard, websocket/live auto-refresh (the manual Refresh button stays), user-configurable card order/favourites, and any new approval workflow (leads still can't approve from here). These can be added later if asked.
|
||||
|
||||
## 9. Deployment
|
||||
|
||||
Standard entech path after local test: bump version (done in §5), `git commit --only -- <explicit dashboard paths>` (shared working tree), push to **both** `origin` and `gitea`, then upgrade entech (`pct exec 111` native `odoo.service`, DB `admin`, `--http-port=0 --gevent-port=0`). Asset bundle rebuilds on version bump; hard-refresh / clear iOS website data to bust cache.
|
||||
24
fusion_clock/migrations/19.0.3.12.1/post-migrate.py
Normal file
24
fusion_clock/migrations/19.0.3.12.1/post-migrate.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Backfill schedule state on upgrade to 19.0.3.12.0.
|
||||
|
||||
Before this version there was no draft/posted concept — every dated
|
||||
``fusion.clock.schedule`` entry was authoritative and drove reminders, absence
|
||||
checks and penalties. The new ``state`` field defaults to 'draft', and the
|
||||
schedule resolver now only acts on POSTED entries. Without this backfill, every
|
||||
pre-existing schedule entry would silently become draft on upgrade and stop
|
||||
driving automation. Mark all pre-existing entries 'posted' to preserve prior
|
||||
behaviour. (Runs only on upgrade, never on a fresh install.)
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return
|
||||
cr.execute("""
|
||||
UPDATE fusion_clock_schedule
|
||||
SET state = 'posted',
|
||||
posted_date = COALESCE(posted_date, now())
|
||||
WHERE state IS NULL OR state = 'draft'
|
||||
""")
|
||||
17
fusion_clock/migrations/19.0.3.13.0/post-migrate.py
Normal file
17
fusion_clock/migrations/19.0.3.13.0/post-migrate.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Backfill leave-request end dates on upgrade to 19.0.3.13.0.
|
||||
|
||||
Leave requests gained a `date_to` (end of a multi-day range). Existing
|
||||
single-day requests have no end date; set it to the start date so they keep
|
||||
being treated as one-day leaves by the absence check and reports.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return
|
||||
cr.execute(
|
||||
"UPDATE fusion_clock_leave_request SET date_to = leave_date WHERE date_to IS NULL"
|
||||
)
|
||||
@@ -9,5 +9,6 @@ from . import res_config_settings
|
||||
from . import clock_activity_log
|
||||
from . import clock_leave_request
|
||||
from . import clock_shift
|
||||
from . import clock_schedule
|
||||
from . import clock_correction
|
||||
from . import res_company
|
||||
|
||||
BIN
fusion_clock/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_correction.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_location.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_location.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_penalty.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_schedule.cpython-312.pyc
Normal file
Binary file not shown.
BIN
fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/clock_shift.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_clock/models/__pycache__/res_company.cpython-312.pyc
Normal file
BIN
fusion_clock/models/__pycache__/res_company.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -34,6 +34,7 @@ class FusionClockActivityLog(models.Model):
|
||||
('correction_request', 'Correction Request'),
|
||||
('ip_fallback', 'IP Fallback Used'),
|
||||
('streak_milestone', 'Streak Milestone'),
|
||||
('unscheduled_shift', 'Unscheduled Shift'),
|
||||
('card_enrollment', 'Card Enrollment'),
|
||||
('unknown_card_tap', 'Unknown Card Tap'),
|
||||
],
|
||||
@@ -108,6 +109,7 @@ class FusionClockActivityLog(models.Model):
|
||||
'correction_request': 'Correction Request',
|
||||
'ip_fallback': 'IP Fallback Used',
|
||||
'streak_milestone': 'Streak Milestone',
|
||||
'unscheduled_shift': 'Unscheduled Shift',
|
||||
}
|
||||
|
||||
@api.depends('latitude', 'longitude')
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
from odoo import models, fields, api
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -23,10 +24,16 @@ class FusionClockLeaveRequest(models.Model):
|
||||
ondelete='cascade',
|
||||
)
|
||||
leave_date = fields.Date(
|
||||
string='Leave Date',
|
||||
string='From Date',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
date_to = fields.Date(
|
||||
string='To Date',
|
||||
index=True,
|
||||
help="Last day of the leave (inclusive); equals the start date for a "
|
||||
"single-day request.",
|
||||
)
|
||||
reason = fields.Text(
|
||||
string='Reason',
|
||||
required=True,
|
||||
@@ -59,15 +66,32 @@ class FusionClockLeaveRequest(models.Model):
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'leave_date')
|
||||
@api.depends('employee_id', 'leave_date', 'date_to')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
date_str = str(rec.leave_date) if rec.leave_date else ''
|
||||
rec.display_name = f"{emp} - Leave ({date_str})"
|
||||
rec.display_name = f"{emp} - Leave ({rec._fclk_date_label()})"
|
||||
|
||||
def _fclk_date_label(self):
|
||||
"""Human label for the leave period: a single date, or 'from to to'."""
|
||||
self.ensure_one()
|
||||
if not self.leave_date:
|
||||
return ''
|
||||
if self.date_to and self.date_to != self.leave_date:
|
||||
return f"{self.leave_date} to {self.date_to}"
|
||||
return str(self.leave_date)
|
||||
|
||||
@api.constrains('leave_date', 'date_to')
|
||||
def _check_leave_dates(self):
|
||||
for rec in self:
|
||||
if rec.date_to and rec.leave_date and rec.date_to < rec.leave_date:
|
||||
raise ValidationError(_("The end date cannot be before the start date."))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('date_to') and vals.get('leave_date'):
|
||||
vals['date_to'] = vals['leave_date']
|
||||
records = super().create(vals_list)
|
||||
for rec in records:
|
||||
rec._notify_office_user()
|
||||
@@ -86,7 +110,7 @@ class FusionClockLeaveRequest(models.Model):
|
||||
try:
|
||||
self.env['mail.activity'].sudo().create({
|
||||
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
|
||||
'summary': f"Leave Request: {self.employee_id.name} on {self.leave_date}",
|
||||
'summary': f"Leave Request: {self.employee_id.name} ({self._fclk_date_label()})",
|
||||
'note': f"Reason: {self.reason}",
|
||||
'user_id': office_user.id,
|
||||
'res_model_id': self.env['ir.model']._get_id('fusion.clock.leave.request'),
|
||||
@@ -102,7 +126,7 @@ class FusionClockLeaveRequest(models.Model):
|
||||
self.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': self.employee_id.id,
|
||||
'log_type': 'leave_request',
|
||||
'description': f"Leave requested for {self.leave_date}: {self.reason}",
|
||||
'description': f"Leave requested for {self._fclk_date_label()}: {self.reason}",
|
||||
'source': 'portal' if self.created_from == 'portal' else 'system',
|
||||
})
|
||||
except Exception as e:
|
||||
|
||||
@@ -166,8 +166,9 @@ class FusionClockReport(models.Model):
|
||||
self.attendance_ids = [(6, 0, attendances.ids)]
|
||||
|
||||
leave_domain = [
|
||||
('leave_date', '>=', self.date_start),
|
||||
# Any leave whose range overlaps the report period.
|
||||
('leave_date', '<=', self.date_end),
|
||||
('date_to', '>=', self.date_start),
|
||||
]
|
||||
if self.employee_id:
|
||||
leave_domain.append(('employee_id', '=', self.employee_id.id))
|
||||
|
||||
484
fusion_clock/models/clock_schedule.py
Normal file
484
fusion_clock/models/clock_schedule.py
Normal file
@@ -0,0 +1,484 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionClockSchedule(models.Model):
|
||||
_name = 'fusion.clock.schedule'
|
||||
_description = 'Clock Shift Schedule Entry'
|
||||
_order = 'schedule_date, employee_id'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
schedule_date = fields.Date(
|
||||
string='Date',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
shift_id = fields.Many2one(
|
||||
'fusion.clock.shift',
|
||||
string='Shift Template',
|
||||
ondelete='set null',
|
||||
)
|
||||
is_off = fields.Boolean(
|
||||
string='Off',
|
||||
default=False,
|
||||
index=True,
|
||||
)
|
||||
start_time = fields.Float(
|
||||
string='Start Time',
|
||||
default=9.0,
|
||||
)
|
||||
end_time = fields.Float(
|
||||
string='End Time',
|
||||
default=17.0,
|
||||
)
|
||||
break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=30.0,
|
||||
)
|
||||
planned_hours = fields.Float(
|
||||
string='Hours',
|
||||
compute='_compute_planned_hours',
|
||||
store=True,
|
||||
)
|
||||
note = fields.Char(string='Note')
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='employee_id.company_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
department_id = fields.Many2one(
|
||||
'hr.department',
|
||||
string='Department',
|
||||
related='employee_id.department_id',
|
||||
store=True,
|
||||
readonly=True,
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'), ('posted', 'Posted')],
|
||||
string='Status',
|
||||
default='draft',
|
||||
index=True,
|
||||
help="Only POSTED entries drive reminders, absence checks and penalties. "
|
||||
"Draft entries are ignored by automation until the team lead posts them.",
|
||||
)
|
||||
posted_date = fields.Datetime(string='Posted On', readonly=True)
|
||||
|
||||
_employee_date_unique = models.Constraint(
|
||||
'UNIQUE(employee_id, schedule_date)',
|
||||
'Only one shift schedule is allowed per employee per day.',
|
||||
)
|
||||
|
||||
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
|
||||
def _compute_planned_hours(self):
|
||||
for rec in self:
|
||||
if rec.is_off:
|
||||
rec.planned_hours = 0.0
|
||||
continue
|
||||
raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0)
|
||||
rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2)
|
||||
|
||||
@api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
emp = rec.employee_id.name or ''
|
||||
date_str = str(rec.schedule_date) if rec.schedule_date else ''
|
||||
rec.display_name = f"{emp} - {date_str} - {rec.fclk_display_value()}"
|
||||
|
||||
@api.constrains('is_off', 'start_time', 'end_time', 'break_minutes')
|
||||
def _check_schedule_times(self):
|
||||
for rec in self:
|
||||
if rec.break_minutes < 0:
|
||||
raise ValidationError(_("Break minutes cannot be negative."))
|
||||
if rec.is_off:
|
||||
continue
|
||||
if rec.start_time < 0 or rec.start_time >= 24:
|
||||
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
|
||||
if rec.end_time <= 0 or rec.end_time > 24:
|
||||
raise ValidationError(_("End time must be between 00:01 and 24:00."))
|
||||
if rec.end_time <= rec.start_time:
|
||||
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
|
||||
shift_minutes = (rec.end_time - rec.start_time) * 60.0
|
||||
if rec.break_minutes >= shift_minutes:
|
||||
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
|
||||
|
||||
@api.onchange('shift_id')
|
||||
def _onchange_shift_id(self):
|
||||
for rec in self:
|
||||
if rec.shift_id:
|
||||
rec.is_off = False
|
||||
rec.start_time = rec.shift_id.start_time
|
||||
rec.end_time = rec.shift_id.end_time
|
||||
rec.break_minutes = rec.shift_id.break_minutes
|
||||
|
||||
@api.model
|
||||
def fclk_float_to_display(self, value):
|
||||
value = float(value or 0.0)
|
||||
hour = int(value)
|
||||
minute = int(round((value - hour) * 60))
|
||||
if minute == 60:
|
||||
hour += 1
|
||||
minute = 0
|
||||
suffix = 'am' if hour < 12 or hour == 24 else 'pm'
|
||||
display_hour = hour % 12
|
||||
if display_hour == 0:
|
||||
display_hour = 12
|
||||
return f"{display_hour}:{minute:02d} {suffix}"
|
||||
|
||||
def fclk_display_value(self):
|
||||
self.ensure_one()
|
||||
if self.is_off:
|
||||
return 'OFF'
|
||||
return (
|
||||
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.start_time)} - "
|
||||
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.end_time)}"
|
||||
)
|
||||
|
||||
@api.model
|
||||
def fclk_hours_display(self, hours):
|
||||
hours = float(hours or 0.0)
|
||||
whole = int(hours)
|
||||
minutes = int(round((hours - whole) * 60))
|
||||
if minutes == 60:
|
||||
whole += 1
|
||||
minutes = 0
|
||||
return f"{whole}:{minutes:02d}"
|
||||
|
||||
@api.model
|
||||
def _fclk_parse_time_part(self, raw):
|
||||
text = (raw or '').strip().lower().replace('.', '')
|
||||
match = re.match(r'^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$', text)
|
||||
if not match:
|
||||
raise ValidationError(_("Could not understand time '%s'.") % raw)
|
||||
hour = int(match.group(1))
|
||||
minute = int(match.group(2) or 0)
|
||||
meridiem = match.group(3)
|
||||
if minute < 0 or minute > 59:
|
||||
raise ValidationError(_("Minutes must be between 00 and 59."))
|
||||
if meridiem:
|
||||
if hour < 1 or hour > 12:
|
||||
raise ValidationError(_("12-hour times must use hours from 1 to 12."))
|
||||
if meridiem == 'am':
|
||||
hour = 0 if hour == 12 else hour
|
||||
else:
|
||||
hour = 12 if hour == 12 else hour + 12
|
||||
elif hour > 24:
|
||||
raise ValidationError(_("Hours must be between 0 and 24."))
|
||||
return hour + (minute / 60.0)
|
||||
|
||||
@api.model
|
||||
def fclk_parse_planner_input(self, input_value, default_break_minutes=30.0):
|
||||
text = (input_value or '').strip()
|
||||
if not text:
|
||||
return {'clear': True}
|
||||
if text.upper() == 'OFF':
|
||||
return {
|
||||
'clear': False,
|
||||
'is_off': True,
|
||||
'shift_id': False,
|
||||
'start_time': 0.0,
|
||||
'end_time': 0.0,
|
||||
'break_minutes': 0.0,
|
||||
}
|
||||
|
||||
normalized = (
|
||||
text.replace('–', '-')
|
||||
.replace('—', '-')
|
||||
.replace(' to ', '-')
|
||||
.replace(' TO ', '-')
|
||||
)
|
||||
parts = [p.strip() for p in normalized.split('-', 1)]
|
||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||
raise ValidationError(_("Enter a shift as '9-5', '9:00-5:30', '9:00 am - 5:30 pm', or OFF."))
|
||||
start = self._fclk_parse_time_part(parts[0])
|
||||
end = self._fclk_parse_time_part(parts[1])
|
||||
if end <= start and end + 12 <= 24:
|
||||
end += 12
|
||||
if end <= start:
|
||||
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
|
||||
return {
|
||||
'clear': False,
|
||||
'is_off': False,
|
||||
'shift_id': False,
|
||||
'start_time': start,
|
||||
'end_time': end,
|
||||
'break_minutes': float(default_break_minutes or 0.0),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def fclk_values_from_planner_payload(self, payload, employee):
|
||||
payload = payload or {}
|
||||
if 'start_time' in payload and 'end_time' in payload and not payload.get('shift_id'):
|
||||
if payload.get('is_off'):
|
||||
return {
|
||||
'clear': False,
|
||||
'is_off': True,
|
||||
'shift_id': False,
|
||||
'start_time': 0.0,
|
||||
'end_time': 0.0,
|
||||
'break_minutes': 0.0,
|
||||
}
|
||||
return {
|
||||
'clear': False,
|
||||
'is_off': False,
|
||||
'shift_id': False,
|
||||
'start_time': float(payload.get('start_time') or 0.0),
|
||||
'end_time': float(payload.get('end_time') or 0.0),
|
||||
'break_minutes': float(payload.get('break_minutes') or 0.0),
|
||||
}
|
||||
shift_id = int(payload.get('shift_id') or 0)
|
||||
if shift_id:
|
||||
shift = self.env['fusion.clock.shift'].sudo().browse(shift_id)
|
||||
if not shift.exists():
|
||||
raise ValidationError(_("Selected shift template no longer exists."))
|
||||
return {
|
||||
'clear': False,
|
||||
'shift_id': shift.id,
|
||||
'is_off': False,
|
||||
'start_time': shift.start_time,
|
||||
'end_time': shift.end_time,
|
||||
'break_minutes': shift.break_minutes,
|
||||
}
|
||||
|
||||
default_break = employee._get_fclk_break_minutes() if employee else 30.0
|
||||
return self.fclk_parse_planner_input(payload.get('input', ''), default_break)
|
||||
|
||||
@api.model
|
||||
def fclk_snapshot(self, schedule):
|
||||
if not schedule:
|
||||
return ''
|
||||
return schedule.fclk_display_value()
|
||||
|
||||
@api.model
|
||||
def fclk_apply_planner_cell(self, employee, schedule_date, payload, user=None):
|
||||
self = self.sudo()
|
||||
employee = employee.sudo()
|
||||
date_obj = fields.Date.to_date(schedule_date)
|
||||
if not employee.exists() or not date_obj:
|
||||
raise ValidationError(_("Invalid employee or schedule date."))
|
||||
|
||||
existing = self.search([
|
||||
('employee_id', '=', employee.id),
|
||||
('schedule_date', '=', date_obj),
|
||||
], limit=1)
|
||||
old_value = self.fclk_snapshot(existing)
|
||||
parsed = self.fclk_values_from_planner_payload(payload, employee)
|
||||
|
||||
if parsed.get('clear'):
|
||||
if existing:
|
||||
existing.unlink()
|
||||
new_schedule = self.browse()
|
||||
new_value = ''
|
||||
else:
|
||||
vals = {
|
||||
'employee_id': employee.id,
|
||||
'schedule_date': date_obj,
|
||||
'shift_id': parsed.get('shift_id') or False,
|
||||
'is_off': bool(parsed.get('is_off')),
|
||||
'start_time': parsed.get('start_time') or 0.0,
|
||||
'end_time': parsed.get('end_time') or 0.0,
|
||||
'break_minutes': parsed.get('break_minutes') or 0.0,
|
||||
'note': payload.get('note') or False,
|
||||
# Any planner edit returns the cell to draft; it must be re-posted
|
||||
# before automation acts on it.
|
||||
'state': 'draft',
|
||||
'posted_date': False,
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
new_schedule = existing
|
||||
else:
|
||||
new_schedule = self.create(vals)
|
||||
new_value = new_schedule.fclk_display_value()
|
||||
|
||||
if old_value != new_value:
|
||||
self.env['fusion.clock.schedule.audit'].sudo().create({
|
||||
'schedule_id': new_schedule.id if new_schedule else False,
|
||||
'employee_id': employee.id,
|
||||
'schedule_date': date_obj,
|
||||
'old_value': old_value,
|
||||
'new_value': new_value,
|
||||
'changed_by_id': (user or self.env.user).id,
|
||||
'changed_at': fields.Datetime.now(),
|
||||
'company_id': employee.company_id.id,
|
||||
'department_id': employee.department_id.id,
|
||||
})
|
||||
return new_schedule
|
||||
|
||||
@api.model
|
||||
def fclk_cell_payload(self, employee, date_obj, schedule=None):
|
||||
schedule = schedule or self.search([
|
||||
('employee_id', '=', employee.id),
|
||||
('schedule_date', '=', date_obj),
|
||||
], limit=1)
|
||||
Schedule = self.env['fusion.clock.schedule']
|
||||
if schedule:
|
||||
return {
|
||||
'schedule_id': schedule.id,
|
||||
'source': 'schedule',
|
||||
'state': schedule.state,
|
||||
'input': schedule.fclk_display_value(),
|
||||
'label': schedule.fclk_display_value(),
|
||||
'is_off': schedule.is_off,
|
||||
'shift_id': schedule.shift_id.id or False,
|
||||
'start_time': schedule.start_time,
|
||||
'end_time': schedule.end_time,
|
||||
'break_minutes': schedule.break_minutes,
|
||||
'hours': schedule.planned_hours,
|
||||
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
|
||||
'note': schedule.note or '',
|
||||
}
|
||||
|
||||
plan = employee._get_fclk_day_plan(date_obj)
|
||||
return {
|
||||
'schedule_id': False,
|
||||
'source': plan.get('source') or 'none',
|
||||
'state': False,
|
||||
'input': plan.get('label') or '',
|
||||
'label': plan.get('label') or '',
|
||||
'is_off': plan.get('is_off', False),
|
||||
'shift_id': False,
|
||||
'start_time': plan.get('start_time') or 0.0,
|
||||
'end_time': plan.get('end_time') or 0.0,
|
||||
'break_minutes': plan.get('break_minutes') or 0.0,
|
||||
'hours': plan.get('hours') or 0.0,
|
||||
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
|
||||
'note': '',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def fclk_email_posted_week(self, employee, week_start, week_end):
|
||||
"""Email one employee a summary of their POSTED shifts for the week."""
|
||||
employee = employee.sudo()
|
||||
if not employee.work_email:
|
||||
return False
|
||||
from .hr_attendance import _fclk_email_wrap
|
||||
entries = self.sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('schedule_date', '>=', week_start),
|
||||
('schedule_date', '<=', week_end),
|
||||
('state', '=', 'posted'),
|
||||
])
|
||||
by_date = {entry.schedule_date: entry for entry in entries}
|
||||
rows = []
|
||||
day = week_start
|
||||
while day <= week_end:
|
||||
entry = by_date.get(day)
|
||||
rows.append((
|
||||
day.strftime('%a %b %d'),
|
||||
entry.fclk_display_value() if entry else 'Not scheduled',
|
||||
))
|
||||
day += timedelta(days=1)
|
||||
company = employee.company_id or self.env.company
|
||||
body = _fclk_email_wrap(
|
||||
company_name=company.name or '',
|
||||
title='Your Posted Schedule',
|
||||
summary=(
|
||||
f'Hello <strong>{employee.name}</strong>, your shifts for '
|
||||
f'<strong>{week_start.strftime("%b %d")} - {week_end.strftime("%b %d, %Y")}</strong> '
|
||||
f'have been posted.'
|
||||
),
|
||||
sections=[('This Week', rows)],
|
||||
note='Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> for details.',
|
||||
)
|
||||
try:
|
||||
mail = self.env['mail.mail'].sudo().create({
|
||||
'subject': f'Your schedule: {week_start.strftime("%b %d")} - {week_end.strftime("%b %d")}',
|
||||
'email_from': company.email or '',
|
||||
'email_to': employee.work_email,
|
||||
'body_html': body,
|
||||
'auto_delete': True,
|
||||
})
|
||||
mail.send()
|
||||
return True
|
||||
except Exception as exc:
|
||||
_logger.error(
|
||||
"Fusion Clock: failed to email posted schedule to %s: %s", employee.name, exc
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class FusionClockScheduleAudit(models.Model):
|
||||
_name = 'fusion.clock.schedule.audit'
|
||||
_description = 'Clock Schedule Change Audit'
|
||||
_order = 'changed_at desc, id desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
schedule_id = fields.Many2one(
|
||||
'fusion.clock.schedule',
|
||||
string='Schedule',
|
||||
ondelete='set null',
|
||||
index=True,
|
||||
)
|
||||
employee_id = fields.Many2one(
|
||||
'hr.employee',
|
||||
string='Employee',
|
||||
required=True,
|
||||
index=True,
|
||||
ondelete='cascade',
|
||||
)
|
||||
schedule_date = fields.Date(
|
||||
string='Schedule Date',
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
old_value = fields.Char(string='Old Value')
|
||||
new_value = fields.Char(string='New Value')
|
||||
changed_by_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Changed By',
|
||||
required=True,
|
||||
ondelete='restrict',
|
||||
)
|
||||
changed_at = fields.Datetime(
|
||||
string='Changed At',
|
||||
default=fields.Datetime.now,
|
||||
required=True,
|
||||
index=True,
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
index=True,
|
||||
)
|
||||
department_id = fields.Many2one(
|
||||
'hr.department',
|
||||
string='Department',
|
||||
index=True,
|
||||
)
|
||||
display_name = fields.Char(
|
||||
compute='_compute_display_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('employee_id', 'schedule_date', 'old_value', 'new_value')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
rec.display_name = "%s - %s: %s -> %s" % (
|
||||
rec.employee_id.name or '',
|
||||
rec.schedule_date or '',
|
||||
rec.old_value or 'blank',
|
||||
rec.new_value or 'blank',
|
||||
)
|
||||
@@ -42,6 +42,17 @@ class FusionClockShift(models.Model):
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
color = fields.Char(string='Color', default='#3B82F6')
|
||||
|
||||
# Weekday pattern — which days this recurring shift applies as the baseline
|
||||
# when there is no posted planner entry for the day. Default Mon-Fri.
|
||||
day_mon = fields.Boolean(string='Mon', default=True)
|
||||
day_tue = fields.Boolean(string='Tue', default=True)
|
||||
day_wed = fields.Boolean(string='Wed', default=True)
|
||||
day_thu = fields.Boolean(string='Thu', default=True)
|
||||
day_fri = fields.Boolean(string='Fri', default=True)
|
||||
day_sat = fields.Boolean(string='Sat', default=False)
|
||||
day_sun = fields.Boolean(string='Sun', default=False)
|
||||
|
||||
employee_ids = fields.One2many(
|
||||
'hr.employee',
|
||||
'x_fclk_shift_id',
|
||||
@@ -56,6 +67,17 @@ class FusionClockShift(models.Model):
|
||||
for rec in self:
|
||||
rec.employee_count = len(rec.employee_ids)
|
||||
|
||||
def covers_weekday(self, date):
|
||||
"""Return True if this recurring shift applies on the given date's
|
||||
weekday (Mon=0 .. Sun=6)."""
|
||||
self.ensure_one()
|
||||
date_obj = fields.Date.to_date(date)
|
||||
if not date_obj:
|
||||
return False
|
||||
days = (self.day_mon, self.day_tue, self.day_wed, self.day_thu,
|
||||
self.day_fri, self.day_sat, self.day_sun)
|
||||
return bool(days[date_obj.weekday()])
|
||||
|
||||
@property
|
||||
def scheduled_hours(self):
|
||||
"""Return the scheduled work hours for this shift (excluding break)."""
|
||||
|
||||
@@ -227,7 +227,18 @@ class HrAttendance(models.Model):
|
||||
continue
|
||||
|
||||
employee = att.employee_id
|
||||
scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold
|
||||
scheduled_hours = daily_threshold
|
||||
if employee:
|
||||
local_date = get_local_today(self.env, employee)
|
||||
if att.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(att.check_in).astimezone(pytz.timezone(tz_name)).date()
|
||||
scheduled_hours = employee._get_fclk_scheduled_hours(local_date)
|
||||
net = att.x_fclk_net_hours or 0.0
|
||||
|
||||
if net > scheduled_hours:
|
||||
@@ -239,61 +250,55 @@ class HrAttendance(models.Model):
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_auto_clock_out(self):
|
||||
"""Cron job: auto clock-out employees after shift + grace period."""
|
||||
"""Cron job: safety-net auto clock-out.
|
||||
|
||||
Overtime past the scheduled end is expected, so this NEVER closes a shift
|
||||
at the scheduled end. It only closes an attendance left open longer than
|
||||
the max-shift safety cap (someone forgot to clock out), and flags the
|
||||
employee to explain on their next clock-in.
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_auto_clockout', 'True') != 'True':
|
||||
return
|
||||
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '12.0'))
|
||||
grace_min = float(ICP.get_param('fusion_clock.grace_period_minutes', '15'))
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
|
||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
|
||||
open_attendances = self.sudo().search([
|
||||
('check_out', '=', False),
|
||||
])
|
||||
|
||||
open_attendances = self.sudo().search([('check_out', '=', False)])
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
|
||||
for att in open_attendances:
|
||||
check_in = att.check_in
|
||||
if not check_in:
|
||||
continue
|
||||
effective_deadline = check_in + timedelta(hours=max_shift)
|
||||
if now <= effective_deadline:
|
||||
continue
|
||||
|
||||
employee = att.employee_id
|
||||
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()
|
||||
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
|
||||
|
||||
deadline = scheduled_out + timedelta(minutes=grace_min)
|
||||
max_deadline = check_in + timedelta(hours=max_shift)
|
||||
effective_deadline = min(deadline, max_deadline)
|
||||
|
||||
if now > effective_deadline:
|
||||
clock_out_time = min(effective_deadline, now)
|
||||
try:
|
||||
clock_out_time = effective_deadline
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
att.sudo().write({
|
||||
'check_out': clock_out_time,
|
||||
'x_fclk_auto_clocked_out': True,
|
||||
'x_fclk_grace_used': True,
|
||||
'x_fclk_clock_source': 'auto',
|
||||
})
|
||||
|
||||
# Apply break deduction
|
||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||
if (att.worked_hours or 0) >= threshold:
|
||||
break_min = employee._get_fclk_break_minutes()
|
||||
att.sudo().write({'x_fclk_break_minutes': break_min})
|
||||
|
||||
att.sudo().write(
|
||||
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
|
||||
)
|
||||
att.sudo().message_post(
|
||||
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
|
||||
f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
|
||||
# Log to activity log
|
||||
ActivityLog.create({
|
||||
'employee_id': employee.id,
|
||||
'log_type': 'auto_clock_out',
|
||||
@@ -303,11 +308,7 @@ class HrAttendance(models.Model):
|
||||
'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False,
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
# Set pending reason
|
||||
employee.sudo().write({'x_fclk_pending_reason': True})
|
||||
|
||||
# Notify office user
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Auto Clock-Out: {employee.name}",
|
||||
@@ -316,16 +317,66 @@ class HrAttendance(models.Model):
|
||||
'hr.attendance',
|
||||
att.id,
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Fusion Clock: Auto clocked out %s (attendance %s)",
|
||||
employee.name, att.id,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
||||
att.id, str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
"Fusion Clock: Failed to auto clock-out attendance %s: %s",
|
||||
att.id, str(e),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_wipe_old_photos(self):
|
||||
"""Cron job: delete clock-in/out verification photos older than the
|
||||
configured retention window (``fusion_clock.photo_retention_days``).
|
||||
|
||||
Only the images are removed — the attendance records, worked hours and
|
||||
penalties are kept. The photos are attachment-backed binary fields, so we
|
||||
unlink the underlying ir.attachment rows directly, which reclaims the
|
||||
filestore space. Set the retention to 0 to disable the wipe entirely."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
retention_days = int(ICP.get_param('fusion_clock.photo_retention_days', '60') or 0)
|
||||
if retention_days <= 0:
|
||||
return # 0 / unset → auto-wipe disabled
|
||||
|
||||
cutoff = fields.Datetime.now() - timedelta(days=retention_days)
|
||||
old_attendances = self.sudo().search([('check_in', '<', cutoff)])
|
||||
if not old_attendances:
|
||||
return
|
||||
|
||||
Attachment = self.env['ir.attachment'].sudo()
|
||||
photo_fields = [
|
||||
'x_fclk_check_in_photo', # NFC kiosk clock-in selfie
|
||||
'x_fclk_check_out_photo', # NFC kiosk clock-out selfie
|
||||
'x_fclk_checkin_photo', # legacy portal clock-in photo
|
||||
]
|
||||
wiped = 0
|
||||
# Batch the attendances so the res_id IN (...) list stays bounded, and
|
||||
# isolate each batch in a savepoint so one bad row can't abort the rest.
|
||||
for offset in range(0, len(old_attendances), 500):
|
||||
batch_ids = old_attendances[offset:offset + 500].ids
|
||||
photos = Attachment.search([
|
||||
('res_model', '=', 'hr.attendance'),
|
||||
('res_field', 'in', photo_fields),
|
||||
('res_id', 'in', batch_ids),
|
||||
])
|
||||
if not photos:
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
count = len(photos)
|
||||
photos.unlink()
|
||||
wiped += count
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: Failed to wipe a photo batch: %s", e)
|
||||
|
||||
if wiped:
|
||||
_logger.info(
|
||||
"Fusion Clock: Wiped %s clock verification photo(s) older than %s days.",
|
||||
wiped, retention_days,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_check_absences(self):
|
||||
@@ -342,121 +393,145 @@ class HrAttendance(models.Model):
|
||||
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
|
||||
|
||||
for emp in employees:
|
||||
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
||||
|
||||
if yesterday.weekday() >= 5:
|
||||
continue
|
||||
# Only days the employee was actually scheduled to work
|
||||
# (posted shift or covering recurring shift) can count as an
|
||||
# absence. Off days and unscheduled days are never flagged.
|
||||
if not emp._get_fclk_day_plan(yesterday).get('scheduled'):
|
||||
continue
|
||||
|
||||
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
||||
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
|
||||
|
||||
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
||||
('resource_id', '=', False),
|
||||
('date_from', '<=', day_end),
|
||||
('date_to', '>=', day_start),
|
||||
])
|
||||
if holidays:
|
||||
continue
|
||||
holidays = self.env['resource.calendar.leaves'].sudo().search([
|
||||
('resource_id', '=', False),
|
||||
('date_from', '<=', day_end),
|
||||
('date_to', '>=', day_start),
|
||||
])
|
||||
if holidays:
|
||||
continue
|
||||
|
||||
att_count = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', day_start),
|
||||
('check_in', '<', day_end),
|
||||
])
|
||||
if att_count > 0:
|
||||
continue
|
||||
att_count = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', day_start),
|
||||
('check_in', '<', day_end),
|
||||
])
|
||||
if att_count > 0:
|
||||
continue
|
||||
|
||||
leave = LeaveRequest.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('leave_date', '=', yesterday),
|
||||
], limit=1)
|
||||
if leave:
|
||||
continue
|
||||
leave = LeaveRequest.search([
|
||||
('employee_id', '=', emp.id),
|
||||
('leave_date', '<=', yesterday),
|
||||
('date_to', '>=', yesterday),
|
||||
], limit=1)
|
||||
if leave:
|
||||
continue
|
||||
|
||||
ActivityLog.create({
|
||||
'employee_id': emp.id,
|
||||
'log_type': 'absent',
|
||||
'log_date': day_start,
|
||||
'description': f"No attendance recorded for {yesterday}",
|
||||
'source': 'system',
|
||||
})
|
||||
ActivityLog.create({
|
||||
'employee_id': emp.id,
|
||||
'log_type': 'absent',
|
||||
'log_date': day_start,
|
||||
'description': f"No attendance recorded for {yesterday}",
|
||||
'source': 'system',
|
||||
})
|
||||
|
||||
emp.sudo().write({'x_fclk_pending_reason': True})
|
||||
emp.sudo().write({'x_fclk_pending_reason': True})
|
||||
|
||||
month_start = yesterday.replace(day=1)
|
||||
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||
absence_count = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', month_boundary_start),
|
||||
])
|
||||
month_start = yesterday.replace(day=1)
|
||||
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||
absence_count = ActivityLog.search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('log_type', '=', 'absent'),
|
||||
('log_date', '>=', month_boundary_start),
|
||||
])
|
||||
|
||||
if absence_count >= max_absences:
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Excessive Absences: {emp.name}",
|
||||
f"{emp.name} has {absence_count} absences this month "
|
||||
f"(threshold: {max_absences}). Please review.",
|
||||
'hr.employee',
|
||||
emp.id,
|
||||
)
|
||||
if absence_count >= max_absences:
|
||||
self._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Excessive Absences: {emp.name}",
|
||||
f"{emp.name} has {absence_count} absences this month "
|
||||
f"(threshold: {max_absences}). Please review.",
|
||||
'hr.employee',
|
||||
emp.id,
|
||||
)
|
||||
|
||||
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
|
||||
_logger.info("Fusion Clock: Marked %s as absent for %s", emp.name, yesterday)
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: absence check failed for %s: %s", emp.name, e)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_employee_reminders(self):
|
||||
"""Cron job: send clock-in/out reminders to employees."""
|
||||
"""Cron job: schedule-driven clock-in / clock-out reminders.
|
||||
|
||||
Reminders only go to employees actually SCHEDULED to work today (posted
|
||||
shift or covering recurring shift). Someone not scheduled — or whose
|
||||
shift simply hasn't started yet — is never pinged.
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_employee_notifications', 'True') != 'True':
|
||||
return
|
||||
|
||||
reminder_in_min = float(ICP.get_param('fusion_clock.reminder_before_shift_minutes', '30'))
|
||||
reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15'))
|
||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
|
||||
|
||||
now = fields.Datetime.now()
|
||||
|
||||
employees = self.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
|
||||
for emp in employees:
|
||||
today = get_local_today(self.env, emp)
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
today = get_local_today(self.env, emp)
|
||||
if not emp._get_fclk_day_plan(today).get('scheduled'):
|
||||
continue
|
||||
if emp.x_fclk_last_reminder_date == today:
|
||||
continue
|
||||
|
||||
if today.weekday() >= 5:
|
||||
continue
|
||||
is_checked_in = emp.attendance_state == 'checked_in'
|
||||
|
||||
if emp.x_fclk_last_reminder_date == today:
|
||||
continue
|
||||
|
||||
scheduled_in, scheduled_out = emp._get_fclk_scheduled_times(today)
|
||||
is_checked_in = emp.attendance_state == 'checked_in'
|
||||
|
||||
# Missed clock-in reminder
|
||||
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
|
||||
if not is_checked_in and now > reminder_deadline:
|
||||
today_start, _ = get_local_day_boundaries(self.env, today, emp)
|
||||
has_attendance = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
if has_attendance == 0:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-In Reminder",
|
||||
f"Hi {emp.name}, you haven't clocked in yet today. "
|
||||
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
|
||||
# Clock-out reminder
|
||||
reminder_before_end = scheduled_out - timedelta(minutes=reminder_out_min)
|
||||
if is_checked_in and now > reminder_before_end and now < scheduled_out:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-Out Reminder",
|
||||
f"Hi {emp.name}, your shift ends at {_fclk_utc_to_local_str(scheduled_out, emp)}. "
|
||||
f"Don't forget to clock out.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
if not is_checked_in:
|
||||
# Missed clock-in — only after THIS employee's own shift
|
||||
# start (+ threshold), so a late shift is never pinged early.
|
||||
scheduled_in, _scheduled_out = emp._get_fclk_scheduled_times(today)
|
||||
if now <= scheduled_in + timedelta(minutes=reminder_in_min):
|
||||
continue
|
||||
today_start, _ = get_local_day_boundaries(self.env, today, emp)
|
||||
has_attendance = self.sudo().search_count([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
if has_attendance == 0:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-In Reminder",
|
||||
f"Hi {emp.name}, you haven't clocked in yet today. "
|
||||
f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
else:
|
||||
# Still-clocked-in nudge (OT-aware): only as the max-shift
|
||||
# safety cap approaches, never at the scheduled end.
|
||||
open_att = self.sudo().search([
|
||||
('employee_id', '=', emp.id),
|
||||
('check_out', '=', False),
|
||||
], order='check_in desc', limit=1)
|
||||
if not open_att or not open_att.check_in:
|
||||
continue
|
||||
cap = open_att.check_in + timedelta(hours=max_shift)
|
||||
if cap - timedelta(minutes=reminder_out_min) < now < cap:
|
||||
self._fclk_send_employee_reminder(
|
||||
emp,
|
||||
"Clock-Out Reminder",
|
||||
f"Hi {emp.name}, you're still clocked in. "
|
||||
f"Remember to clock out when you leave.",
|
||||
)
|
||||
emp.sudo().write({'x_fclk_last_reminder_date': today})
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock: reminder failed for %s: %s", emp.name, e)
|
||||
|
||||
@api.model
|
||||
def _cron_fusion_weekly_summary(self):
|
||||
|
||||
@@ -58,13 +58,15 @@ class HrEmployee(models.Model):
|
||||
"Same card the employee uses for door access.",
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fclk_nfc_card_uid_unique',
|
||||
'UNIQUE(x_fclk_nfc_card_uid)',
|
||||
'This NFC card is already assigned to another employee.',
|
||||
),
|
||||
]
|
||||
# Enforce NFC card-UID uniqueness ONLY when a UID is set. Odoo 19 silently ignores
|
||||
# the legacy `_sql_constraints` list (see repo-root CLAUDE.md rule 9), so this never
|
||||
# created a DB constraint. Use the declarative UniqueIndex with a partial WHERE so the
|
||||
# many employees without a card can share a blank/NULL value, while two employees can
|
||||
# never be assigned the same physical card.
|
||||
_fclk_nfc_card_uid_unique = models.UniqueIndex(
|
||||
"(x_fclk_nfc_card_uid) WHERE x_fclk_nfc_card_uid IS NOT NULL AND x_fclk_nfc_card_uid != ''",
|
||||
'This NFC card is already assigned to another employee.',
|
||||
)
|
||||
|
||||
# On-time streak
|
||||
x_fclk_ontime_streak = fields.Integer(
|
||||
@@ -120,11 +122,89 @@ class HrEmployee(models.Model):
|
||||
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
||||
)
|
||||
|
||||
def _get_fclk_break_minutes(self):
|
||||
"""Return effective break minutes for this employee.
|
||||
Priority: employee override > shift > global setting.
|
||||
def _get_fclk_schedule_for_date(self, date):
|
||||
"""Return this employee's dated Fusion Clock schedule for a local date."""
|
||||
self.ensure_one()
|
||||
date_obj = fields.Date.to_date(date)
|
||||
if not date_obj:
|
||||
return self.env['fusion.clock.schedule']
|
||||
return self.env['fusion.clock.schedule'].sudo().search([
|
||||
('employee_id', '=', self.id),
|
||||
('schedule_date', '=', date_obj),
|
||||
], limit=1)
|
||||
|
||||
def _get_fclk_day_plan(self, date):
|
||||
"""Return the effective plan for a local date, with an explicit
|
||||
``scheduled`` flag that ALL attendance automation keys off.
|
||||
|
||||
Resolution order:
|
||||
1. POSTED planner entry (``fusion.clock.schedule`` state='posted').
|
||||
Draft entries are ignored, so the recurring baseline still applies
|
||||
until the team lead posts the schedule.
|
||||
2. The employee's recurring shift, IF it covers this weekday.
|
||||
3. Otherwise: not scheduled. The global default times are returned
|
||||
only as a display hint; ``scheduled`` stays False so nothing fires.
|
||||
"""
|
||||
self.ensure_one()
|
||||
Schedule = self.env['fusion.clock.schedule'].sudo()
|
||||
schedule = self._get_fclk_schedule_for_date(date)
|
||||
if schedule and schedule.state == 'posted':
|
||||
return {
|
||||
'source': 'schedule',
|
||||
'schedule_id': schedule.id,
|
||||
'scheduled': not schedule.is_off,
|
||||
'is_off': schedule.is_off,
|
||||
'start_time': schedule.start_time,
|
||||
'end_time': schedule.end_time,
|
||||
'break_minutes': schedule.break_minutes,
|
||||
'hours': schedule.planned_hours,
|
||||
'label': schedule.fclk_display_value(),
|
||||
}
|
||||
|
||||
shift = self.x_fclk_shift_id
|
||||
if shift and shift.covers_weekday(date):
|
||||
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
|
||||
return {
|
||||
'source': 'shift',
|
||||
'schedule_id': False,
|
||||
'scheduled': True,
|
||||
'is_off': False,
|
||||
'start_time': shift.start_time,
|
||||
'end_time': shift.end_time,
|
||||
'break_minutes': shift.break_minutes,
|
||||
'hours': hours,
|
||||
'label': '%s - %s' % (
|
||||
Schedule.fclk_float_to_display(shift.start_time),
|
||||
Schedule.fclk_float_to_display(shift.end_time),
|
||||
),
|
||||
}
|
||||
|
||||
# Not scheduled — global default times are a display hint only.
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
|
||||
return {
|
||||
'source': 'none',
|
||||
'schedule_id': False,
|
||||
'scheduled': False,
|
||||
'is_off': False,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'break_minutes': break_minutes,
|
||||
'hours': 0.0,
|
||||
'label': '',
|
||||
}
|
||||
|
||||
def _get_fclk_break_minutes(self, date=None):
|
||||
"""Return effective break minutes for this employee.
|
||||
Priority: dated schedule > employee override > shift > global setting.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if date:
|
||||
plan = self._get_fclk_day_plan(date)
|
||||
if plan.get('source') == 'schedule' and not plan.get('is_off'):
|
||||
return plan.get('break_minutes') or 0.0
|
||||
if self.x_fclk_break_minutes > 0:
|
||||
return self.x_fclk_break_minutes
|
||||
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
|
||||
@@ -138,7 +218,7 @@ class HrEmployee(models.Model):
|
||||
def _get_fclk_scheduled_times(self, date):
|
||||
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
|
||||
|
||||
Uses employee shift if assigned, otherwise global settings.
|
||||
Uses dated schedule first, employee shift second, then global settings.
|
||||
The configured hours are interpreted in the employee's local
|
||||
timezone and converted to naive-UTC datetimes so they can be
|
||||
compared with Odoo's UTC-based ``fields.Datetime.now()``.
|
||||
@@ -146,13 +226,9 @@ class HrEmployee(models.Model):
|
||||
import pytz
|
||||
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
in_hour = self.x_fclk_shift_id.start_time
|
||||
out_hour = self.x_fclk_shift_id.end_time
|
||||
else:
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
plan = self._get_fclk_day_plan(date)
|
||||
in_hour = plan.get('start_time') or 0.0
|
||||
out_hour = plan.get('end_time') or 0.0
|
||||
|
||||
in_h = int(in_hour)
|
||||
in_m = int((in_hour - in_h) * 60)
|
||||
@@ -179,16 +255,13 @@ class HrEmployee(models.Model):
|
||||
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
|
||||
return scheduled_in, scheduled_out
|
||||
|
||||
def _get_fclk_scheduled_hours(self):
|
||||
def _get_fclk_scheduled_hours(self, date=None):
|
||||
"""Return the expected work hours for this employee's shift."""
|
||||
self.ensure_one()
|
||||
if self.x_fclk_shift_id:
|
||||
return self.x_fclk_shift_id.scheduled_hours
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
|
||||
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
|
||||
break_hrs = self._get_fclk_break_minutes() / 60.0
|
||||
return max((out_hour - in_hour) - break_hrs, 0.0)
|
||||
plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
|
||||
if plan.get('is_off'):
|
||||
return 0.0
|
||||
return plan.get('hours') or 0.0
|
||||
|
||||
def _compute_absence_counts(self):
|
||||
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
|
||||
|
||||
@@ -56,8 +56,11 @@ class ResConfigSettings(models.TransientModel):
|
||||
fclk_max_shift_hours = fields.Float(
|
||||
string='Max Shift Length (hours)',
|
||||
config_parameter='fusion_clock.max_shift_hours',
|
||||
default=12.0,
|
||||
help="Maximum shift length before auto clock-out (safety net).",
|
||||
default=16.0,
|
||||
help="Safety-net cap: an attendance left open longer than this is "
|
||||
"auto-clocked-out (assumed forgot-to-clock-out). Overtime up to this "
|
||||
"cap is never cut off, so set it comfortably above your longest real "
|
||||
"shift + overtime.",
|
||||
)
|
||||
fclk_enable_penalties = fields.Boolean(
|
||||
string='Enable Penalty Tracking',
|
||||
@@ -268,6 +271,15 @@ class ResConfigSettings(models.TransientModel):
|
||||
help="Which clock location is bound to the NFC kiosk for this company. "
|
||||
"Required when the kiosk is enabled.",
|
||||
)
|
||||
fclk_photo_retention_days = fields.Integer(
|
||||
string='Auto-Wipe Photos After (days)',
|
||||
config_parameter='fusion_clock.photo_retention_days',
|
||||
default=60,
|
||||
help="Clock-in/out verification photos older than this many days are deleted "
|
||||
"automatically by a daily cron. The attendance record, worked hours and "
|
||||
"penalties are kept — only the images are removed, reclaiming storage. "
|
||||
"Set to 0 to disable the auto-wipe.",
|
||||
)
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
|
||||
@@ -11,6 +11,9 @@ access_fusion_clock_leave_request_user,fusion.clock.leave.request.user,model_fus
|
||||
access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_schedule_user,fusion.clock.schedule.user,model_fusion_clock_schedule,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_schedule_manager,fusion.clock.schedule.manager,model_fusion_clock_schedule,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_schedule_audit_manager,fusion.clock.schedule.audit.manager,model_fusion_clock_schedule_audit,group_fusion_clock_manager,1,0,0,0
|
||||
access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0
|
||||
access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1
|
||||
access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0
|
||||
@@ -22,4 +25,5 @@ access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusio
|
||||
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
|
||||
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
|
||||
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
|
||||
|
||||
|
@@ -1,25 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ================================================================
|
||||
App category + privileges (Odoo 19) so Fusion Clock roles appear
|
||||
as selectable application-access dropdowns on the user form,
|
||||
exactly like the other Fusion apps (no developer mode needed).
|
||||
Odoo 19 dropped res.groups.category_id; groups link to a
|
||||
res.groups.privilege, which carries the category_id.
|
||||
================================================================ -->
|
||||
<record id="module_category_fusion_clock" model="ir.module.category">
|
||||
<field name="name">Fusion Clock</field>
|
||||
<field name="sequence">45</field>
|
||||
</record>
|
||||
|
||||
<!-- Main role hierarchy (User < Team Lead < Manager) -> one dropdown -->
|
||||
<record id="res_groups_privilege_fusion_clock" model="res.groups.privilege">
|
||||
<field name="name">Fusion Clock</field>
|
||||
<field name="sequence">45</field>
|
||||
<field name="category_id" ref="module_category_fusion_clock"/>
|
||||
</record>
|
||||
|
||||
<!-- Standalone kiosk-operator role -> its own row under the same header -->
|
||||
<record id="res_groups_privilege_fusion_clock_kiosk" model="res.groups.privilege">
|
||||
<field name="name">Fusion Clock Kiosk</field>
|
||||
<field name="sequence">46</field>
|
||||
<field name="category_id" ref="module_category_fusion_clock"/>
|
||||
</record>
|
||||
|
||||
<!-- Groups -->
|
||||
<record id="group_fusion_clock_user" model="res.groups">
|
||||
<field name="name">Fusion Clock / User</field>
|
||||
<field name="name">User</field>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="comment">Can clock in/out and view own attendance</field>
|
||||
</record>
|
||||
|
||||
<record id="group_fusion_clock_team_lead" model="res.groups">
|
||||
<field name="name">Fusion Clock / Team Lead</field>
|
||||
<field name="name">Team Lead</field>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||
<field name="comment">Can view direct reports attendance (read-only)</field>
|
||||
</record>
|
||||
|
||||
<record id="group_fusion_clock_manager" model="res.groups">
|
||||
<field name="name">Fusion Clock / Manager</field>
|
||||
<field name="name">Manager</field>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_clock"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
||||
<field name="comment">Can manage locations, view all attendance, generate reports</field>
|
||||
</record>
|
||||
|
||||
<!-- Dedicated kiosk-operator permission: can run the shared clock kiosk
|
||||
(NFC tap / PIN) WITHOUT full Clock Manager access. Gates the
|
||||
"Fusion Clock Kiosk" app menu and is accepted by the kiosk controllers.
|
||||
Implies only base.group_user, so it does NOT reveal the full Fusion
|
||||
Clock app (which is gated to group_fusion_clock_user). -->
|
||||
<record id="group_fusion_clock_kiosk_app" model="res.groups">
|
||||
<field name="name">Kiosk Operator</field>
|
||||
<field name="privilege_id" ref="res_groups_privilege_fusion_clock_kiosk"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="comment">Can open and operate the shared clock kiosk (NFC tap / PIN) without full Clock Manager access. Intended for shared wall-tablet accounts.</field>
|
||||
</record>
|
||||
|
||||
<!-- Auto-assign admin to Manager group -->
|
||||
<function model="res.users" name="write">
|
||||
<value eval="[ref('base.user_admin')]"/>
|
||||
@@ -174,6 +215,49 @@
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Record Rules - Dated Schedules
|
||||
================================================================ -->
|
||||
<record id="rule_schedule_user" model="ir.rule">
|
||||
<field name="name">Schedule: User sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_schedule_team_lead" model="ir.rule">
|
||||
<field name="name">Schedule: Team Lead sees direct reports</field>
|
||||
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_schedule_manager" model="ir.rule">
|
||||
<field name="name">Schedule: Manager full access</field>
|
||||
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_schedule_audit_manager" model="ir.rule">
|
||||
<field name="name">Schedule Audit: Manager reads all</field>
|
||||
<field name="model_id" ref="model_fusion_clock_schedule_audit"/>
|
||||
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- ================================================================
|
||||
Record Rules - Correction Request
|
||||
================================================================ -->
|
||||
@@ -286,4 +370,15 @@
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_schedule_portal" model="ir.rule">
|
||||
<field name="name">Schedule: Portal user sees own</field>
|
||||
<field name="model_id" ref="model_fusion_clock_schedule"/>
|
||||
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user