Adds two Integer fields to res.partner:
- x_fc_default_lead_time_min_days
- x_fc_default_lead_time_max_days
Set once on the customer's Plating Defaults tab (Fulfilment group);
auto-copies onto every new Express Order via the existing
_onchange_partner_id hook. Operator can still override per-order
since the onchange only fills when the wizard field is still blank.
Field declaration lives in fusion_plating_configurator (alongside
the rest of the partner cascade reads). View edit lives in
fusion_plating_invoicing where the Plating Defaults tab already
hosts the other partner-level defaults (invoice strategy, deposit
%, delivery method, deadline-days). Invoicing depends on
configurator, so the fields are registered before the view loads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related fixes on the Express Orders totals card:
1. Totals card now breaks out Subtotal / Tax / Tooling Charge /
Grand Total. Previously the "Subtotal" and "Grand Total" rows
both read from total_amount (same value rendered twice) and no
tax was shown at all. Customers on a fiscal position-mapped
tax rate (Ontario HST, etc.) had their taxes silently dropped
from the preview.
2. tooling_charge now feeds the Grand Total. The total_amount
compute previously summed line subtotals only. Added a real
SO line for the tooling charge in action_create_order so the
eventual sale.order.amount_total matches the preview AND the
invoice carries a "Tooling Charge" line item.
3. tax_ids is now visible as an optional column on the lines
list. Operator can see + override the auto-applied tax per
line. Default still comes from FP-SERVICE product mapped
through partner.property_account_position_id (fiscal position).
New compute fields on fp.direct.order.wizard:
- total_subtotal (sum of line.qty * line.unit_price, pre-tax)
- total_tax (sum of line + tooling taxes via compute_all)
- total_amount (subtotal + tax + tooling — was just subtotal)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Durable: nexa/entech upgrade commands, central service-account Contact
Creation prerequisite, backup-outside-addons-path gotcha, smoke-tests-must-
call-the-controller lesson. Plus current deploy status + the one remaining
step (browser confirmation of My Tickets / New on entech).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Squash-merge of feat/helpdesk-customer-followup. The billing and
fusion_login_audit work from that branch is already on main (landed
separately); this lands only the helpdesk feature.
- Identity keystone: submit() forwards partner_email/partner_name/
x_fc_client_label so the central Helpdesk find-or-creates the customer
partner and subscribes them as a follower (enables reply emails + magic link).
- Embedded in-app 'My Tickets' inbox: server-side scoped read/reply RPC
endpoints, per-user seen tracking (fusion.helpdesk.ticket.seen), systray
unread badge. Defense-in-depth scope domain + _norm_email normalisation
(wildcard emails cannot widen scope).
- fusion_helpdesk_central: x_fc_client_label field + list/search views +
branded acknowledgement email template.
- Deployed and smoke-tested live: nexa central 19.0.1.1.0, entech client
19.0.1.4.1 (requires Contact Creation on the central service account).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the login string resolves to an existing user and the password is
wrong, BOTH overrides used to write a failure row:
- _check_credentials wrapper: result=failure, reason=bad_password
- _login wrapper (catching the propagating AccessDenied): result=
failure, reason=unknown_user
Discovered in production smoke on westin-v19 after the deploy: a
single failed login for info@gsafinancialconsulting.com produced two
audit rows (one bad_password, one unknown_user). The unknown_user
label was wrong — the user IS in the system.
Fix: _login now checks whether the login string resolves to any user
BEFORE writing the unknown_user row. If yes, _check_credentials
already logged the attempt and _login skips. If no, the user lookup
in super() failed and _login is the only chance to log.
Regression test test_login_known_user_bad_password_single_row asserts
exactly one row per attempt and that the row carries bad_password
(not unknown_user) when the user exists.
30 tests green locally; production smoke on westin-v19 confirms:
one row per failed login, bad_password, IP 172.18.0.1 captured.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture in the plan the Odoo 19 gotchas discovered during execution
that the original plan template missed:
- Test command requires --http-port=0 --gevent-port=0 (running
container holds 8069).
- Declarative models.Constraint / models.Index (T2).
- res.users.groups_id renamed to group_ids (T3, T6).
- ir.rule groups is additive not restrictive (T3).
- mail.template inline-template ctx IS env.context (T11).
- ir.cron has no numbercall field in 19 (T12).
- registry.cursor() in tests is TestCursor; cr.commit() raises;
use savepoints (T13).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Asserts the smart-button and Login Activity tab fields are stripped
from res.users get_view() for non-admin users, and present for
Settings admins. Locks down the contract behind the
groups="base.group_system" XML attributes on the form-inheritance
view (the inherited view record cannot carry groups itself per
CLAUDE.md rule #11; the gate must live on the inner nodes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5-min cron processes up to 100 pending rows per pass: private IPs
short-circuit to state=private_ip; same-IP cache (30 days) avoids
duplicate ip-api.com calls; reverse DNS via socket with 1.5s timeout;
HTTP lookup respects ip-api''s X-Rl rate-limit header. Tests cover
private-IP shortcut, cache hit (no HTTP), and internal-state skip --
no network calls needed.
Per-row isolation uses cr.savepoint() instead of cr.commit() because
Odoo 19 TestCursor raises AssertionError on commit/rollback. Recorded
the gotcha as CLAUDE.md rule #14.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds _fc_retention_gc() that deletes rows older than the configured
horizon (default 365 days; 0 = keep forever). Registered as a daily
ir.cron. Tests verify both the delete path and the "keep forever"
short-circuit.
Also documents the Odoo 19 gotcha that ir.cron dropped the numbercall
field (the legacy "-1 = run forever" pattern now raises ValueError at
install time; just omit the field).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mail template + helpers (_fc_alert_*, _fc_recent_failure_count,
_fc_send_failure_alert) wired into _check_credentials so that crossing
the consecutive-failure threshold within the window queues exactly one
mail.mail per attempted login per 60-minute cooldown. Master switch
x_fc_login_audit_alert_enabled honoured. Recipients are members of
base.group_system with a non-empty email and share=False; the
__system__ superuser is excluded by Odoo''s default user filter.
Tests (3 new, 22 total green):
test_failure_burst_queues_one_email
test_cooldown_suppresses_second_alert
test_alert_disabled_master_switch
setUp ensures base.user_admin has an email (fusion-dev''s admin user
ships without one; the only user with an email is __system__, which
is filtered out of standard res.users searches).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four x_fc_* fields on res.config.settings backed by ir.config_parameter:
retention_days (default 365, 0 = forever), alert_threshold (5),
alert_window_min (15), alert_enabled (True). New "Login Audit" block
on the General Settings page (gated by base.group_system on the block,
NOT on the inherited view record per CLAUDE.md rule #11).
CLAUDE.md gotchas added during this task:
#5 Boolean config_parameter fields don't round-trip "False" as a
string — IrConfigParameter.set_param deletes the row on falsy.
Test with assertFalse, never assertEqual(..., "False").
#6 ir.ui.view uses group_ids (Odoo 19 rename mirrored from res.users).
Setting groups_id on an ir.ui.view record raises ValueError at
install. (The XML attribute groups="..." on inner nodes is
unrelated and still works.)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
List, form, and search views for fusion.login.audit, plus a "Login
Events" full-history action and a "Failed Logins (24h)" pre-filtered
action. Both surface under Settings -> Technical -> Login Audit
(menu items gated by base.group_system). Views are no-create / no-edit
/ no-delete to enforce append-only at the UI layer too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds four x_fc_* fields on res.users: login_audit_ids (One2many),
login_audit_count (compute), last_successful_login (compute, stored),
last_login_ip (compute, stored). action_fc_view_login_audit returns
a window action scoped to the current user. View inheritance adds a
smart button to the button box and a "Login Activity" page to the
notebook, both gated by base.group_system on the inner XML nodes
(NOT on the view record — Odoo 19 forbids that; see CLAUDE.md rule #11).
Tests (2 new, 18 total green):
test_computed_last_successful_login — uses registry cursor to commit
the audit row so the stored compute picks it up across the
TransactionCase boundary.
test_action_view_login_audit_returns_window_action — smart-button
action shape + domain scoping.
CLAUDE.md rule #11 added: inherited ir.ui.view records cannot have
groups/group_ids on the record; the gate must be on the inner XML nodes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Overrides res.users._login. When the login string does not resolve to
any user, super() raises AccessDenied; we record a row with user_id=NULL
and failure_reason="unknown_user", then re-raise. Closes the gap where
typo'd or scanned logins would otherwise vanish from the audit trail.
The existing _fc_record_login_event helper writes through an independent
registry.cursor(), so the audit row survives the rollback that follows
the re-raised AccessDenied.
Note: in Odoo 19 _login is a plain instance method (not the classmethod
it was in earlier versions) and takes (credential, user_agent_env). The
original plan was written for the classmethod signature; corrected here
and recorded in CLAUDE.md rule #10 so future-Claude does not waste time
re-discovering it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps res.users._check_credentials. On AccessDenied, records a row with
result=failure and failure_reason='bad_password' (or '2fa_failed' when
credential['type'] == 'totp'), then re-raises. Regression test asserts
the attempted password value never lands in any audit field.
The audit row is written through registry.cursor() (independent cursor) so
it survives the rollback that follows AccessDenied — in production
odoo/service/model.py::retrying resets the transaction and http.py closes
the cursor without committing, in tests assertRaises opens its own
savepoint. Either way an inline write would vanish. Tests
enter registry_test_mode and use manual try/except to keep the audit row
visible across the savepoint hierarchy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Overrides res.users._update_last_login to create a fusion.login.audit
row with result=success after the parent runs. The write goes through
sudo() + mail_create_nolog=True. Any exception in the audit path is
caught and logged but never propagates — a broken audit table must
never block a real user from logging in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single helper builds vals for fusion.login.audit rows from the live
HTTP request, or falls back to ip=''internal'' + geo_lookup_state=''internal''
when there is no request. Parses UA into browser/os/device_type via the
bundled user_agents library. Never reads credential[''password'']. Tests
cover: no-request fallback, UA parsing on a Chrome/Windows UA, and the
regression that no password value leaks into the vals dict.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Record rule grants admins an unrestricted domain on the audit log;
ACL forbids write/create/unlink for every group (audit is append-only;
sudo() inside auth hooks is the only write path). Defence-in-depth
layering: ACL is the actual gate, the rule documents and locks down
admin access path.
Tests (5, all green) cover:
test_admin_can_read_through_acl_and_rule — positive path through both.
test_acl_blocks_read_for_regular_user — base.group_user denied by ACL.
test_acl_blocks_read_for_portal_user — base.group_portal share user
denied (sensitive data leakage
surface closed at ACL layer).
test_acl_blocks_write_for_admin — append-only at the write boundary.
test_acl_blocks_unlink_for_admin — append-only at the unlink boundary.
Drop the redundant `from . import tests` from the root __init__.py —
Odoo's test loader imports `odoo.addons.<mod>.tests` directly; the
extra import was dead weight (and inconsistent with the repo pattern).
CLAUDE.md gotchas added during this task:
#6 res.users.groups_id -> group_ids rename (test setUp pitfall).
#6 ir.rule `groups` is additive, not restrictive — group-scoped
rules only apply to users in that group, they do not restrict
non-members. Default to letting the ACL gate; use rules for
row-level filters ACLs cannot express.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- All 16 columns per spec (user, attempted_login, result, failure_reason,
event_time, ip/geo fields, user_agent triple, device_type, database).
- Check constraint binds failure_reason presence to result value.
- Three composite indexes (user+time, login+time, geo_state+time) supporting
the per-user, failure-burst, and geo cron queries.
- Minimal admin-read ACL added so subsequent tests can verify writes.
- 3 TransactionCase tests passing: model create, failure_reason nullable on
success, geo_lookup_state='internal' accepted.
Odoo 19 deprecation note: this implementation uses the declarative
models.Constraint and models.Index attributes (Odoo 19 silently drops the
legacy `_sql_constraints = [...]` list and `init()`/raw-SQL pattern with
only a warning). Captured in CLAUDE.md rule #9.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty installable module with manifest, package inits, and icon.
Subsequent tasks add the audit model, hooks, views, and tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Durable login audit for Odoo 19 (westin-v19). Captures successful and
failed authentications via _update_last_login / _check_credentials /
_login overrides, surfaces history on res.users as a smart button +
"Login Activity" tab (admins-only), async geo-enriches IPs via ip-api.com
through network_logger, 365-day retention with daily GC cron, and
emails Settings admins on N consecutive failures for the same login
within a configurable window.
Motivation: a spot audit of GSA Accounting (uid 63) showed Odoo's
res_users_log keeps only one row per user (rest is GC'd), /var/log/odoo
is empty (warn-level stdout logging), and the container json log
rotates within days — leaving no durable login trail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
docs(billing): session handoff — core on main, sub-project #2 (NexaCloud) next
Captures resume state for the centralized-billing initiative: core engine done
and on main, the 4-chunk decomposition of sub-project #2 (NexaCloud adapter +
dual-run reconciliation), the pending "where to start" decision, open questions,
and the test/branch workflow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
Add _match_api_key() class method to fusion.billing.service, with a
TDD test suite (TestServiceApiKey) covering key generation, hash storage,
positive match, and rejection of bad/inactive keys. Also fix
fcb_test_on_trial.sh to use --http-port 8070, as Odoo 19 forces
http_spawn() even under --no-http when --test-enable is set.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Local dev Odoo is Community (can't install the module). Add a guest-exec runner
that syncs the module to the odoo-trial Enterprise sandbox (VM 316, db trial) and
runs --test-enable there; pass = FCB_EXIT=0. Scaffold verified installing on
Odoo 19.0 Enterprise (7 fusion_billing_* tables created).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Centralize billing for all NexaSystems services (NexaCloud, NexaDesk,
NexaMaps, custom apps, memberships) on the Odoo 19 Enterprise instance,
replacing Lago. The module adds only the metering + integration layer;
native sale_subscription / account_accountant / payment_stripe do all the
financial work (invoicing, HST, dunning, portal, credit notes, Stripe).
Includes:
- Design spec (docs/superpowers/specs/2026-05-27-nexa-billing-centralized-design.md):
6 locked decisions, architecture, data model, usage engine, Lago-shaped
API, webhook control loop, NexaCloud pilot, phased dual-run migration.
- Module scaffold: 7 fusion.billing.* models (service, account.link, metric,
charge, usage, webhook, reconciliation), bearer-auth API controller shell,
security ACLs, README. Compiles on Odoo 19.0; engine/API bodies are stubs
pending the implementation plan.
- CLAUDE.md rule #15: no sale.subscription model in Odoo 19 — a subscription
is a sale.order(is_subscription) + sale.subscription.plan (verified live).
Task 0 verified: a single Stripe account is shared across NexaCloud and all
Lago providers, so no Stripe account/card migration is required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fp.step.template rows already held 'fa-bathtub' (1), 'fa-flag' (2),
and 'fa-undo' (2) — all plating-relevant and presumably valid in an
earlier version of the Selection list. When step_insert snapshot-
copied these into a fresh fusion.plating.process.node via
_copy_snapshot_fields, the ORM rejected them with
ValueError: Wrong value for fusion.plating.process.node.icon
because they weren't in the curated 39-icon list anymore.
Adding 'fa-bathtub' (bathtub / tank / soak), 'fa-flag' (flag /
milestone / gate), and 'fa-undo' (undo / rework / rerun) to the
process.node Selection. Aligns the two lists (template uses
_get_icon_selection -> node._fields['icon'].selection at runtime).
No data migration needed — existing template rows immediately
re-validate against the wider Selection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FpExpressActionBtns.onOpen called action_open_part which returned an
ir.actions.act_window dict without a 'views' key. Odoo 19's
_preprocessAction in the web client tries to .map over action.views
and throws TypeError: Cannot read properties of undefined (reading 'map').
Fix: include 'views': [[False, 'form']] alongside view_mode='form' on
both copies of action_open_part (wizard line + sale.order.line).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three view edits to surface the new cert toggles + workflow nudges:
1. res.partner — Plating Documents tab gains a "Aerospace / Defence"
separator + group with the three new toggles (Nadcap / MTR /
Customer-Specific). All boolean_toggle widget, default OFF.
2. fp.process.node — Recipe form gains a "Certificate Output" group
visible only when node_type == 'recipe'. Five requires_* toggles
+ a blue info banner explaining the suppress-only precedence.
3. fp.certificate — Certificate PDF tab gains a yellow alert banner
when certificate_type is one of the three orphan types AND no
attachment is set. Tells the operator "this type expects a PDF
you upload from disk".
Sub: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
Task: T6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Block fp.certificate.action_issue on Nadcap / Mill Test / Customer-
Specific certs when attachment_id is empty. These three cert types
are manual-attach only — operator uploads the supplier doc /
regulator-issued cert / filled customer template PDF before the
cert can be issued. Prevents shipping the customer an empty PDF.
_fp_render_and_attach_pdf gets an early-return guard so an orphan-
type cert never tries to render a CoC QWeb template.
Sub: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
Task: T5. Makes test_orphan_cert_issue_blocks_without_attachment pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites fp.job._resolve_required_cert_types as a documented three-step
pipeline:
Step 1 — partner + part flags (extended to read 3 new orphan-type
partner toggles: x_fc_send_nadcap_cert / x_fc_send_mill_test
/ x_fc_send_customer_specific)
Step 2 — recipe-level requires_* Booleans STRIP cert types from
the wanted set (suppress-only — never adds)
Step 3 — CoC + thickness bundling preserved (thickness collapses
into CoC PDF as page 2)
Field-existence guards on partner/recipe attribute reads keep the
resolver robust if the certificates / plating module schemas drift.
Recipe is suppress-only per Q1 locked decision: customer/part is the
ceiling, recipe can only remove. Test 3 (test_recipe_cannot_add_certs_
customer_didnt_want) is the explicit regression guard.
Sub: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
Task: T4. Makes the 5 resolver tests from T3 pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six failing tests in test_recipe_cert_suppression.py covering the
full design surface:
1. test_recipe_suppresses_thickness
2. test_recipe_suppresses_nadcap_for_commodity_part
3. test_recipe_cannot_add_certs_customer_didnt_want (suppress-only
regression guard — recipe can never add types customer didn't ask for)
4. test_part_override_coc_recipe_suppresses
5. test_all_orphan_types_propagate (4-element output + bundling)
6. test_orphan_cert_issue_blocks_without_attachment
These will all fail until T4 (resolver) and T5 (orphan-attach gate)
land. RED phase of TDD locked in via commit ordering.
Sub: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
Task: T3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds five requires_* Booleans on fusion.plating.process.node
(requires_coc, requires_thickness_report, requires_nadcap_cert,
requires_mill_test, requires_customer_specific), default True.
Recipe is SUPPRESS-ONLY: when False, the recipe never produces that
cert type even if the customer/part requested it. Default True =
existing recipes keep producing the same cert set they produce today.
Surfaced on recipe-level form (node_type == 'recipe'); resolver reads
from job.recipe_id which is always a top-level recipe node.
Post-migrate backfills NULL -> TRUE on existing nodes.
Sub: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
Task: T2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three Boolean fields (x_fc_send_nadcap_cert, x_fc_send_mill_test,
x_fc_send_customer_specific) to res.partner, default False. Wires
aerospace/defence customers into the existing cert resolver so the
three orphan fp.certificate.certificate_type values become reachable.
Post-migrate idempotently backfills NULL -> FALSE on existing rows.
Sub: docs/superpowers/specs/2026-05-27-recipe-cert-toggles-design.md
Task: T1 of the implementation plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>