Compare commits

...

352 Commits

Author SHA1 Message Date
gsinghpal
719853c251 fix(fusion_clock): leave From/To use CSS grid so they don't stack on iOS
iOS Safari date inputs have a large intrinsic min-width that can break a flex
row; switch .fclk-leave-daterange to grid 1fr 1fr + min-width:0 on the inputs
so the two fields always share the row and shrink. Also changes the bundle hash
to force iOS to drop the cached CSS. Live on entech 19.0.3.13.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:07:32 -04:00
gsinghpal
6a9c7c74ea feat(fusion_clock): multi-day leave requests (date range)
Request Leave now takes a From/To date range instead of a single day (the To
field is optional -> single-day). Added date_to to fusion.clock.leave.request
(start kept as leave_date), with overlap detection on submit and a date_to >=
leave_date constraint. The absence check and reports now treat a leave as
covering its whole span. The form shows two date inputs; the controller accepts
date_from/date_to (the old single leave_date payload is still honoured). A
migration backfills date_to = leave_date for existing rows.

Live and verified on entech 19.0.3.13.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:01:19 -04:00
gsinghpal
87639a12b5 fix(fusion_planning): add Schedule tab to the Payslips page navs
The Schedule tab is injected into the Clock/Timesheets/Reports navs via xpath
inherits, but the two payslip templates (portal_payslip_list_page,
portal_payslip_detail_page) had no inherit, so Payslips showed only 4 tabs.
Add the matching inherits. Verified on the rendered /my/clock/payslips page:
5 nav items incl. Schedule. Live on entech 19.0.1.4.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:50:12 -04:00
gsinghpal
360370db15 fix(fusion_clock): kill portal white border — neutralise .o_fp_portal_shell
Verified from the live DOM that fusion_plating_portal wraps the app in
#wrapwrap > main > .o_fp_portal_shell > .o_fp_portal_main > #wrap.o_portal_wrap
> .container. The white frame was .o_fp_portal_shell (+ .container max-width),
which my earlier wrapper-neutralisation didn't target. Add the shell + inner
main + force all wrappers transparent/full-width/no-padding under
body:has(.fclk-app). Live on entech 19.0.3.12.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:46:19 -04:00
gsinghpal
85bbd8a20e fix(portal): recover full-bleed wrapper fix + Schedule Payslips tab
These round-2 portal fixes (white-border wrapper neutralisation in
portal_clock.css, and the Payslips nav tab on the fusion_planning Schedule
page) were briefly bundled into a concurrent NFC commit that a parallel session
then rebased, dropping them from main. They are deployed and verified on entech
(fusion_clock 3.12.3 / fusion_planning 1.3.0); re-committing so git matches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:36:38 -04:00
gsinghpal
136a64ea21 fix(fusion_clock): enforce NFC card-UID uniqueness via declarative UniqueIndex
Odoo 19 silently ignores the legacy `_sql_constraints` list (repo CLAUDE.md
rule 9), so it never created a DB constraint — two employees could be assigned
the same x_fclk_nfc_card_uid and the NFC tap's search(limit=1) then picked an
arbitrary one. Replace it with a declarative models.UniqueIndex carrying a
partial WHERE predicate, so uniqueness is enforced only when a UID is set;
employees without a card keep sharing a blank/NULL value.

Makes test_nfc_models.TestNfcModels.test_card_uid_is_unique_when_set pass.
Verified on entech (DB admin): 0 pre-existing duplicate UIDs, full upgrade +
61/61 fusion_clock tests green, and the unique partial index
hr_employee_fclk_nfc_card_uid_unique now exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:34:14 -04:00
gsinghpal
a479052b72 fix(fusion_clock): portal white-border + responsive timesheet entries
- White border on every portal page: the .fclk-app full-bleed relied on exact
  negative margins to cancel the portal layout's container padding; when it
  didn't match, the white page chrome showed through. Match the PAGE background
  to the app (light #f3f4f6 / dark #0f1117, via body:has(.fclk-app)) so the
  gutter is invisible, and clip horizontal overflow.
- Timesheets not responsive: the 6-column table crammed/wrapped on phones.
  Replaced the table with stacked cards (date + net up top, in -> out, then
  break / location / Correct) that read cleanly at any width. Correction-link
  data attributes preserved; the xpath-inherited .fclk-nav-bar untouched.

Live on entech 19.0.3.12.2 (both rules verified in the served frontend bundle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:20:56 -04:00
gsinghpal
11108dfea3 Merge: employee portal — staff Clock + Payslips, customer-sidebar gating
Internal staff now land on /my/clock with no customer sidebar; new
finalized-payslip portal under /my/clock/payslips (inline paystub from
payslip.line_ids + PDF). Customers' portal is unchanged. Live on entech.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 22:02:28 -04:00
gsinghpal
85cdecddea fix(fusion_clock): keep inline nav bars instead of a shared template
A shared portal_employee_navbar template broke fusion_planning, which
xpath-inherits each clock page's inline fclk-nav-bar to inject its
Schedule tab (anchored on a[@href='/my/clock/timesheets']). Revert to the
original inline-nav pattern on all four pages and append the Payslips item
to each — zero changes needed in fusion_planning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:56:54 -04:00
gsinghpal
2aaa1a57e7 feat(fusion_clock): schedule-driven attendance automation
Reminders, absence detection, late/early penalties, and auto-clock-out are now
driven by each employee's real schedule (posted planner entry -> recurring
shift), never the global 9-5 default. Employees who aren't scheduled get no
reminders/absence. Overtime past the scheduled end is never cut off — auto
clock-out only fires at a max-shift safety cap (default raised 12 -> 16h). Team
leads build the planner in draft and Post it (publishes + emails employees).

- hr.employee._get_fclk_day_plan: explicit `scheduled` flag; posted-only planner
  entries (drafts ignored), else recurring shift covering that weekday, else
  not-scheduled; sources 'schedule'/'shift'/'none'.
- fusion.clock.shift: day_mon..day_sun weekday pattern + covers_weekday().
- fusion.clock.schedule: draft/posted state + posted_date; planner edits reset
  to draft; fclk_email_posted_week notification.
- Rewrote the reminder / absence / auto-clock-out crons: schedule-gated,
  per-employee savepoints, OT-aware cap, weekend hardcode removed.
- Penalties + all three clock-in paths skip days the employee isn't scheduled.
- shift_planner: Post Week route + planner Post button + draft count.
- Migration backfills pre-existing schedule entries to 'posted' so they keep
  driving automation after upgrade.
- Tests: resolver matrix, cron gating, OT cap; fixed the existing planner test
  for the new state/source semantics.

Design: docs/superpowers/specs/2026-05-30-schedule-driven-attendance-design.md
Frontend footprint kept at zero to avoid colliding with the concurrent
employee-portal (payslips) work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:54:05 -04:00
gsinghpal
b5d5a9acba fix(fusion_clock): build paystub from payslip.line_ids, not fusion_payroll fields
entech runs the enterprise hr_payroll module (not the custom fusion_payroll),
whose hr.payslip lacks employee_cpp/ytd_* fields. Render the inline paystub
from payslip.line_ids (name + total) so it works on any payroll provider.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:49:53 -04:00
gsinghpal
0d94af6532 fix(fusion_clock): render payslip PDF via report.id, not report_name
Odoo 19's _get_report() resolves a dotted string report_ref through
env.ref() as an XML ID, which lands on the QWeb view rather than the
ir.actions.report action. Pass the action id (matches every other
_render_qweb_pdf call site in the repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:45:05 -04:00
gsinghpal
95abd2e337 style(fusion_clock): payslip list/detail, 4-item nav, and sign-out styles
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:38:22 -04:00
gsinghpal
b1db851e29 feat(fusion_clock): add Payslips tab to employee nav + Sign Out in clock header
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:37:17 -04:00
gsinghpal
f18c59fe89 feat(fusion_clock): payslip list + inline paystub templates and shared employee navbar
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:35:55 -04:00
gsinghpal
2fb774e4fa feat(fusion_clock): portal routes for employee payslips (list / inline paystub / pdf)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:34:53 -04:00
gsinghpal
60c25f8241 feat(fusion_plating_portal): hide customer sidebar from internal staff + redirect them to the clock portal
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:33:12 -04:00
gsinghpal
47a6523e24 docs(employee-portal): implementation plan (5 build tasks + entech smoke)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:30:21 -04:00
gsinghpal
4a9f31cef5 docs(employee-portal): design spec for staff Clock + Payslips portal
Separate internal employees from the customer portal: suppress the
fusion_plating_portal sidebar for internal users, redirect them to the
clock page, and add a finalized-payslip view (inline paystub + optional
PDF) under /my/clock/payslips in fusion_clock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:22:27 -04:00
gsinghpal
dd908c3861 docs(fusion_clock): design spec — schedule-driven attendance automation
Posted-schedule/recurring-shift drives reminders, absence, penalties, and
auto-clock-out (never the global 9-5 default); overtime never cut (auto-close
only at a safety cap); team-lead draft->post workflow with employee notify.
Frontend footprint kept at zero to avoid colliding with the concurrent
fusion_plating employee-portal session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 21:17:56 -04:00
gsinghpal
5c1f60b3b8 changes 2026-05-30 20:59:59 -04:00
gsinghpal
55da42e91f fix(fusion_clock): hide the Odoo backend escape hatch on the NFC kiosk
The website module injects a fixed "frontend->backend" nav
(.o_frontend_to_backend_nav — the floating apps-grid/edit button) on every
frontend page for any internal user. Since the kiosk account is an internal
user, that button let a kiosk user tap through to the Odoo backend.

Hide it with a page-scoped inline style in the kiosk template head, so it's
suppressed only on /fusion_clock/kiosk/nfc and the real website keeps its nav.

Live as 19.0.3.11.8 (verified the rule is in the rendered template).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:35:50 -04:00
gsinghpal
ab3e6fa1e2 feat(fusion_clock): auto-wipe clock-in/out photos after a retention window
Privacy/space housekeeping for the kiosk verification selfies. A new daily cron
(_cron_fusion_wipe_old_photos) deletes the photo attachments on attendances
whose clock-in is older than fusion_clock.photo_retention_days (default 60).
Only the images are removed — attendance records, worked hours and penalties
are kept. Clearing the attachment-backed binary reclaims filestore space.

- Configurable in Settings → Fusion Clock → NFC Kiosk ("Auto-Wipe Photos After
  (days)"); set 0 to disable.
- Wipes all three photo fields (NFC check-in/out + legacy portal photo),
  batched with per-batch savepoints.
- tests/test_photo_retention.py covers wipe-old / keep-recent / retention=0.

Verified live on entech (19.0.3.11.7) via a rollback-only dry run: a 70-day
shift's photos were wiped (record + 8h hours preserved) while a 5-day shift's
photo was kept; nothing persisted. 0 attendances currently exceed 60 days.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:19:22 -04:00
gsinghpal
e2f7fa6d19 feat(fusion_clock): show NFC clock-in/out photos on the attendance form
The kiosk captures and stores a photo on every tap (x_fclk_check_in_photo /
x_fclk_check_out_photo on hr.attendance), but no view displayed those — the
form only showed the legacy portal field x_fclk_checkin_photo, so the NFC
photos were invisible in the UI. Add a "Verification Photos" group showing the
clock-in and clock-out photos (plus the legacy portal photo), each hidden when
empty. (The activity log has no image field — photos live on the attendance.)

Live as 19.0.3.11.6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:06:39 -04:00
gsinghpal
2c8ad83d43 fix(fusion_clock): NFC clock-out shows gross worked time, not net-of-penalty
The result card showed x_fclk_net_hours = worked_hours − break − early-out
penalty minutes. Tapping out before the scheduled end adds a 15-min early-out
penalty to the break field, so short shifts clamped to 0 → "Worked 0h 0m".

Show GROSS attendance.worked_hours (the actual clock-in → clock-out elapsed
time) instead, and format adaptively (Xh Ym / Ym / Ys) so brief shifts and
quick tests don't all read 0. Net-of-deductions stays in the payroll reports.

Live as 19.0.3.11.5 (verified worked_hours computes correctly in the DB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:59:10 -04:00
gsinghpal
3fd074ff6d fix(fusion_clock): kiosk photo now shows on clock + profile (right image fields)
Root-caused on live entech (not guessed):
- The kiosk runs as a non-HR operator (uid 141) who gets AccessError reading
  hr.employee images, so /web/image served a placeholder. Point the result-card
  avatar at hr.employee.public/avatar_128 — verified readable as the operator,
  returns the real photo. (Odoo's own UI uses .public for employee images.)
- The Odoo profile/preferences avatar is res.users → res.partner.image_1920,
  which the capture never wrote. Propagate the captured photo to the linked
  user's partner image so the profile updates too.
- Enlarge the capture oval (it was small): stage 62vh/520px, guide width 64%.

Live as 19.0.3.11.4. Also backfilled the existing test photo to the user's
partner image so the profile shows it without re-capturing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:29:46 -04:00
gsinghpal
e26a7cd9e8 feat(fusion_clock): NFC photo capture — 10s auto-capture, vertical oval, cache-busted avatar
- Profile photo DID save (verified: image_1920 attachment persists); the
  "doesn't update" was a browser-cache miss. Add ?unique=<write_date> to the
  result-card avatar URL so a freshly-captured photo shows on clock in/out.
- Capture now starts a 10-second countdown (time to get into frame) then
  auto-snaps; the button toggles to Cancel while counting.
- Face guide is now a VERTICAL oval (aspect-ratio 3/4) over a portrait stage —
  it was rendering horizontal. Faces are taller than wide.

Deployed live to entech (LXC 111) as 19.0.3.11.3; frontend bundle verified to
compile clean and contain the new rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:14:19 -04:00
gsinghpal
09cea73e50 fix(fusion_clock): SCSS compile error — replace CSS min() with width+max-width
Odoo's Sass compiler evaluates the built-in min() function and errors with
"Incompatible units: 'px' and 'vw'" on `width: min(86vw, 380px)`, which broke
the entire web.assets_frontend bundle (kiosk + all portal pages unstyled).
Equivalent, compiler-safe: `width: 86vw; max-width: 380px;`.

Verified: forced a fresh frontend bundle compile on entech — no Incompatible
-units error, served CSS contains the compiled --pin rule. Live as 19.0.3.11.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:48:29 -04:00
gsinghpal
3235d4ceca fix(fusion_clock): un-squeeze the NFC kiosk Manager PIN pad on tablets
The --pin panel used width:auto, so in the centred flex overlay it
collapsed to its content width and crushed the 3-column numpad. Give it
a definite width (min(86vw, 380px)) and make the keys proper tappable
squares (min-height 60px, 1.6rem font).

Deployed live to entech (LXC 111) as 19.0.3.11.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:43:54 -04:00
gsinghpal
5a488ae86e feat(fusion_clock): always-available kiosk photo action + compact manager PIN pad
NFC kiosk:
- Add "📷 Photo" action to every Manage-page employee row and to the
  post-enroll result card, so a manager can set/replace a profile photo
  at any time (previously only surfaced when the employee had no image).
- Slim the Manager PIN pad: dedicated --pin panel variant (max-width 360px,
  reduced padding) with a tighter numpad, removing the oversized whitespace.

Deployed live to entech (LXC 111) as 19.0.3.11.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:38:34 -04:00
gsinghpal
55898dd1d4 feat(fusion_clock): NFC kiosk — enrollment, manager page, sounds, lock, profile photos
Kiosk work across this session (19.0.3.6.0 -> 19.0.3.10.0):
- Program-from-unknown-tap: amber prompt -> Manager PIN -> pick/create employee
  -> binds the captured UID (no re-tap). Reassign moves a card between employees.
- Manager page (gear, when unlocked): search employees + tag status; assign/re-tag,
  clear tag, archive employee, + new employee. Server-gated by the enroll password.
- Screen lock: kiosk starts locked (tap-only); Unlock -> Manager PIN, Lock button;
  PIN remembered for the session so the gear never re-prompts.
- Sounds: pleasant + loud sine chimes (rising in / descending out) + a low "denied"
  tone for wrong/unknown taps. Gated by fusion_clock.enable_sounds.
- Guided profile-photo capture for employees with no picture (clock-in or enroll):
  live camera + oval face guide -> capture -> preview -> save to hr.employee.
- PIN no longer re-renders per digit; centered result card; 12h time; clock-out shows
  "Worked Xh Ym this shift"; modern clock idle icon; faster animations/result timers;
  session keep-alive so the kiosk login never expires.
- New endpoints: create_employee, clear_tag, delete_employee (archive), verify_pin,
  save_profile_photo; enroll gains force-reassign.
- Docs: fusion_clock is now developed in Claude Code (dropped Cursor references).

Spec/plan under fusion_clock/docs/superpowers/. Deployed live on entech
(odoo-entech / LXC 111 on pve-worker5), v19.0.3.10.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 17:21:33 -04:00
gsinghpal
2a16f80d8d feat(fusion_clock): kiosk app + Kiosk Operator role, full-screen PWA, app-integrated permissions
- PWA manifest on the NFC kiosk page so it installs as a full-screen
  home-screen app (Chrome "Install" / Safari "Add to Home Screen").
- Dedicated "Kiosk Operator" permission + gated "Fusion Clock Kiosk"
  top-level app (act_url -> /fusion_clock/kiosk/nfc). Kiosk controllers
  accept Manager OR Kiosk Operator; all kiosk data ops already run sudo.
- Fix 403: read the company kiosk location via sudo on page-load and tap
  (Kiosk Operator has no fusion.clock.location ACL).
- Odoo 19 permissions UX: ir.module.category + res.groups.privilege so
  User/Team Lead/Manager and Kiosk Operator appear as application-access
  dropdowns on the user form (no developer mode). Short group display names.
- Docs: note res.groups.privilege as the Odoo 19 category_id replacement.

Deployed live to entech (odoo-entech / LXC 111 on pve-worker5). v19.0.3.6.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:51:14 -04:00
gsinghpal
cecc699a70 fix(plating): trim default SO order-line columns to the plating set
Default-hide six order-line columns that aren't part of the plating
view (Product/product_template_id, Description Template, Specification,
Delivered Qty, Invoiced Qty, Taxes) by flipping them to optional="hide".
They stay available via the optional-columns toggle. Default-visible set
is now Customer-Facing, Part, Process/Recipe, Thickness, Serial, Job #,
Effective Deadline, Qty, Unit, Unit Price, Amount — for both quotations
and sales orders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 00:42:20 -04:00
gsinghpal
4949856336 fix(plating): drop Specification + Delivery Date from customer reports
Remove the unused Customer Specification field and the redundant
Delivery Date (Lead Time covers it) from the customer-facing SO
confirmation and invoice PDFs (portrait + landscape). SO info row goes
5->4 columns (Delivery Date gone); the Customer Job # / Spec / Delivery
Method row goes 3->2 (Spec gone). Internal docs (traveller, sticker) and
the CoC process "Specification(s)" section are left untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 00:19:40 -04:00
gsinghpal
9826e03b4e fix(plating): show additional charge under subtotal on SO + invoice PDFs
Tooling/additional charge lines (any product line with no part catalog)
no longer print in the parts table — they render in the totals block
under the subtotal with their entered label + amount. Subtotal is now
parts-only; tax + grand total are unchanged (the charge is still a real
taxed line in the data). Applies to SO confirmation and invoice, both
portrait and landscape. Also aligns the invoice S/N cell to the SO's
multi-serial rendering.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 00:04:33 -04:00
gsinghpal
69aa6b050b style(plating): subtle plum accents + gradients on the express order form
Add a cohesive, restrained colour layer using the existing express
tokens (light/dark aware): faint plum gradient washes on the PO card,
legend bar, table header, and Order Summary header; filled accent
gradient pills (EXPRESS / CAD); accent rules on the section title,
summary header, and Grand Total footer. Adds an $xpr-accent-tint token
plus four composed gradient tokens.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:12:46 -04:00
gsinghpal
5675784916 fix(plating): stop charge/tax pickers collapsing in order summary
The right-aligned value column squeezed the Additional Charge and Tax
dropdowns to a sliver. Move each picker into the (wider) label column,
stacked under its label at full width, so every value cell is now a
single amount that lines up cleanly in the right column under the
vertical divider.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 23:01:14 -04:00
gsinghpal
0d4a871d0c style(plating): add vertical column divider to order summary table
Switch the summary rows from a flex space-between layout to a fixed
two-column grid (label | value) so a vertical divider on the label
cell's right edge lines up across every row. Values are right-aligned
into a clean amount column; the Grand Total footer keeps the divider at
the heavier rule weight.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:54:52 -04:00
gsinghpal
aac95ee16b style(plating): express order summary as a bordered table
Restyle the Order Summary card into a clean bordered table — a tinted
"Order Summary" caption bar, a divider line under every row, and an
accent-tinted Grand Total footer with a strong top rule. Uses the
existing light/dark express tokens so it renders correctly in both
colour schemes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:49:11 -04:00
gsinghpal
028814b292 fix(plating): order-level Lot Order toggle replaces per-line lot checkbox
Express order entry now has a single "Lot Order" toggle on the header
instead of a per-line "Lot" checkbox. When on, every line shows Lot
Total and prices as a flat lot (unit price derived = lot total / qty,
qty preserved for production); when off, the Lot Total column is hidden
and lines price per unit as usual. Keeps the order summary clean for the
common per-unit case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:44:07 -04:00
gsinghpal
2bd0672b52 fix(configurator): lot pricing robust in totals + SO-create (not reliant on onchange)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:48:43 -04:00
gsinghpal
dc1dacddc2 feat(configurator): express summary — charge type + tax type + lot column 2026-05-29 21:42:42 -04:00
gsinghpal
6dde3ec2b1 feat(configurator): SO-create applies one tax to all lines + typed charge line
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:40:52 -04:00
gsinghpal
a2ac804238 feat(configurator): per-line lot pricing (derive unit price, keep qty) 2026-05-29 21:38:49 -04:00
gsinghpal
f8929eb686 feat(configurator): totals = one tax on (subtotal + charge) 2026-05-29 21:37:00 -04:00
gsinghpal
a07a5f931a feat(configurator): wizard charge_type_id + charge_amount + order-level tax_id 2026-05-29 21:34:47 -04:00
gsinghpal
c6022c70f9 feat(configurator): fp.additional.charge.type model + config menu + seed 2026-05-29 21:32:49 -04:00
gsinghpal
7efaadc1c1 docs(plating): implementation plan for charge type + order-level tax + lot pricing
Bite-sized TDD plan: charge-type model + config UI, wizard charge/tax fields,
totals = one tax on (subtotal+charge), per-line lot pricing, SO-create tax on
all lines + typed charge line, and the express summary/line view changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:25:25 -04:00
gsinghpal
21300db8e8 docs(plating): spec — configurable charge type + order-level tax + lot pricing
Direct/Express order entry: a searchable/creatable fp.additional.charge.type
replaces the fixed Tooling Charge; one order-level account.tax applies to
(subtotal + charge); per-line lot pricing (flat lot total, derived unit price,
qty preserved). Reordered summary. Quotes out of scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 21:17:55 -04:00
gsinghpal
1e9ffccd6b feat(invoicing): managers (+QM+Owner) can create customer invoices
Grant Odoo Billing (account.group_account_invoice) to group_fp_manager via
implied_ids; Quality Manager + Owner inherit it. Billing only (not Accountant);
the SO-origin workflow gate in fusion_plating_jobs is unchanged, so managers
invoice from the Sale Order's Create Invoice action. Tests assert Manager/Owner
get Billing and Shop Manager does not.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 20:33:40 -04:00
gsinghpal
b2186ab032 feat(configurator): Description History list on the part Descriptions tab
Read-only per-part version history (version#, reference, customer-facing,
order, by/when) below the curated templates list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:57:57 -04:00
gsinghpal
855b160752 feat(configurator): auto-load latest part description version on order entry
Wizard line (direct + express) and SO line now pre-fill BOTH internal +
customer-facing from the part's latest version (fallback to
default_specification_text), without clobbering typed text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:57:16 -04:00
gsinghpal
da7ec59474 feat(configurator): save a part description version on SO confirm
Each part-bearing line writes a deduped version (final order# + date) via
_fp_save_description_version, after the parent-number rename so the title
reflects the confirmed order number.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:56:01 -04:00
gsinghpal
2ed3dcee58 feat(configurator): per-part description version model + part load/save helpers
fp.part.description.version: immutable per-part snapshots with version_no/
is_latest maintained in create(), titled "<SO#> · <date>". fp.part.catalog
gains description_version_ids + _fp_resolve_line_descriptions (load latest,
fallback to default_specification_text) and _fp_save_description_version
(dedup + sync default). ACL mirrors fp.sale.description.template.

Tests deferred to entech (local Docker unavailable this session).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:55:14 -04:00
gsinghpal
9b18f77e06 docs(plating): implementation plan for per-part description history
Bite-sized TDD plan: version model + part load/save helpers, save-on-confirm
hook, wizard + SO-line auto-load, and the part Descriptions-tab history list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:51:10 -04:00
gsinghpal
1ae83e187e docs(plating): spec — per-part description history (auto-version on order entry)
Dedicated fp.part.description.version model: latest auto-loads both internal +
customer-facing into a new order line; on SO confirm, a changed description
saves a new version titled "S#### · date". Browsable per-part history;
default_specification_text kept synced. SO surfaces only (not quotes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 19:38:20 -04:00
gsinghpal
1b0657bd76 fix(configurator): drop the first-time-part "no saved specification" popup
The order-line onchange still auto-ticks "Save as Default" (so a new part's
spec is remembered next time) — only the explanatory popup is removed, per
client request. The ticked checkbox on the line is the cue now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 12:11:34 -04:00
gsinghpal
f75e082e67 feat(shopfloor): tablet Shipping panel on the Job Workspace
Carrier/service/weight inputs + Generate Label + Mark Shipped, shown when the
job is awaiting_ship and gated read-only ("Waiting on: WO-xxxx") until every
job on the order is ready. Reuses workspace card tokens; dark-mode accent
override included.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:19:51 -04:00
gsinghpal
f1273798cd feat(shopfloor): tablet shipping endpoints + /load shipping payload
generate_label (sudo'd FedEx machinery) and mark_shipped (as the technician),
both enforcing the order-level ship-together gate. /load now returns a
shipping block (carrier/service/weight + readiness + any existing label)
when the job is awaiting_ship.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:16:36 -04:00
gsinghpal
bb814a46ff feat(jobs): order-level ship-readiness helpers
_fp_order_ship_state + _fp_mark_order_shipped enforce spec D4 ship-together:
the order ships only when every active job on it is awaiting_ship/done.
Shared by the tablet shipping endpoints and /fp/workspace/load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:14:36 -04:00
gsinghpal
be7256ce4c feat(logistics): technicians can create/edit delivery, POD, chain-of-custody
Spec D5 delivery-completion set. Dispatch records (route/vehicle/pickup)
stay read-only for technicians.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:13:11 -04:00
gsinghpal
d37f10f1c3 feat(receiving): technicians can count+close receivings from the tablet
ACL: grant group_fp_technician write+create on fp.receiving / line / damage.
sudo the internal sale.order x_fc_receiving_status write so a non-privileged
technician isn't blocked inside action_mark_counted / action_close.

Tests deferred to entech (local Docker unavailable this session).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:11:39 -04:00
gsinghpal
b98ee8a6fb docs(plating): implementation plan for technician receiving + shipping tablet
Bite-sized TDD plan across receiving ACL+sudo, delivery ACL, fp.job
ship-readiness helpers, shipping endpoints, and the workspace shipping
panel. Also patches the spec to record the sale.order status-write sudo
fix found during planning.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:50:23 -04:00
gsinghpal
df0de97a68 docs(plating): spec — technician receiving + shipping from the workstation tablet
Design for letting Technicians receive a confirmed order and ship a finished
order from the fp_job_workspace tablet surface. Receiving is ACL-only (the
panel + endpoints already exist); shipping adds a workspace panel + two
sudo-backed endpoints (generate label, mark shipped) gated on all order jobs
being awaiting_ship.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 00:35:40 -04:00
gsinghpal
49a0a953e5 fix(plating): single bilingual CoC — remove the separate French print action
The CoC body now renders English + the French translation together, so the
separate "Certificat de Conformité (Français)" print option was redundant.

- Removed the action_report_coc_fr report action and the now-dead
  report_coc_fr template; renamed action_report_coc_en to "Certificate of
  Conformance" (print filename "CoC - <name>").
- fp_notification_template: dropped the per-partner-language EN/FR branch —
  CoC email attachments always render the single bilingual action_report_coc_en.
- fp_hide_default_reports: dropped the FR sequence record.
- Refreshed the report_coc.xml design note.
- Bump reports 19.0.11.32.0, notifications 19.0.7.1.0.

Deployed on entech (-u removed the orphan FR action + template). Verified the
cert Print menu now lists only "Certificate of Conformance" and it renders
clean. The dead action_report_coc_fr ref in the uninstalled
fusion_plating_bridge_mrp is left as-is (module not loaded).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:33:00 -04:00
gsinghpal
64eb34cdff fix(plating): CoC signer follows Settings "Certificate Owner" (no stale freeze)
Changing Settings -> Certificate Owner didn't move existing certs: the signer
was snapshotted from the company owner at cert-creation time, and the CoC
prefers that snapshot over the live owner.

- _fp_create_certificates no longer freezes the company owner into
  certified_by_id; it snapshots ONLY a deliberate per-spec signer. Empty
  certified_by_id then resolves the LIVE company owner in the CoC report.
- action_issue lazy-fill made robust: resolves the company via the SO /
  env.company (fp.certificate has no company_id) so it fills the CURRENT
  owner at issue and the "Certified By" gate still passes.
- Settings help text corrected: signature comes from the user's Plating
  Signature (Preferences -> My Profile), not "HR Employee".
- Data fix on entech: cleared certified_by_id on 5 stale draft CoCs with no
  per-spec signer so they follow the current owner.

Bump certificates 19.0.9.3.0, jobs 19.0.11.4.0. Verified: CoC-30058 resolves
signer = Garry Singh (has Plating Signature), renders clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:20:38 -04:00
gsinghpal
cd0c08f348 fix(plating): parse Fischerscope .doc/.docx/RTF dropped on the cert form
The cert form's x_fc_local_thickness_pdf field only stored the upload; only
the Issue Certs wizard parsed it. Add create/write hooks on the jobs-side
fp.certificate that, when a NON-PDF is written to that field, run the wizard's
parser: readings -> thickness_reading_ids, header metadata -> x_fc_thickness_*,
microscope image (RTF) -> x_fc_thickness_image_id, then relocate the source to
x_fc_local_thickness_evidence_id and clear the PDF field (mirrors the wizard's
non-PDF end state). Real PDFs pass through untouched for the page-2 merge.
Re-entry guarded via the fp_skip_thickness_parse context flag. Bump jobs
19.0.11.3.0.

Deployed + verified on entech: CoC-30065 (.doc) back-filled to 3 readings +
metadata (operator BK) + extracted microscope image, renders inline (242KB);
PDF cert CoC-30040-02 correctly left untouched.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:01:02 -04:00
gsinghpal
6a5364e053 fix(plating): compact CoC first column + 3-line part data
- Column titles now render inline "English / French" on one line (was
  stacked), cutting header height. First column drops "Line Item": it is
  now Part Number / No. de pièce, Description / Description, Serial Number /
  Numéro de série with a tight line-height.
- First-column DATA shows three lines — part number, part name, serial
  number — via new fp.certificate._fp_resolve_part_identity() (part name
  from the job's part catalog, serials from the matching SO line; blanks
  fall back to "-"). Bump certificates 19.0.9.2.0, reports 19.0.11.31.0.

Deployed + verified on entech (CoC-30059: ('9876699373',
'VALVE BODY - COMPLETE - ASSY', ''), 243KB render).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:28:04 -04:00
gsinghpal
ec78fc148d feat(plating): fully bilingual CoC — all labels EN/FR
Convert every remaining CoC label from single-language (is_fr branch) to
bilingual EN/FR: document title, customer block (Name / Address / Contact /
Email / Phone), Fischerscope thickness report title + metadata (Equipment /
Product / Application / Directory / Calibration Std. / Operator / Measured /
Measuring Time), reading stats (Mean / Std Dev / Range), Source file,
Certified By, Name, and the Certification Statement heading. The statement
paragraph now prints both English and French. Reuses the SO report's inline
.fp-bl-en/.fp-bl-fr bilingual classes. Bump reports 19.0.11.30.0.

Deployed + render-verified on entech (CoC-30059 with thickness block, 243KB).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:11:40 -04:00
gsinghpal
9d9be17542 feat(plating): bilingual EN/FR column titles on the CoC
Make every CoC classic-body column title bilingual — English (bold) over
the French translation (italic grey), matching Steelhead and the SO
report's stacked-header convention. Cert-info headers (Date of
Certification / Generated By / Work Order #) and line-item headers
(Process / Customer PO / Shipped / NC Qty / Customer Job No.) now show
both languages. First column carries Part Number / Line Item,
Description, and Serial Number, each translated. Bump reports 19.0.11.29.0.

Deployed + render-verified on entech (CoC-30065, 224KB).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:54:45 -04:00
gsinghpal
1d1bbfe612 fix(plating): border the CoC signature/statement table
My prior change removed the .cert-statement-box border but the signature +
statement table was never bordered, leaving the whole section borderless.
Add class="bordered" so the two main columns (Certified By | Certification
Statement) get the outer box + divider like the other report tables; the
statement text keeps no separate inner box. Bump reports 19.0.11.28.2.

Deployed on entech (fusion_plating_reports upgrade clean).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:37:28 -04:00
gsinghpal
b1257b6983 fix(plating): remove border around CoC certification statement
The .cert-statement-box border was redundant next to the bordered tables;
render the statement as plain text (padding 0). Bump reports 19.0.11.28.1.

Deployed on entech (fusion_plating_reports upgrade clean).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:32:53 -04:00
gsinghpal
687decca28 fix(plating): clean up CoC layout — drop empty logo column + separating lines
- Customer block: remove the (usually empty) customer-logo third column;
  Address | Contact now split 50/50.
- Remove the heavy header bottom border (the Sale Order header has none) and
  the hr.heavy rule between the customer block and the cert info table.
- Drop now-dead CSS (.fp-coc h1, hr.heavy, .customer-logo). Bump reports to
  19.0.11.28.0.

Deployed + render-verified on entech (CoC-30065, 222KB PDF, no QWeb errors).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:25:32 -04:00
gsinghpal
307afbf3c0 feat(plating): CoC spec-optional + SO-style header + thickness for any cert
- Drop the hard spec_reference gate on fp.certificate.action_issue. The
  customer-facing description (_fp_resolve_customer_facing_description,
  walks job -> SO line, reuses fp_customer_description) now drives the CoC
  Process column; spec_reference prints only when an estimator fills it.
- CoC EN/FR reports swap web.external_layout for fp_external_layout_clean +
  paperformat_fp_a4_portrait. New shared coc_header (company logo + address
  left, Nadcap logo centre, title + Code128 barcode right) mirrors the Sale
  Order header. Removed the 3-logo Nadcap/AS9100/CGP accreditation strip and
  the body H1s; padding-top 0 on both body wrappers.
- Un-gate the Issue Certs wizard thickness upload (was invisible unless the
  customer was thickness-flagged) so a Fischerscope report can be attached to
  ANY cert; merge (page 2) + inline readings already render unconditionally.
- Update issue-gate tests, bump versions (certificates 19.0.9.1.0,
  reports 19.0.11.27.0, jobs 19.0.11.2.0), record CLAUDE.md rule 14c.

Deployed + render-verified on entech (CoC-30065, 223KB PDF, no QWeb errors).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:17:09 -04:00
gsinghpal
fecd2415f6 changes 2026-05-27 19:23:17 -04:00
gsinghpal
e36318f7a5 feat(billing): Stripe/Lago-verified go-forward sync + activate daily cron
The NexaCloud->Odoo ledger now verifies every new invoice against its
SOURCE billing system before posting, instead of trusting NexaCloud's
unreliable created_at/status/paid_at:

- _fc_verify routes by stripe_invoice_id prefix (in_ -> Stripe REST,
  lago: -> Lago REST) and returns source-truth
  {invoice_date, void, draft, paid, paid_at, amount_paid}, or None when it
  can't be determined/reached (left for the next run).
- _ingest_invoices(post=True, verified=...) uses the source invoice date
  (and accounting date), and reconciles a payment ONLY when the source
  confirms paid.
- _cron_sync_verified posts only finalized invoices; skips void + draft,
  logs unverified for retry. Replaces the old _cron_ingest_recent.

Cron cron_fc_invoice_ledger is enabled daily on nexamain. First live run:
23 already-posted, 1 void + 2 Stripe drafts + 5 zero-amount all skipped,
0 new posted, ledger intact at $3,403.46.

Tests: routing/guards (no network), verified date+reconcile, and the cron's
void/draft/unverified filtering (sources patched). FCB_EXIT=0 on odoo-trial.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:37:36 -04:00
gsinghpal
feddca19d6 docs(billing): record verified backfill (Stripe+Lago) + go-forward verification caveat 2026-05-27 17:57:27 -04:00
gsinghpal
95378ff1da fix(billing): skip zero-amount invoices (no lines) — drop empty move, don't post nothing 2026-05-27 17:33:36 -04:00
gsinghpal
c8529b8a99 feat(billing): post + reconcile only PAID invoices, keeping original dates
_post_and_reconcile_paid: for invoices NexaCloud marks paid, set the ledger
entry's invoice_date AND accounting date to the original NexaCloud date,
post, then reconcile the Stripe payment dated to the actual paid_at. Unpaid
invoices stay draft. Per-invoice isolated. 76 tests green on odoo-trial.
2026-05-27 17:29:41 -04:00
gsinghpal
7a66d7849d fix(billing): name ledger partners by company, not the NexaCloud user's full_name
One operator (e.g. "Gurpreet Singh") manages several distinct customer
businesses; naming partners from full_name mislabeled Mobility Specialties
Inc and Apex Vita Corporation as "Gurpreet Singh". Read the company field,
name the partner by company (mark is_company), and rewrite existing partners
so prior full_name-based names are corrected on re-ingest. 75 tests green.
2026-05-27 17:24:48 -04:00
gsinghpal
9ad09c32b0 fix(billing): robust shadow prune (charges before products + archive fallback) 2026-05-27 17:02:43 -04:00
gsinghpal
6b63df8c3d fix(billing): ledger live-run fixes — UUID cast, UTF-8, reconciling line
Surfaced by the nexamain dry-run against real data:
- reader: cast invoice_items.invoice_id::text (uuid = text[] mismatch).
- readers: set_client_encoding('UTF8') — invoice descriptions contain "×".
- ingest: add a balancing line when invoice.subtotal != sum(items). 9 paid
  base-plan invoices store the charge in subtotal with NO invoice_items, so
  itemized ingestion under-recorded revenue by ~$1,143 (37%); the reconciling
  line makes the Odoo invoice total match what Stripe billed.
74 tests green on odoo-trial.
2026-05-27 16:57:00 -04:00
gsinghpal
72d3130c88 feat(billing): NexaCloud invoice ledger — ingest invoices to account.move
Odoo becomes the accounting SoR by ingesting NexaCloud's real Stripe
invoices (read-only via the existing DSN) into native account.move
customer invoices: per-service-family income accounts, tax derived to
match the source invoice.tax, Stripe payments reconciled via
account.payment.register (invoice shows paid), idempotent on
x_fc_nexacloud_invoice_id, draft-first with bulk-post + a daily cron
(inactive). Plus a prune helper for the now-obsolete metered shadow data.
73 tests green on odoo-trial. Account codes use dots (Odoo 19 rejects '-').
2026-05-27 16:50:31 -04:00
gsinghpal
f6518b4d7e docs(billing): TDD plan for NexaCloud invoice ledger (ingest -> account.move, posted+reconciled+HST) 2026-05-27 16:44:21 -04:00
gsinghpal
bf6ee2bb2c docs(billing): design spec — NexaCloud invoice ledger (Odoo as accounting SoR)
Pivot from recompute-metered-billing to INGEST NexaCloud's real Stripe
invoices into Odoo account.move (posted + payment-reconciled + HST), driven
by the dual-run finding that 94% of NexaCloud revenue is Stripe service
invoices + add-ons + proration outside the per-deployment/CPU model. Full
accounting SoR, all history + ongoing, revenue split by service family,
draft-first rollout. Build/test on trial; reuses the read-only DSN + partner
mapping. Supersedes the metered direction for NexaCloud (engine kept inert).
2026-05-27 16:33:46 -04:00
gsinghpal
077f898283 chnages 2026-05-27 16:12:22 -04:00
gsinghpal
779539d1b5 docs(billing): dual-run stand-up results — shadow import done, reconciliation 2 match / 7 delta (stopped before flip) 2026-05-27 15:57:54 -04:00
gsinghpal
34a65f9c4a fix(fusion_helpdesk_central): chatter notice no longer collapsed; adds summary
Previous engagement notice used <blockquote> to style the findings
quote. Odoo's mail.thread renderer auto-tags every <blockquote> with
data-o-mail-quote-node="1" and the chatter UI then HIDES the content
behind a "..." widget — exactly the wrong UX since the findings are
the load-bearing content, not throwaway quoted text. Swapped both
quote blocks for styled <div>s with the same visual treatment (left
border, light background, padding) so they render fully inline with
no toggle.

Also expanded the notice to mirror more of what the owner sees in the
engagement email: now includes BOTH "Our reply" (the findings) and
"Summary sent to the owner" (the AI summary). The employee can see
the full context being used for the decision, not just the engineer's
reply. Skipped the Original Request section because the employee
wrote it themselves — would just clutter the thread.

white-space:pre-wrap preserves multi-line findings/summaries that the
engineer typed with line breaks. The two sections are visually
distinct: findings in light blue (matching the email's "Our Reply"
treatment), summary in light grey (matching "Summary for the
Decision" in the email).

Verified live on ticket #54: new message body has no <blockquote>,
no data-o-mail-quote attribute, and contains both section headers
with their content rendered inline.

Bumps fusion_helpdesk_central to 19.0.2.4.2.
2026-05-27 15:36:46 -04:00
gsinghpal
97cce8c755 docs(billing): record NexaCloud surgical deploy (inert; .env + Cursor WIP preserved) 2026-05-27 15:32:15 -04:00
gsinghpal
fe98fadf61 fix(fusion_helpdesk_central): engagement now posts public chatter for employee
Sending an engagement triggered template.send_mail(), which logged the
outbound email to the chatter as a `notification` message with the
internal `Note` subtype. That's correct for nexa-side bookkeeping (we
don't want the raw email body propagating to the customer), but it
meant nothing public was posted — so the entech-side My Tickets inbox
showed no activity. The employee couldn't tell their request had been
escalated for approval.

_fc_reset_engagement now posts a follow-up public message via
message_post (subtype mail.mt_comment, message_type='comment') with:

   Awaiting owner approval from <owner_name>.
  Their decision will appear here when they reply.

  Our reply:
  > <findings text>

This survives the entech _public_messages filter (comment +
non-internal subtype) and propagates to the employee's My Tickets
thread, giving them context AND the engineer's reply without exposing
the raw outbound email or the owner's email address.

Smoke-tested live on ticket #54: re-engaged with the same owner, the
new mail.message (id=348213) is subtype=Discussions / internal=False /
message_type=comment, and contains both the awaiting-approval notice
and the findings text. _public_messages would surface it.

Bumps fusion_helpdesk_central to 19.0.2.4.1.
2026-05-27 15:31:58 -04:00
gsinghpal
32c7026558 feat(fusion_helpdesk_central): owner email shows 3 sections — Request / Reply / Summary
The owner only saw the AI summary, which was a paraphrase of the user
report — they couldn't see the actual request OR what we said back.
Restructure the engagement email into three sections so the owner can
read the conversation and not just the AI's take:

  1. Original Request (from the reporter) — ticket.description, no
     longer buried in a <details> collapsible at the bottom
  2. Our Reply — the wizard's "Your Findings" text, now persisted on
     the ticket so the email template can render it directly. This is
     the engineer's analysis / response to the request.
  3. Summary for the Decision — the AI-generated brief

Approve / Reject buttons stay below all three. Bulk email mirrors the
same per-card structure.

New ticket field x_fc_engagement_findings (Text, copy=False) stores
the findings at send-time so they survive as audit history. Wizard's
_action_send_single / _action_send_bulk pass findings into
_fc_reset_engagement; bulk uses per-line findings + per-line summary.

Mail templates are in <data noupdate="1"> so a plain -u doesn't
re-import them. Pre-migration in migrations/19.0.2.4.0/pre-migration.py
deletes the existing template records + ir_model_data so the upgrade's
data load re-creates them with the new body_html. Pre- (not post-)
because data load happens between the two phases.

Smoke-tested live on nexa: rendered template HTML contains all three
section headers at the expected positions with their expected content
markers (ORIGINAL FROM RIYA in Original Request, REPLY-FROM-GURPREET
in Our Reply, the summary text in Summary for the Decision).

Bumps fusion_helpdesk_central to 19.0.2.4.0.
2026-05-27 15:26:26 -04:00
gsinghpal
76866a7c76 fix(fusion_helpdesk_central): wizard dialog closed on Generate Summary click
Previous fix (return True from action_generate_summary) prevented the
self-id crash but introduced a worse regression: Odoo's web client
auto-closes target='new' modals on any non-action return — the
"wizard done" convention. So Generate Summary updated the field and
then immediately killed the dialog, leaving the user with no chance
to click Send Engagement.

The only Odoo-19 idiom that reliably keeps a wizard dialog open
across a button click is to return an act_window dict that re-opens
the same wizard record (res_id=self.id). That was the original
approach — it crashed because of the active_id self-id collision in
default_get. With the active_model='helpdesk.ticket' guard now in
place (from 0104e877), the re-open is safe.

Belt-and-suspenders: the re-open action passes context={} explicitly,
so even if a future change to default_get drops the active_model
guard, there's no parent-form active_id leaking in to confuse the
ticket lookup. The wizard record is loaded by res_id directly; Odoo
19 doesn't call default_get for record loads, only for new-record
creation.

Centralised the re-open logic in _reopen_action so single + bulk
modes share the same code path.

Bumps fusion_helpdesk_central to 19.0.2.3.4.
2026-05-27 15:12:33 -04:00
gsinghpal
f19ca02e05 docs(billing): record odoo-nexa deploy (installed, inert); NexaCloud deploy blocked on Cursor WIP 2026-05-27 15:12:16 -04:00
gsinghpal
1f5eaf0386 docs(billing): handoff update — sub-project #2 complete (2a/2d shipped, 2b/2c code-complete) 2026-05-27 14:52:28 -04:00
gsinghpal
a82f09ea50 fix(billing): reconciliation review fixes — per-subscription key, IDOR guard
- CRITICAL: reconciliation upsert keyed on (service, partner, period) collided
  when one customer has two deployments (two subs) in a period — the second
  overwrote the first. Add external_subscription_id to the model + a
  UNIQUE(service_id, external_subscription_id, period) constraint, and key the
  upsert per subscription. New test proves two subs for one partner keep two rows.
- raise a clear error if the nexacloud service is missing (was a confusing
  per-row failure).
- _fc_resolve_subscription: the integer fallback no longer reaches a different
  service's tagged subscription (latent multi-service IDOR); live untagged subs
  stay resolvable and the partner-link authz is unchanged.
Full suite green on odoo-trial.
2026-05-27 14:51:43 -04:00
gsinghpal
a5144a925c feat(billing): /usage resolves subscription by source app id (enables 2b)
_api_record_usage now resolves the target subscription via the source
app's own id (x_fc_nexacloud_subscription_id, scoped to the service)
before falling back to a direct Odoo sale.order id. This is what lets
NexaCloud push usage against the shadow subscriptions the importer
created from NexaCloud UUIDs — closing the flip-day mapping gap the
review flagged. Authz unchanged (partner must be linked to the service).
2026-05-27 14:37:30 -04:00
gsinghpal
2bdf4ef6a0 feat(billing): 2d dual-run reconciliation (Odoo-computed vs NexaCloud-actual)
fusion.billing.reconciliation gains the compute: _compute_reconciliation
(flat + charge overage vs external, status match/delta at a tolerance) and
_reconcile_rows (resolve shadow sub -> flat + charge, upsert one row per
service/partner/period, per-row isolated). The wizard gains a read-only
_read_reconciliation_rows (NexaCloud usage cpu_hours*3600 + invoice-item
subtotals per YYYY-MM) and a "Run Reconciliation" button. 2a amended to
stamp x_fc_nexacloud_plan_id on shadow subs so reconciliation can find the
charge. Read-only on NexaCloud; writes only reconciliation rows (shadow
guarantees intact). 8 new tests, full suite green on odoo-trial.
2026-05-27 14:34:23 -04:00
gsinghpal
3ba9f2821e docs(billing): spec + TDD plan for 2d NexaCloud dual-run reconciliation 2026-05-27 14:31:26 -04:00
gsinghpal
0104e87750 fix(fusion_helpdesk_central): Generate Summary crashed wizard with self-id collision
Repro: open the engagement wizard on a ticket, write findings, click
'Generate Summary from Findings'. Notification: "Ticket N no longer
exists" and the whole dialog closes — even though the ticket clearly
exists in the DB.

Root cause was two compounding bugs:

1. action_generate_summary returned an act_window dict with
   res_id=self.id to "stay open after writing the summary field". The
   web client honoured that by opening a NEW act_window — and the new
   action's context inherited active_id=<wizard_id> (because that's
   the res_id of the action being opened). Wizard ids are not ticket
   ids, but our default_get didn't know the difference.

2. default_get read ctx.get('active_id') unconditionally, without
   first checking ctx.get('active_model') == 'helpdesk.ticket'. So
   when active_id pointed at the wizard's own id, default_get fed
   that to _default_get_single, which raised "Ticket <wizard_id> no
   longer exists" — and the user saw a confusing error about a
   ticket that obviously DID exist (just not with that id).

Two fixes:

(a) action_generate_summary + action_generate_all_summaries now
    return True. The form field write is visible to the client via
    the call response; the wizard re-renders with the new
    ai_summary populated. No spurious navigation, no context
    pollution.

(b) default_get only consults active_id / active_ids when
    active_model is helpdesk.ticket. Explicit
    default_ticket_id[s] context keys still take precedence and
    aren't gated by active_model (they're the caller's strong
    signal).

Verified live: opening the wizard with active_id=99999 and NO
active_model no longer raises 'Ticket 99999 no longer exists' —
just creates the wizard cleanly. The normal flow (default_ticket_id
+ active_model='helpdesk.ticket') still works as before.

Bumps fusion_helpdesk_central to 19.0.2.3.3.
2026-05-27 14:30:53 -04:00
gsinghpal
1f818096db fix(fusion_helpdesk_central): findings + summary actually span full width
Previous fix (col=1 on the group) didn't work — Odoo still rendered
the group's string as a left-column label inside the form sheet's
flow, so the textarea got pushed into a narrow right column. The
summary field looked entirely missing because its content split
between the button row (on the right) and the textarea (collapsed
nowhere visible).

Right idiom (lifted from Odoo's own mail.compose.message wizard):
WIDE textareas live directly at the form level, not inside <group>.
Section titles use <separator string="…"> which renders as a
horizontal divider with the label above. The textarea then takes
the full sheet width naturally.

Same pattern applied to bulk mode for consistency.

Also moved Personal Note into the top compact group with Owner /
Owner Email since it's a one-line input that belongs with the
header info, not pretending to be a wide section.

Bumps fusion_helpdesk_central to 19.0.2.3.2.
2026-05-27 14:17:23 -04:00
gsinghpal
bb873e8a7a feat(billing): importer Test Connection guard + operator runbook
Add action_test_connection — a read-only connectivity/schema check that
reports source row counts and imports nothing, the safe first step before
a dry-run. Wire a "Test Connection" button on the wizard. Document the
end-to-end run in the README: least-privilege read-only DB role SQL, the
fusion_billing.nexacloud_dsn system parameter (libpq DSN = NexaCloud's
URL minus +asyncpg), and the Test → dry-run → real-run flow. Refresh the
stale SCAFFOLD status. 53/53 green on odoo-trial.
2026-05-27 14:16:32 -04:00
gsinghpal
d4ef4d55e0 fix(fusion_repairs): wrap Wysiwyg content with markup() so HTML renders, not escapes
User reported the rich text editor showing raw HTML tags as literal text
instead of rendering them as formatted prose. Root cause: Odoo's Editor
delegates content insertion to setElementContent() (web/core/utils/html.js),
which only takes the innerHTML branch when the content was flagged as safe
markup via owl's markup() helper. Plain strings fall through to the
textContent branch, which is what the user was seeing:

    <p>Ask the client if the stairlift has power. Check:</p> <ul> <li>...

instead of the rendered paragraph + list.

The canonical html_field.js in @html_editor wraps its value with markup()
before passing it to the Wysiwyg config; I missed that detail.

FIX
- import markup from @odoo/owl
- in wysiwygConfig getter, wrap the saved content_html string with
  markup() before assigning to config.content
- pass markup("") for empty content (avoids editor confusion with falsy)
- load-bearing comment to keep future refactors from re-introducing the bug

VERIFIED
- upgrade clean
- 7 stale asset bundles flushed, container restarted, login serves 200
- new bundle 014fee9 renders 10029808 bytes
- node --check PARSE_OK
- compiled bundle contains: content:rawHtml?markup(rawHtml):markup("")
  which is exactly the markup-wrapped path the Editor wants

Bumped to 19.0.2.2.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:55:20 -04:00
gsinghpal
fc8963da99 fix(fusion_helpdesk_central): findings + summary textareas span full width
The Findings and Summary fields rendered at half-width because their
enclosing <group> defaulted to col="2" — Odoo reserves a label column
even when the field has nolabel="1", so the textarea was squeezed
into the right half of the dialog while the left half sat empty.

Switch both groups to col="1" so the field uses the entire group
width. Also tag both fields with widget="text" explicitly (it was
inferred from the Text field type, but being explicit makes the
intent obvious to anyone reading the view) and migrate the button
row to a flex div so the helper text aligns with the button vertical
center.

Bumps fusion_helpdesk_central to 19.0.2.3.1.
2026-05-27 13:53:38 -04:00
gsinghpal
c520803c84 feat(fusion_helpdesk_central): findings-first wizard, explicit Generate button
The old flow fired OpenAI on wizard open with just ticket + chatter,
so the AI summary was just a paraphrase of what the user originally
reported — your engineering analysis (scope, limitations, recommended
approach) never made it to the owner. Restructure to a two-step flow:

  1. Open wizard → empty findings + empty summary, NO OpenAI call
  2. You write findings: scope / effort / approach / risk
  3. Click 'Generate Summary from Findings' → OpenAI runs with
     ticket + chatter + findings, where the prompt explicitly tells
     the model to weight findings MORE THAN the original report
  4. Review/edit, then Send

Bulk wizard mirrors the flow per line: each row gets its own
findings + summary, one 'Generate All Summaries' button fans out
parallel OpenAI calls using each line's own findings.

Updated SUMMARY_PROMPT to:
- Tell the model the support engineer's findings are authoritative
- Emit a bullet structure that leads with the recommendation, not
  the user's restated ask
- Side with findings over the original report when they conflict

New tests cover:
- default_get does NOT fire OpenAI (regression guard for auto-AI)
- Findings text actually reaches the OpenAI prompt
- Send works with a manually-typed summary (no AI in the loop)
- Existing bulk + validation paths still pass with the new shape

Also folds in the deferred code-review #7: ThreadPoolExecutor now
explicitly cancels pending futures on timeout via
shutdown(wait=False, cancel_futures=True) so a slow OpenAI day can't
hold the wizard open for ceil(N/workers)*15s.

Bumps fusion_helpdesk_central to 19.0.2.3.0.

Smoke-tested live on nexa: opening the wizard makes zero OpenAI calls;
clicking Generate with findings='My findings: scope is XL, ~8h' makes
exactly one call and the findings text is verifiably in the prompt
body received by call_openai_chat.
2026-05-27 13:49:02 -04:00
gsinghpal
7349f3180d docs(billing): note flip-day usage-API subscription-id mapping for 2b 2026-05-27 13:45:23 -04:00
gsinghpal
2414b6328e fix(fusion_repairs): designer setup() scope - onMounted/onWillUnmount were stranded outside, broke entire backend bundle
REGRESSION FROM b22bb11b (Wysiwyg integration).

While inserting the new Wysiwyg methods (wysiwygConfig getter, onWysiwygLoad,
onToggleSource) between setup() and the existing onMounted / onWillUnmount
hook calls, I accidentally closed setup() early with the new
`this.wysiwygEditors = {};` assignment. That left the original
`onMounted(async () => {...});` and `onWillUnmount(...);` calls dangling
INSIDE the class body but OUTSIDE any method - which is invalid JS.

JavaScript's class-body parser sees the bare `onMounted(async () => ...)`
and tries to interpret it as a method declaration where `onMounted` is the
name and the parens are the parameter list. `async () => {...}` is not a
valid parameter, so the bundle fails with:

  Uncaught SyntaxError: Unexpected token '('
  web.assets_web.min.js:28807

That single parse failure tanks the entire backend asset bundle, leaving
users with a completely blank screen on /odoo (and any other backend
route). Frontend bundle was unaffected.

FIX

Move the onMounted / onWillUnmount calls back inside setup() where they
belong. Add a load-bearing comment explaining why they must stay there so
this regression cannot silently come back during a future refactor.

VERIFIED

  - line 51: setup() opens
  - lines 87, 93: onMounted, onWillUnmount calls INSIDE setup
  - line 142: _initDrawflow as a normal class method (outside setup)
  - upgrade clean
  - bundle 10029245 bytes, exactly one onMounted( occurrence in
    FlowchartDesigner class body
  - node --check on the freshly-rendered web.assets_web.min.js -> PARSE_OK

Bumped to 19.0.2.2.3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:44:58 -04:00
gsinghpal
5605012245 fix(billing): importer review fixes — surface failures, validate, dedupe
Resolves findings from the post-build review:
- C1: a partial import was indistinguishable from success. action_run_import
  now logs failed rows at ERROR (survives nexa's log_level=warn) and the
  wizard shows red/amber banners with failed/skipped counts.
- H3: an unrecognized billing_cycle silently fell back to monthly (wrong
  plan AND price). Now raised per-row -> failed[], never silently mis-billed.
- M5: a NULL plan price silently became a $0 line. Prices now preserve
  NULL-vs-0.0; a missing price for the subscription's cycle is failed[].
- H2: post-connect query/schema errors now become a clean UserError, not a
  raw SQL traceback (matches the connection-error path).
- M4: per-row failures now record the exception type and log a traceback.
- MED#3: charge plan_id set explicitly False so re-runs re-assert the
  shadow-safe NULL even if it was changed between runs.
- HIGH-edge: re-run only rewrites x_fc_* on existing subs; partner_id/plan_id/
  line are set at creation only (never rewrite immutable fields).
- account_link: partner email match is now case-insensitive (=ilike) to avoid
  duplicate partners against a differently-cased pre-existing partner.

Shadow-safety invariant unchanged and re-confirmed. 52/52 green on odoo-trial.
2026-05-27 13:44:51 -04:00
gsinghpal
52849777dd feat(fusion_helpdesk_central): expose OpenAI key + cron settings in UI
Adding the 'Fusion Helpdesk Central' block to General Settings so the
three ICP keys the engagement flow reads are configurable from a real
form instead of forcing admins to open Technical → System Parameters.

Three settings, all wired via config_parameter= so the existing read
paths (engagement_wizard, _fc_send_engagement_reminders) keep working
unchanged:

- fusion_helpdesk_central.openai_api_key (password widget — doesn't
  render plaintext on the form)
- fusion_helpdesk_central.openai_model (default 'gpt-4o-mini')
- fusion_helpdesk_central.engagement_reminder_days (default 3, 0
  disables the reminder cron entirely)

Bumps fusion_helpdesk_central to 19.0.2.2.0.

Find under Settings → Fusion Helpdesk Central. The block has two
sub-sections: "Owner Approval — AI Summary" (key + model) and
"Owner Approval — Reminder Cadence" (days).
2026-05-27 13:36:44 -04:00
gsinghpal
6f060896bf feat(billing): 2a NexaCloud→Odoo importer (read-only, idempotent, shadow-safe)
fusion.billing.import.wizard backfills NexaCloud into Odoo: read-only
psycopg2 reader (_read_nexacloud_rows, DSN from ir.config_parameter)
split from pure-Odoo writes (_import_rows/_do_import) so the logic is
unit-tested headless. Maps users→partners+links (reusing
_resolve_or_create_partner, stashing stripe_customer_id), plans→a
cpu_seconds charge catalog (included_quota=cpu_seconds_quota,
unit_batch=3600, $0.0075/core-hour, plan_id NULL), and deployments→one
DRAFT shadow sale.order per deployment with the flat price set
explicitly. Shadow-safe by construction: draft + no payment token +
charge plan_id NULL (rating cron is a no-op). Idempotent re-runs;
per-row savepoints isolate bad rows; dry-run rolls back. 11 tests,
50/50 green on odoo-trial.
2026-05-27 13:34:47 -04:00
gsinghpal
3e0b531110 fix(billing): charge rate precision — Float not Monetary, no premature cent-rounding
price_per_unit was a Monetary field, so a realistic sub-cent rate like
$0.0075/core-hour was rounded to $0.01 on write, corrupting the rate.
Make it Float(16,6). Also stop _compute_billable from rounding the
overage amount to 2 decimals mid-calc — that lost the half-cent on
sub-cent rates and would drift against the source app, which keeps
usage amounts at 4 decimals and only rounds at the invoice total.
Now rounds to 6 dp (float-noise only); cent-rounding defers to the
invoice line. Exposed while building the NexaCloud importer.
2026-05-27 13:34:37 -04:00
gsinghpal
8cc02759b8 feat(fusion_helpdesk_central): Owner Contact field + Add-as-Follower button
Adds a one-click 'loop the owner into the chatter' shortcut on the
ticket form — separate from the engagement approval flow, just keeps
the owner in the loop on ongoing communication.

What's new on helpdesk.ticket:

- x_fc_owner_display (computed Char): 'Kris Pathinather <kris@…>',
  read live from fusion.helpdesk.client.key so a change to the owner
  contact reflects immediately on every existing ticket.
- x_fc_owner_email_resolved (computed Char): email-only slice, drives
  view visibility (the field + button only render when an owner is
  configured).
- x_fc_owner_is_follower (computed Boolean): True when a partner with
  the owner email is in message_partner_ids. Swaps the button for a
  green 'Following' badge when the owner is already on the thread.
- action_add_owner_as_follower(): find-or-create the owner partner by
  email and message_subscribe. Idempotent — second call is a no-op,
  no duplicate partner. Raises UserError with a clear message if no
  owner is configured.

View extension on the helpdesk ticket form: injects right after the
existing partner_id ('Customer') field in the customer side group,
so it reads as 'Customer | Owner Contact [Add as Follower]' — same
row, no layout shift when the state flips to 'Following'.

Tests cover the compute display in three states (configured,
no-client-label, no-owner-on-key), the action's three paths
(create-and-subscribe, reuse-existing-partner, idempotent-when-
already-following), and the UserError when nothing is configured.

Smoke-tested live on nexa: ticket with x_fc_client_label='ENTECH'
displays 'Kris Pathinather <kris@enplating.ca>'; first click adds
res.partner #723 to followers and flips owner_is_follower to True;
second click is a no-op.

Bumps fusion_helpdesk_central to 19.0.2.1.0.
2026-05-27 13:28:18 -04:00
gsinghpal
40b3205274 docs(billing): TDD implementation plan for 2a NexaCloud importer
9 task-by-task plan: x_fc fields + wizard scaffold, identity, catalog
(plan_id NULL), draft shadow subscriptions, idempotency+dry-run,
shadow-safety assertions, per-row error isolation, DSN read guard,
full suite + static checks. Tests run on odoo-trial.
2026-05-27 13:25:26 -04:00
gsinghpal
15470426eb refactor(fusion_helpdesk): owner contact is a res.partner, not two text fields
Smaller UX simplification on the client side: the owner is already a
contact in entech's address book, so picking one is faster + safer than
re-typing their email and name (and avoids typos creeping into the
approval-email To: header).

What changed:
- Entech settings: drop fhd_owner_email + fhd_owner_name char fields;
  add fhd_owner_partner_id Many2one to res.partner exposed in the
  same "Owner Approval" block as a single partner selector. Quick-create
  + create-and-edit kept enabled so admins can spin up a new partner
  inline if the owner isn't already in the system.
- controllers/main.py::_read_config: derives owner_email + owner_name
  from the selected partner via the new _resolve_owner_contact helper.
  Missing / dangling partner id → blank email + name → central simply
  won't see the keys and the Engage button stays disabled (correct
  "not configured" behaviour).
- Nexa side: ZERO changes. Still receives owner_email + owner_name
  strings on the ticket payload, still upserts client_key.owner_email/
  name. The partner abstraction stops at the entech boundary.
- migrations/19.0.2.1.0/post-migration.py auto-resolves the legacy
  fusion_helpdesk.owner_email ICP value to an existing res.partner
  (lowest-id match on lowercased email), writes the new
  fusion_helpdesk.owner_partner_id key, and deletes the obsolete
  owner_email + owner_name ICP rows so a future reader doesn't trip
  over stale config.

Verified live on entech: kris@enplating.ca → res.partner #2308 ("Kris
Pathinather"), legacy keys purged, controller._resolve_owner_contact
returns the expected (email, name). The piggyback payload is unchanged
so existing client_key sync continues to work without a central
redeploy.

Bumps fusion_helpdesk to 19.0.2.1.0. fusion_helpdesk_central stays at
19.0.2.0.0 (no central-side changes required).
2026-05-27 13:21:08 -04:00
gsinghpal
b22bb11b31 feat(fusion_repairs): flowchart designer node content uses Odoo Wysiwyg
Replace the plain <textarea> in the flowchart designer's node-editor
right-panel with Odoo 19's native rich text editor so admins write
formatted prose / lists / bold / links / inline images without typing
HTML tags. The raw <textarea> stays available behind a toggle for the
power-user case (pasting markup from elsewhere, debugging).

CHANGES

manifest:
  - depends += 'html_editor' (provides @html_editor/wysiwyg)
  - bumped to 19.0.2.2.1

components/flowchart_designer/flowchart_designer.js:
  - import { Wysiwyg } from '@html_editor/wysiwyg'
  - import { MAIN_PLUGINS } from '@html_editor/plugin_sets'
  - register Wysiwyg in static components
  - state.sourceMode boolean (default false = rich text mode)
  - wysiwygConfig getter builds the EditorConfig for the SELECTED node;
    onChange reads editor.getContent() and writes back into the same
    selectedMeta.content_html the rest of the designer already uses,
    so the save path is unchanged
  - onWysiwygLoad(editor) captures the editor instance per dfId so the
    onChange callback can resolve the right one when nodes switch
  - onToggleSource flushes the current editor's content before flipping
    modes so unsaved keystrokes don't get lost

components/flowchart_designer/flowchart_designer.xml:
  - replaced <textarea>...</textarea> with a conditional block:
      sourceMode == false -> <Wysiwyg t-key="'wysiwyg-' + selectedNodeId"
                                       config="wysiwygConfig"
                                       onLoad="onWysiwygLoad.bind(this)"/>
      sourceMode == true  -> <textarea class="font-monospace" rows="10"/>
  - t-key forces the editor to re-mount with the freshly-selected node's
    content; otherwise switching nodes would keep showing the first
    selected node's HTML
  - new toolbar row above the editor has a "HTML Source" / "Rich Text"
    toggle button (eye / code icons) so the user can flip at will
  - hint text updated to reflect what each mode supports

components/flowchart_designer/flowchart_designer.scss:
  - widened the right editor panel from 320px to 360px to give the
    Wysiwyg toolbar room to breathe
  - new .fr-wysiwyg-shell rule frames the embedded editor with the same
    border + background as the other form-controls in the panel, with
    a min-height of 180px and max-height 320px so it scrolls when the
    content grows. Pins .o-we-toolbar inside the shell so it stays in
    view as the user scrolls long content.

The save path, the runtime renderer, and the data model are unchanged -
content_html is still sanitised HTML stored on fusion.repair.flowchart.node.

Verified on local westin-v19:
  - upgrade clean (no errors, no warnings)
  - login serves 200 after restart
  - 4 stale asset bundles flushed; Drawflow JS still served 46KB at
    /fusion_repairs/static/src/lib/drawflow/drawflow.min.js
  - Wysiwyg export confirmed at
    /usr/lib/python3/dist-packages/odoo/addons/html_editor/static/src/wysiwyg.js:25
  - MAIN_PLUGINS export confirmed at plugin_sets.js:103

Bumped to 19.0.2.2.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 13:18:27 -04:00
gsinghpal
134c94fc6c docs(billing): design spec for sub-project #2a NexaCloud→Odoo importer
One-time, re-runnable, read-only importer that backfills NexaCloud
customers/plans/deployments into Odoo as a shadow copy for dual-run
reconciliation. Locks the brainstorming decisions: per-deployment
granularity, flat+overage billing, cpu_seconds metric, CPU-only v1,
Odoo-side psycopg2 reader, and shadow-safety by construction (draft
subs + no payment token + charges with NULL plan_id).
2026-05-27 13:18:26 -04:00
gsinghpal
f1a2b300f7 fix(fusion_helpdesk_central): close magic-link race + cron savepoint + avg pivot
Findings from the post-feature code review on commit 396170b4. Addresses
the two CRITICAL + one HIGH + two MEDIUM issues; rest are deferred.

CRITICAL #1 — magic-link token race:
  Two near-simultaneous POSTs on the same /engagement/<token>/approve
  could both SELECT state='pending' under READ COMMITTED, both post
  chatter, and let the last writer flip the outcome. Now the POST path
  does an atomic UPDATE helpdesk_ticket SET token=NULL WHERE token=%s
  AND state='pending' RETURNING id — the loser gets no row back and
  renders the friendly invalid-link page. Verified live: 2 concurrent
  POSTs → 1 wins, 1 loses, exactly 1 chatter row.

CRITICAL #2 — reminder cron without per-row savepoint:
  Per CLAUDE.md rule #14, a DB failure mid-loop aborts the whole
  transaction and silently kills the rest of the batch. Wrap each row's
  send_mail+write in `with self.env.cr.savepoint()`. Also corrected the
  success-count log (was len(stale), now actual sent count).

HIGH #3 — turnaround pivot summed instead of averaged:
  fields.Float defaults to SUM aggregator; meaningless for per-ticket
  decision delays. Added aggregator='avg' so the pivot reads "avg
  turnaround per ticket" not "summed wait time".

HIGH #4 — added test_concurrent_claim_only_one_wins regression test
  that fires two real HTTP POSTs against the same token and asserts
  exactly one wins + exactly one approval chatter row exists.

MEDIUM #6 — cron nextcall pinned to 09:00 tomorrow so reminders land
  in business hours regardless of when the module was last upgraded.

MEDIUM #10 — escalate failed owner-partner-create from WARNING to
  ERROR (via _logger.exception) since silent attribution to the bot
  account is a real audit-trail confusion.

Deferred (follow-up commits): #5, #7 (executor cleanup), #8, #9,
#11–#14 — none are bugs, all spec-drift or hardening.
2026-05-27 13:16:20 -04:00
gsinghpal
396170b438 feat(fusion_helpdesk): owner-approval engagement flow + AI summary + reporting
Ships the design spec at docs/superpowers/specs/2026-05-27-owner-approval-flow-design.md.

What's new on central (fusion_helpdesk_central 19.0.1.2.0 -> 19.0.2.0.0):

- Engagement model: 8 new fields on helpdesk.ticket (state, snapshotted
  owner email/name, single-use UUID4 token, sent/reminded/decided
  timestamps, AI summary, stored-computed turnaround hours).
- Wizard: single + bulk modes on one fusion.helpdesk.engagement.wizard
  TransientModel with a child wizard.line for per-ticket bulk summaries.
  default_get pulls the OpenAI summary on open; AI fan-out for bulk is
  parallel via ThreadPoolExecutor (max 5 workers, 30s overall cap).
- OpenAI client in utils.py — stdlib urllib, 15s per-call timeout, every
  failure collapses to '' so the wizard's manual-summary fallback fires.
- Public portal: /fusion_helpdesk/engagement/<token>/<decision> GET +
  POST, four branded standalone QWeb pages (confirm/done/invalid/error).
  Token is single-use, cleared on confirm. Decision posts a public
  comment attributed to the resolved owner partner; chatter propagates
  to the employee's My Tickets thread per the "fully visible" UX choice.
- Mail templates (single + bulk) with magic-link buttons. Bulk template
  renders one card per ticket, each with its own approve/reject URL.
- Reminder cron: daily, single-shot per engagement, configurable via
  fusion_helpdesk_central.engagement_reminder_days ICP (default 3, 0
  disables).
- Reporting dashboard: pivot/graph/list/kanban over helpdesk.ticket
  filtered to engaged ones, with avg-turnaround measure. Menu lives
  under Helpdesk > Reporting > Owner Engagements.
- Client_key extended with owner_email/owner_name fields; ticket.create
  upserts them from the client-side piggyback (no new sync endpoint).
- 100% coverage on utils + integration tests on wizard, controllers,
  re-engagement, cron, computed turnaround. OpenAI mocked in CI.

What's new on client (fusion_helpdesk 19.0.1.7.1 -> 19.0.2.0.0):

- Two new ICP settings: fusion_helpdesk.owner_email / .owner_name with
  a new "Owner Approval" block in Settings > Fusion Helpdesk.
- controllers/main.py::submit piggybacks both keys on every ticket
  payload so central keeps client_key.owner_email/name fresh
  automatically.

Verified live end-to-end on entech -> nexa: payload upsert, wizard with
mocked AI, action_send, portal GET/POST/GET-again cycle, second click
hits the friendly invalid-token page. Token entropy = 122 bits (UUID4).
2026-05-27 13:03:23 -04:00
gsinghpal
eb186cac3c feat(fusion_repairs): Bundle 11 - CS guided troubleshooting flowcharts + vendor PO
Two big workflow additions:

1. Visual drag-and-drop flowchart designer (Drawflow) + card-by-card runner
   (with show-whole-tree toggle) so admins build per-(category, symptom)
   decision trees with embedded photos/videos and CS walks callers through
   them on the phone. Resolved-on-call closes the repair; escalated copies
   the full transcript into internal_notes so the dispatched tech sees what
   was already tried before they arrive at the client.

2. Vendor + draft-PO + factory-tracking on the part-order capture. Tech on
   the phone with the factory picks the vendor from contacts, types the OEM
   part #, cost, ETA date (calendar widget), factory ticket #, RA #, ticks
   under_warranty, and the system auto-creates a draft purchase.order with
   the right product (looked up or created from OEM) + activity for the
   office on the ETA day + client email with ETA prominently shown and
   cost intentionally omitted.

NEW MODELS

fusion.repair.symptom.class - lookup table (category + name + code).
  Replaces the flat x_fc_issue_category Char on repair.order. Seeded with
  7 stairlift symptoms + lighter coverage for hospital bed / porch lift /
  lift chair. Equipment Class added to fusion.repair.product.category
  (this carried over from the Bundle 10 plan).

fusion.repair.flowchart + .node + .edge - design-time graph.
  - flowchart has name, category, symptom, version, published flag,
    canvas_layout (Drawflow JSON), node_ids, edge_ids, computed start_node
  - node has node_type (question / suggestion / info / outcome),
    content_html, media_ids (M2M ir.attachment for photos + videos),
    is_start, outcome_kind (resolved / escalate / order_part),
    canvas_x/y for Drawflow round-trip
  - edge has source, target, label, sequence - supports N-ary branching
    (not just Yes/No)
  - designer_load() and designer_save(payload) RPC API the OWL component
    consumes; save is atomic-replace + bumps version + soft-validates

fusion.repair.flowchart.run + .step - runtime sessions.
  - One run per repair, audited; runtime_start_or_resume() returns the
    existing in-progress run or creates a fresh one for the matching chart
  - runtime_choose(edge_id, cs_note) records a step + advances current_node
  - runtime_complete(outcome) snapshots final node + calls _apply_outcome:
      resolved   -> auto-close via action_repair_start + action_repair_end,
                    set x_fc_resolved_on_call, post transcript to chatter
      escalated  -> prepend transcript to repair.internal_notes so the tech
                    sees it first when they open the form
      order_part -> chatter note; tech opens visit-report wizard next
      abandoned  -> just save transcript
  - Each step snapshots node_name + chosen_label at write time so the
    transcript survives later chart edits without breaking.

REPAIR.ORDER EXTENSIONS

- x_fc_symptom_class_id (M2O) - new structured symptom field
- x_fc_resolved_on_call (Boolean, tracked) - true after a resolved outcome
- x_fc_flowchart_run_ids + x_fc_flowchart_run_count
- action_start_troubleshoot() - opens the runner client action, raises a
  helpful UserError if no symptom set or no published chart exists
- action_view_flowchart_runs() smart button
- x_fc_issue_category renamed string to "(legacy)" - kept for back-compat
  + AI prompt context; new intakes set the M2O

DRAWFLOW DESIGNER (OWL)

static/src/lib/drawflow/drawflow.min.{js,css} - vendored Drawflow 0.0.59
  (MIT). Loaded only in web.assets_backend, ~48KB total.

components/flowchart_designer/flowchart_designer.{js,xml,scss}:
  - Client action "fusion_repair_flowchart_designer" with full drag-drop
    canvas + zoom + pan
  - 4 custom node templates color-banded by type (question blue,
    suggestion green, info gray, outcome red/green/amber per outcome_kind)
  - Right-panel editor for selected node: title, type, outcome kind,
    content (HTML), media uploader (drag-drop or click), set-as-start
    toggle, per-outgoing-edge label editor
  - Save serializes Drawflow JSON to canvas_layout + atomic-replaces the
    structured node/edge rows via the designer_save RPC

CARD RUNNER (OWL)

components/flowchart_runner/flowchart_runner.{js,xml,scss}:
  - Client action "fusion_repair_flowchart_runner"
  - DEFAULT MODE: card-by-card. One big card per node, embedded photos +
    inline <video controls>, answer buttons sized for phone use, CS note
    textarea (saved as cs_note on the step), running transcript at the
    bottom
  - TOGGLE: "Show Whole Tree" loads the same Drawflow lib in read-only
    fixed mode, imports the canvas_layout JSON, highlights current node
    yellow / visited green via .fr-current / .fr-visited classes
  - Outcome buttons drive the right runtime_complete() call; success
    notifications + auto-return to the parent repair form
  - "Abandon & Escalate" header button at all times - transcript is saved
    even on bail-out so the dispatched tech still benefits

PART ORDER + VENDOR PO

repair.part.order new fields:
  vendor_partner_id (M2O res.partner, is_company domain), purchase_order_id
  (auto-created draft PO), product_id (auto-resolved or created),
  unit_cost (Monetary) + currency_id, internal_po_ref, factory_ticket_ref,
  factory_ra_number, under_warranty.

action_create_draft_po() - resolves product.product by OEM (default_code)
  or creates a new one in a "Spare Parts" product.category, creates a
  purchase.order in draft state with one line (product + qty + price_unit
  + date_planned from expected_date or +7d), stamps Westin's internal PO
  ref as partner_ref so the factory can find it on return. Office reviews
  and confirms via the normal Odoo flow.

_schedule_eta_activity() - schedules a Repair: Assign Technician activity
  on the parent repair.order due on expected_date, assigned to
  repair.user_id, so the office is reminded to call the client and book
  the return visit on the day parts arrive.

VISIT-REPORT WIZARD PARTLINE EXTENSIONS

Same new fields exposed inline on the partline list so the tech captures
everything on the phone with the factory in one form:
  vendor_partner_id (vendors-only filter), unit_cost + currency,
  expected_date (calendar widget) replacing expected_lead_days as the
  preferred input, under_warranty, internal_po_ref, factory_ticket_ref,
  factory_ra_number, create_draft_po (default True - auto-builds PO on
  submit when vendor + cost are both set).

CLIENT EMAIL TIGHTENED

email_template_parts_ordered:
  - Subject now includes ETA "Parts ordered for your stairlift - expected 2026-06-06"
  - Hero ETA panel: large blue-bordered card with "Expected Arrival" label
    and the date in 24px bold
  - Cost INTENTIONALLY OMITTED - "Our office will call you to confirm a
    return visit time. If you have any questions about pricing or
    scheduling, please reach out to our office directly."
  - "There is nothing for you to do right now." callout

UI

- repair.order form header: new "Start Troubleshooting" button (info
  style, sitemap icon, visible when state in (draft, confirmed,
  under_repair) AND symptom is set)
- repair.order form intake row: x_fc_symptom_class_id picker filtered to
  the category, x_fc_resolved_on_call display when true
- repair.part.order form: header button "Create Draft Purchase Order"
  + new Vendor / Cost / Warranty group + System group with the PO link
- Intake wizard equipment line: symptom_class_id picker
- New menus:
    Configuration > Symptom Classes
    Configuration > Troubleshooting Flowcharts
    Fusion Repairs > Troubleshooting Sessions (run history)

SECURITY

18 new ACL rows for the 6 new models, scoped Manager-full / User-read /
FieldTech-read. Flowchart runs and steps get write access for User so CS
can record steps; Manager owns flowchart + node + edge CRUD.

POST-MIGRATION (19.0.2.2.0)

Existing installs: walks all distinct (category, x_fc_issue_category) text
pairs on repair.order, creates a placeholder fusion.repair.symptom.class
per pair (or reuses an existing match by code/name), back-fills the new
x_fc_symptom_class_id M2O. Idempotent + safe to re-run.

DEPENDENCY

Added 'purchase' to depends (action_create_draft_po needs purchase.order).

VERIFIED END-TO-END on local westin-v19 (Margaret persona, 0 bugs):

  STEP 0 seed: chart v1 8 nodes / 12 edges / published, 7 stairlift
                  symptoms, stairlift class=lift_elevating
  STEP 1 CS creates RO-202605-60 with symptom Not Moving
  STEP 2 Start Troubleshooting -> client action tag returned
  STEP 3 walk run: Power on? Yes -> Seatbelt? Yes -> Swivel? Yes ->
                   outcome 'Still not moving - dispatch technician'
                   (outcome_kind=escalate)
  STEP 4 runtime_complete('escalated') -> internal_notes prepended with
                   CS troubleshooting summary
  STEP 5 visit-report parts_needed with vendor Handicare + cost $425 +
                   warranty + factory refs -> PART-00008 created + draft
                   PO 26690 auto-built with line "Handicare 1100 control
                   board" qty 1 @ $425, partner_ref WH-2026-1042
  STEP 6 mark_ordered -> client email queued (NO cost mentioned, ETA
                   shown prominently) + office activity scheduled for
                   2026-06-06
  STEP 7 fresh resume returns same run; resolved outcome auto-closes the
                   repair (state=done, x_fc_resolved_on_call=True)

Bumped to 19.0.2.2.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 12:50:06 -04:00
gsinghpal
4acf9d7f85 docs(spec): owner approval flow design
End-to-end spec for the owner-approval feature on fusion_helpdesk +
fusion_helpdesk_central. Captures data model, engagement flow (single +
bulk), magic-link approval portal, OpenAI summary, reminder cron,
reporting dashboard, edge cases, and test plan. Ready for the
writing-plans skill to turn into an implementation plan.
2026-05-27 12:37:57 -04:00
gsinghpal
e596723ba5 fix(fusion_helpdesk): render message bodies as HTML, not escaped text
The OWL dialog used <t t-out="m.body"/> on message bodies, but t-out
escapes plain strings — it only renders raw when the value is a Markup
instance. Bodies arrive over JSON-RPC as plain strings (Markup is a
client-side type, doesn't cross the wire), so the customer was seeing
literal "<p>This has been fixed.</p>" in the thread instead of the
rendered HTML.

Wrap incoming bodies in `markup()` at the boundary (openTicket +
sendReply call sites) so the template renders them as the sanitised
HTML the central chatter already produced. Trust is fine — the body is
sanitised server-side by mail.thread before it ever leaves nexa.

Bumps fusion_helpdesk to 19.0.1.7.1.
2026-05-27 11:40:17 -04:00
gsinghpal
d7ec91b0f1 feat(fusion_helpdesk): Critical flag, KPI cards, colored stage pills
Three coordinated changes on top of the section grouping:

1. **Mark as Critical** — a red chip on the New tab sets priority='3'
   when submitted. The central post-create hook auto-applies a "Critical"
   helpdesk.tag (shipped via fusion_helpdesk_central data XML, noupdate=1
   so support can recolor without losing it on upgrade), giving support
   a kanban-groupable signal that doesn't rely on remembering what
   priority='3' means. Scoped to in-app-channel tickets only, so a
   support agent manually setting Urgent on their own ticket isn't
   silently tagged.

2. **KPI cards above the sections** — Total / Open / Closed / Critical
   in a 4-up grid (auto-collapses to 2x2 under 540px). Each card uses
   its own saturated gradient so it reads on both light and dark mode —
   the dialog backdrop is irrelevant because the gradient brings its
   own background. Counts are computed in JS from state.tickets so they
   always match what's rendered below.

3. **Colored stage pills** — red Critical, green Solved, dark-yellow New,
   orange Cancelled, blue for In Progress / Testing / On Hold. Critical
   priority gets a *separate* red pill alongside the stage pill so you
   keep stage info even on escalated tickets. Stage matching is
   substring-based (lowercased) so a renamed "Resolved" or "Done" stage
   on central still maps to the green pill.

Tests cover the new is_critical=True → priority='3' wiring and the
default omission so SLA / stage defaults keep working for normal
tickets. Bumps fusion_helpdesk to 19.0.1.7.0 and
fusion_helpdesk_central to 19.0.1.2.0. End-to-end smoke test verified
live: priority=3 + x_fc_client_label triggers the Critical tag.
2026-05-27 11:21:11 -04:00
gsinghpal
3e5ced1655 feat(fusion_helpdesk): group My Tickets into Critical/New/Solved sections
The flat write_date-sorted list was hard to scan with 50+ tickets — solved
ones were intermixed with active ones, and there was no signal for
priority. Bucket each ticket server-side into 'critical' (open + priority
High/Urgent), 'solved' (stage marked fold=True on central) or 'open'
(everything else), and render three labelled sections in the dialog with
sticky headers, count badges, and per-group accent colours. Backend keeps
its write_date desc order so latest is always at top within each bucket.

Bucketing uses helpdesk.stage.fold (not the stage name) so renaming
"Solved" to "Done" on the central won't quietly mis-categorise rows.
Adds bucket_ticket() in utils.py with unit tests covering the
folded-wins-over-priority precedence and the missing-priority fallback.

Also surfaces a small Urgent (triangle) / High (arrow) icon on each row
so a critical ticket reads at a glance even after a user scrolls past
the section header.

Bumps fusion_helpdesk to 19.0.1.6.0.
2026-05-27 11:04:31 -04:00
gsinghpal
aabfc1afe7 fix(fusion_helpdesk): auto-grant reporter admin to system admins + doc backfill
The customer-followup ship left two papercuts that hid 51 historical
tickets from the entech owner:

1. group_reporter_admin had zero members on install — the new XML record
   created the group but never granted it. Extend base.group_system's
   implied_ids so every system administrator transparently inherits the
   admin view of the embedded inbox on install / upgrade. (4, id) tuple
   is additive — never replaces base's existing implications.

2. Tickets created before this feature shipped had NULL
   x_fc_client_label and NULL partner_email, so the scope filter
   excluded them all. The reporter identity was still recoverable from
   the description HTML's diag block. Backfill recipe is captured in
   CLAUDE.md so future deployments can apply the same one-shot UPDATE
   without re-deriving the regex.

Bumps fusion_helpdesk to 19.0.1.5.0. Verified live on entech: all six
base.group_system members now return True for
has_group('fusion_helpdesk.group_reporter_admin').
2026-05-27 10:54:51 -04:00
gsinghpal
45b698beb5 feat(configurator): per-customer default lead time on partner profile
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>
2026-05-27 10:38:36 -04:00
gsinghpal
de6336ba42 changes 2026-05-27 10:36:48 -04:00
gsinghpal
c876767755 Merge branch 'main' of https://github.com/gsinghpal/Odoo-Modules 2026-05-27 10:36:37 -04:00
gsinghpal
d1fc3d8720 fix(express): show Tax on totals + add tooling as real SO line
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>
2026-05-27 10:33:31 -04:00
gsinghpal
a78ceaba51 docs(claude): fusion_helpdesk deploy procedures + 2026-05-27 handoff
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>
2026-05-27 09:26:21 -04:00
gsinghpal
6c15a7b1cf feat(fusion_helpdesk): customer follow-up + embedded ticket inbox
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>
2026-05-27 09:23:33 -04:00
gsinghpal
45ddb444a7 docs(billing): handoff — note fusion_login_audit also landed on main 2026-05-27 09:06:16 -04:00
gsinghpal
9df3262d30 fix(fusion_login_audit): avoid duplicate row on bad-password
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>
2026-05-27 09:03:59 -04:00
gsinghpal
5d9609b5ee chore(fusion_login_audit): release 19.0.1.0.0
Module is feature-complete per
docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md:
- T1  Module skeleton + icon
- T2  fusion.login.audit model (16 fields, declarative Constraint+3 Indexes)
- T3  Security: ACL + admin-only record rule + 5 tests
- T4  _fc_build_event_vals context helper (UA parse, password safety)
- T5  Success hook: _update_last_login -> result=success row
- T6  Bad-password hook: _check_credentials wrapped
- T7  Unknown-user hook: _login override (instance method in 19)
- T8  res.users smart button + Login Activity tab (4 x_fc_* fields)
- T9  Standalone list/form/search/kanban + 2 actions + 3 menus
- T10 res.config.settings + General Settings section (4 knobs)
- T11 Failure-burst alert email + 60-min cooldown
- T12 Daily retention GC cron
- T13 5-min async geo enrichment cron (private/cache/HTTP)
- T14 View-visibility security tests for non-admin
- 29 tests pass; both crons active; 3 menus installed.

Out of scope for v1 (documented in spec): API-key auth, OAuth/SSO,
per-user self-service view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:03:59 -04:00
gsinghpal
622f133f05 docs(plan): inline corrections from T11/T12/T13 execution
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>
2026-05-27 09:03:59 -04:00
gsinghpal
482f12256e test(fusion_login_audit): view-visibility checks for admin vs non-admin
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>
2026-05-27 09:03:59 -04:00
gsinghpal
86b8e59c95 feat(fusion_login_audit): async geo enrichment cron
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>
2026-05-27 09:03:59 -04:00
gsinghpal
1b8038d8e8 feat(fusion_login_audit): nightly retention GC cron
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>
2026-05-27 09:03:59 -04:00
gsinghpal
a2d13cf83b feat(fusion_login_audit): failure-burst alert email + cooldown
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>
2026-05-27 09:03:59 -04:00
gsinghpal
6f6aa6e90a feat(fusion_login_audit): settings model + page section
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>
2026-05-27 09:03:59 -04:00
gsinghpal
0513ea23a4 feat(fusion_login_audit): standalone views + menus
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>
2026-05-27 09:03:59 -04:00
gsinghpal
72aa28e6c4 feat(fusion_login_audit): smart button + Login Activity tab on res.users
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>
2026-05-27 09:03:58 -04:00
gsinghpal
a7cf44249d feat(fusion_login_audit): hook unknown-user failures via _login
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>
2026-05-27 09:03:58 -04:00
gsinghpal
0e6ebe7bc6 feat(fusion_login_audit): hook bad-password failures via _check_credentials
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>
2026-05-27 09:03:58 -04:00
gsinghpal
dced0c66a4 feat(fusion_login_audit): hook successful login via _update_last_login
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>
2026-05-27 09:03:58 -04:00
gsinghpal
2ced576204 feat(fusion_login_audit): add _fc_build_event_vals context helper
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>
2026-05-27 09:03:58 -04:00
gsinghpal
61a0cb244f feat(fusion_login_audit): admin-only record rule + security tests
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>
2026-05-27 09:03:58 -04:00
gsinghpal
aeea670064 feat(fusion_login_audit): add fusion.login.audit model
- 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>
2026-05-27 09:03:58 -04:00
gsinghpal
b0836e1c93 feat(fusion_login_audit): module skeleton + icon
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>
2026-05-27 09:03:58 -04:00
gsinghpal
a32946be44 docs(plan): fusion_login_audit implementation plan
15 TDD tasks targeting ~28 tests:
T1 skeleton+icon, T2 model+indexes, T3 security, T4 capture helper,
T5 success hook, T6 bad-password hook, T7 unknown-user hook, T8 user
form (smart button + tab + computes), T9 standalone views + menus,
T10 settings + page section, T11 failure-burst alert + cooldown,
T12 retention GC cron, T13 async geo enrichment cron, T14 view
visibility security tests, T15 manual smoke + release tag.

Self-reviewed: every spec section maps to a task; no placeholders;
method and field names consistent across tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:03:58 -04:00
gsinghpal
01a85c475c docs(spec): fusion_login_audit design
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>
2026-05-27 09:03:58 -04:00
gsinghpal
43b2edcbb5 @
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>
@
2026-05-27 08:56:28 -04:00
gsinghpal
d770c0c3a9 fix(billing): resolve code-review findings (authz, cross-billing, validation, webhook integrity)
- C1/H4: rating cron only rates subs on the charge's own plan_id
- C1: _fc_rate_usage skips creating a line when amount is 0 (still updates existing)
- C2/C4: /usage authorizes each event (exists + is_subscription + linked customer)
- C3: API handlers validate input and return 4xx-shaped errors instead of raising;
       controller maps status=='error' to HTTP 400
- H1: cron uses real billing window [last_invoice_date or start_date, next_invoice_date)
- H2: _aggregate uses half-open window anchored on period_start
- H3: idempotency scoped to (subscription_id, metric_id, idempotency_key)
- H5: webhook stores canonical body, signs+POSTs it verbatim, adds X-Fusion-Event-Id,
       caps backoff at 2**min(attempts,10)
- H6: SSRF guard rejects non-https / localhost / private / link-local webhook_url
- M7: charge_model reduced to standard/package (dropped unimplemented graduated/volume)
- L1: currency_id required on charge + reconciliation
- L2: charge price non-negative + unit_batch positive DB constraints

Adds 17 regression tests (suite 22 -> 39, all green via fcb_test_on_trial.sh).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
a5db0fe71e feat(billing): usage-rating + webhook-dispatch crons
- SaleOrder._fc_rate_usage: aggregates usage, computes overage via
  charge._compute_billable, upserts sale.order.line for the overage product
- FusionBillingUsage._cron_rate_open_periods: hourly cron iterates active
  charges × in-progress subscriptions, calls _fc_rate_usage
- data/ir_cron.xml: two crons — rate usage (hourly), dispatch webhooks (2 min)
- __manifest__.py: registers data/ir_cron.xml in data list
- test_usage.py: test_rate_open_period_creates_overage_line (TDD, FCB_EXIT=0)

Reference: _create_recurring_invoice / _get_invoiceable_lines confirmed in
Enterprise sale_subscription/models/sale_order.py — overage line goes onto
sale.order so native invoicing picks it up via _get_invoiceable_lines.
2026-05-27 08:42:08 -04:00
gsinghpal
c44fd89ed1 feat(billing): wire HTTP controllers to API handlers 2026-05-27 08:42:08 -04:00
gsinghpal
6c395709cf feat(billing): outbound webhook engine (HMAC + retry/backoff)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
0754d0b101 feat(billing): subscription creation handler (sale.order is_subscription)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
2435096f32 feat(billing): inbound API handlers (customer/usage/catalog)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
25952cf226 feat(billing): period usage aggregation by metric function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
eb1ee85d24 feat(billing): idempotent usage ingestion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
1e34a67384 feat(billing): metered charge math (quota + overage)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 08:42:08 -04:00
gsinghpal
a1cfab6fe9 feat(billing): identity resolution external account -> partner 2026-05-27 08:42:08 -04:00
gsinghpal
a46e31e710 feat(billing): service API-key generation + matching
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>
2026-05-27 08:42:08 -04:00
gsinghpal
032b10752e test(billing): odoo-trial Enterprise test runner + plan test-env fix
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>
2026-05-27 08:40:51 -04:00
gsinghpal
e7d63a3859 docs(billing): core engine implementation plan (TDD, 11 tasks) 2026-05-27 08:40:51 -04:00
gsinghpal
2b47bd8b10 feat(billing): design + scaffold fusion_centralize_billing
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>
2026-05-27 08:40:51 -04:00
gsinghpal
2f74d5ecb9 fix(plating): add 3 missing icons to process.node Selection
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>
2026-05-27 02:43:38 -04:00
gsinghpal
f8abadfc18 fix(configurator): OPEN button errored on missing action.views
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>
2026-05-27 02:40:27 -04:00
gsinghpal
164b775206 feat(views): partner Aerospace group + recipe Certificate Output + cert banner
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>
2026-05-27 02:05:28 -04:00
gsinghpal
b7211468b2 feat(certificates): orphan-cert attachment gate + render early-return
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>
2026-05-27 02:04:10 -04:00
gsinghpal
fb6cccc8b1 feat(jobs): three-step cert resolver with recipe suppression
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>
2026-05-27 02:03:18 -04:00
gsinghpal
ae02164b78 test(jobs): 6 tests for recipe-level cert suppression + orphan gate
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>
2026-05-27 02:02:18 -04:00
gsinghpal
a5063cc816 feat(plating): recipe-level cert suppression Booleans
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>
2026-05-27 01:57:32 -04:00
gsinghpal
89267a9f41 feat(certificates): partner toggles for Nadcap / MTR / Customer-Specific
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>
2026-05-27 01:56:36 -04:00
gsinghpal
e599daf4d9 plan: implementation tasks for recipe cert toggles + aerospace parity
Seven tasks, TDD-style:
  T1 — Partner toggles (3 booleans) + post-migrate backfill
  T2 — Recipe booleans (5 requires_*) + post-migrate backfill
  T3 — Six failing tests in test_recipe_cert_suppression.py
  T4 — Three-step resolver implementation
  T5 — Cert action_issue orphan-attachment gate + render guard
  T6 — UI views (partner separator + cert banner + recipe group)
  T7 — Deploy to entech + smoke runbook

Module version landings:
  fusion_plating_certificates  -> 19.0.12.0.0
  fusion_plating               -> 19.0.22.0.0
  fusion_plating_jobs          -> 19.0.8.1.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 01:55:02 -04:00
gsinghpal
e09913af5a docs: spec for recipe-level cert suppression + aerospace cert-type parity
Adds recipe-level Boolean toggles (requires_coc / requires_thickness_report /
requires_nadcap_cert / requires_mill_test / requires_customer_specific,
default True) so a recipe can suppress certs the customer requested when
the recipe physically never produces them (passivation = no thickness,
commodity ENP = no nadcap).

Closes gaps on three orphan fp.certificate.certificate_type values
(Nadcap, Mill Test, Customer Specific) — adds partner toggles
(x_fc_send_nadcap_cert / x_fc_send_mill_test / x_fc_send_customer_specific,
default False), wires them through _resolve_required_cert_types, and
sets up manual-attach Issue flow (no QWeb auto-render for orphan types).

Brainstorming Q&A locked: recipe SUPPRESSES only, partner+recipe scope
(part-level unchanged), 5 booleans default True, manual PDF attach for
orphans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 01:48:30 -04:00
gsinghpal
416daa36d2 feat(configurator): pack right column alongside tall PO block — no dead space
Previous tightening removed the row-span but reintroduced a worse
problem: the tall PO block (with PO Pending + Expected By + chase
warning visible = ~250px) had only 2 small cells next to it
(Customer Job # / Job Sorting). 200px+ of vertical air below them
before row 3 started.

Layout now:
- Row 1: Customer (1-2) + Delivery Address (3-4)
- Rows 2-5 left: PO Block spans 4 grid rows (cols 1-2)
- Rows 2-5 right: 4 PAIRS of fields fill cols 3-4 in DOM order:
    Row 2: Customer Job # + Job Sorting
    Row 3: Material/Process + Lead Time
    Row 4: Payment Terms + Delivery Method
    Row 5: Pricelist + Quote Validity
- Row 6: Blanket SO + Invoice Strategy + conditional Deposit % / Progress %
  (full 4-col width, kicks in after the PO block ends)

CSS Grid auto-flow places the right-side cells in the open positions
next to the row-span-4 PO block. Each grid row auto-sizes to the max
of the cells in that row (PO block top portion or the right pair),
so PO block height naturally aligns with the 4 right rows — no dead
air on either side.
2026-05-27 00:40:06 -04:00
gsinghpal
b7f280141f feat(configurator): tighten header spacing — remove row-span, smaller gaps
User reported too much vertical air between fields. Two changes:

1. Removed grid-row: span 2 from the PO block. The row-span pattern
   stretched each grid row to half the PO block's height (~125px each),
   leaving empty space below Customer Job # / Job Sorting on row 2 and
   below Material/Lead Time on row 3.

   New layout:
   - Row 1: Customer (1-2) + Delivery Address (3-4)
   - Row 2: PO Block (1-2, naturally tall) + Customer Job # + Job Sorting
   - Row 3: Material/Process + Lead Time + Payment Terms + Delivery Method
   - Row 4: Pricelist + Quote Validity + Blanket SO + Invoice Strategy
   - Row 5 (conditional): Deposit % or Progress % (when invoice strategy uses them)

   PO block forces row 2 to be tall but cols 3-4 just sit at top — that
   was the original mockup pattern, and it's denser overall because
   rows 3+ are all the standard short height.

2. Tightened spacing in SCSS:
   - Grid row gap 14px → 6px
   - Cell label margin 0 (was 2px)
   - Input padding 5px → 2px vertical, min-height 30px → 24px
   - PO block padding 10px → 6/12/8px
   - PO row gap 2px padding → 0 (min-height 28px keeps clickable target)
   - PO chase text 11px → 10px, tighter line-height
2026-05-27 00:34:46 -04:00
gsinghpal
2b8d99f69d fix(configurator): preserve boolean_toggle slider styling (PO Pending fix)
My .o_fp_xpr_cell rule set width/height: 18px on every input[type=
checkbox], which broke Bootstrap's .form-switch slider proportions
(switches need width: 2em / height: 1em). Result: PO Pending and
other boolean_toggle widgets rendered as a single grey circle with
no visible track.

Excluded .o_field_boolean_toggle from the checkbox override and added
explicit Bootstrap form-switch styling — width: 2em, height: 1.2em,
accent colour on checked state, accent-bg focus ring. Non-switch
checkboxes (Blanket SO, Block partial shipments etc.) keep the 18px
square treatment.
2026-05-27 00:29:46 -04:00
gsinghpal
18072c9c60 fix(reports+configurator): clean description, recipe propagation, uppercase rendering
H1 — Recipe propagation hardening for multi-part orders. The G3 onchange
fires when material_process changes, but a newly-added line (especially
via inline part create) sometimes didn't pick up the recipe before
confirm. In action_create_order, just BEFORE building so_vals, force
line.process_variant_id = wizard.material_process if the line is missing
one. Also added the same fallback inside the so_vals dict so the SO line
always carries the right recipe even if the wizard line missed it.

H2 — Strip 'spec - PART Rev X (xN)' header from customer-facing
description. Per user feedback, the customer-facing reports (SO
confirmation, Invoice, CoC, packing slip, BoL) should show ONLY the
typed description + thickness in the Description column. The legacy
header that prepended part metadata to line.name duplicated info from
the Part Number column. Wizard now writes ONLY the customer description
to line.name; the Part Number column owns the part-rev-name display.

H3 — Uppercase customer-facing description in reports. The shared
customer_line_description macro now wraps the description, serial,
and thickness in text-transform: uppercase divs. All reports that use
the macro (SO confirmation, Invoice, CoC, packing slip, BoL) get the
caps treatment automatically. Non-part lines (freight, rush fees)
keep their natural casing.

Manually cleaned up DOD-00154/SO-30062:
- Backfilled line 682 with the header recipe (ENP ALUM BASIC HP)
- Stripped the legacy 'No spec - PART Rev (xN)' header from both
  lines' names; descriptions now read 'THIS IS TEST SPECIFICATIONS...'
  and 'THIS IS BLB ABLA BOLL' cleanly.
2026-05-27 00:24:27 -04:00
gsinghpal
1d0d4afdbf fix(jobs): override_map always wins in _is_node_included + 2 wiring fixes
Three cascading bugs caused DOD-00153/WO-30061 to confirm with zero
steps (and DOD-00150 to keep masking/bake even with overrides):

1. _is_node_included() in fp_job._generate_steps_from_recipe consulted
   the per-job override_map ONLY when node.opt_in_out was 'opt_in' or
   'opt_out'. Default is 'disabled' (mandatory), so overrides on
   mandatory recipe nodes (Masking, De-Masking, Oven baking) were
   silently ignored. Fix: consult override_map FIRST — explicit per-job
   override always wins, regardless of node's opt_in_out value.

2. fp.direct.order.line.recipe_choice_ids didn't include the wizard's
   material_process recipe (Express Orders order-level recipe), so the
   line's process_variant_id domain rejected propagation. Added a 4th
   tier to the compute that pulls the order's header recipe in.

3. sale_order._fp_resolve_recipe_for_line fell back from line picker
   to part.default_process_id with nothing between. Added Express
   header recipe (self.x_fc_material_process) as a 2nd-priority
   fallback — catches cases where G3 propagation failed to reach the
   line but the SO header has the recipe set.

Also fixed an unrelated G4 bug: _FP_PART_SYNC_FIELDS mapped
process_variant_id → 'default_process_variant_id' which doesn't
exist. Real field is 'default_process_id' (singular).

Cleaned up DOD-00153/WO-30061 manually: backfilled line +
job.recipe_id, regenerated steps with overrides respected. 8 steps
now visible, masking/bake correctly omitted.
2026-05-27 00:11:25 -04:00
gsinghpal
f5cee25299 fix(configurator): override helper kind names — mask/demask/bake (not masking/de_masking/baking)
Root cause: spec + plan assumed fusion.plating.process.node.default_kind
values for masking/baking nodes were 'masking', 'de_masking', 'baking'.
Actual values per inspection of WO-30060 / recipe ENP-ALUM-BASIC:
  - 'mask'    (Masking step)
  - 'demask'  (De-Masking step)
  - 'bake'    (Oven baking / Oven bake post de-rack)

So _fp_apply_express_overrides_to_job was searching for nodes that
don't exist → no override rows created → step generation included
masking + bake even when the SO line had x_fc_masking_enabled=False
and x_fc_bake_instructions=empty.

Fixed all 4 occurrences in _fp_apply_express_overrides_to_job:
- pre-deletion search uses ('mask','demask','bake')
- masking opt-out walker calls ('mask','demask')
- bake opt-out walker calls ('bake',)
- bake step instructions filter uses default_kind == 'bake'

Manually cleaned up DOD-00150 / WO-30060:
- Deleted the 4 masking/bake steps that were wrongly created
- Created 4 override rows so any re-generation respects the opt-outs

Future orders with masking off / bake empty will correctly skip these
recipe nodes at step-generation time.
2026-05-26 23:58:09 -04:00
gsinghpal
6351aa6054 fix(configurator): pass .id when carrying material_process M2O to sale.order create
Regression from G2 conversion (Char → Many2One). The wizard's
action_create_order built so_vals with 'x_fc_material_process':
self.material_process (the recordset) instead of .id. Passing a
recordset where an integer FK is expected raised:
  psycopg2.ProgrammingError: can't adapt type 'fusion.plating.process.node'
at sale.order create time, breaking Confirm Order.

Python-only fix — no module upgrade needed, systemctl restart picks
it up.
2026-05-26 23:49:06 -04:00
gsinghpal
a7cbd1a6f7 fix(configurator): Part cell row 1 shows part# / rev separately via CSS overlay
Root cause: my @api.depends_context('fp_express_part_picker') decorator
on _compute_display_name was not honored by Odoo. Verified via odoo
shell — display_name returns the full 'PART (Rev X) — Name' regardless
of context. Reason: display_name is defined on the base Model class
and Odoo registers the field metadata (incl. _depends_context) when
the field is FIRST declared. Subclass redefinitions of the compute
method don't update _depends_context after the fact.

Workaround: don't rely on display_name context override. Instead,
overlay a custom span on top of the Many2OneField that shows JUST
the part_number_display value. CSS overlay uses:
- position: absolute / inset: 0
- background: $xpr-card (matches list row background)
- z-index: 2 over the picker
- pointer-events: none so clicks pass through to the picker

When the picker is focused (:focus-within parent), the overlay
hides so the user sees the autocomplete input value as they type.
When not focused, the overlay covers display_name with just the
part number.

Row 1 now reads 'ENG-1042  /  B' — picker on the left (showing only
part_number_display), separator, revision on the right. Matches the
mockup pixel layout the user requested.
2026-05-26 23:41:58 -04:00
gsinghpal
9c7b7c54e5 feat(configurator): Part cell rows 2-3 now editable — type to save
Customer feedback: rows 2 (description) and 3 (serials) in the Part
cell rendered as read-only spans. User wanted to edit directly.

New writable computed fields on fp.direct.order.line:
- part_name_editable: compute reads part_catalog_id.name, inverse
  writes back to part.name on the linked catalog record
- serials_text: compute joins serial_ids names with commas; inverse
  parses the typed string and find-or-creates fp.serial records,
  updates the line's serial_ids M2M

Removed the redundant rev separator (display_name already includes
'(Rev X)' so showing it twice was clutter). Rev edits happen by
editing the part record directly via the OPEN button.

OWL widget templates updated:
- Row 2: <input> bound to part_name_editable, t-on-change saves
- Row 3: <input> bound to serials_text, t-on-change parses + saves

SCSS:
- Row 2 input: italic, transparent border, focus tints background yellow
- Row 3 input: small grey text, comma-separated friendly placeholder
- Both disabled-look when no part is picked

Both inputs trigger the inverse method on blur. The G4 sync chain
takes over from there to push line.line_description etc. back to
the part as before — so editing in the line keeps the part defaults
fresh for future orders.
2026-05-26 23:26:58 -04:00
gsinghpal
48c2a4bfe1 feat(configurator): 19.0.22.1.0 — recipe-driven orders + auto-sync to part
Four customer-feedback fixes (G1-G4):

G1 — Part cell display redundancy. fp.part.catalog.display_name was
showing 'PART (Rev X) — Name' which duplicated with my Part cell widget's
separately-rendered revision + name rows. Added @api.depends_context
('fp_express_part_picker') to _compute_display_name: when the context
flag is True, display_name returns JUST the part_number. The Express
view passes the flag on the part_catalog_id field, so the picker shows
'9876699373' and the widget's row 2/3 show the rev + name.

G2 — Material/Process Tag is now the order's RECIPE, not a free-text
shop tag. Converted material_process from Char to Many2One(fusion.
plating.process.node) with domain [('node_type','=','recipe')] on both
fp.direct.order.wizard AND sale.order. Pre-migration (19.0.22.1.0/
pre-migrate.py) drops the old VARCHAR column so Odoo recreates as
INTEGER FK. Per dev-stage policy, old tag data is dropped.

G3 — Auto-apply order recipe to every line. New onchange
_onchange_material_process_apply_to_lines on the wizard: when the
header recipe is picked / changed, propagate to every line's
process_variant_id (unless the line has an explicit per-line override
that doesn't match the previous header value).

Plus an override on fp.direct.order.line.create that seeds new lines'
process_variant_id from wizard.material_process. So a newly-added
line auto-inherits the order's recipe.

G4 — Auto-sync line edits back to the part catalog. New
_fp_sync_to_part method called from create() + write() on
fp.direct.order.line. Tracked fields:
- line_description     → part.default_specification_text
- bake_instructions    → part.default_bake_instructions
- thickness_range      → part.x_fc_default_thickness_range
- masking_enabled      → part.default_masking_enabled
- process_variant_id   → part.default_process_variant_id

Future orders for the same part will auto-pull these updated defaults
via the existing _onchange_part_default_thickness chain. Last-write-
wins semantics across concurrent edits (acceptable per dev-stage).
2026-05-26 23:20:27 -04:00
gsinghpal
4c5ee6143c feat(configurator): reorganize header — PO block spans 2 grid rows, fill the right side
Wasted-space audit revealed:
1. PO Block occupied a wide 2-col span but its inner inputs didn't
   fill the available width (130px label column + narrow input area)
2. Customer Job # + Job Sorting placed in row 2 cols 3-4 next to the
   tall PO block left empty vertical space below them
3. Row 2 col 3-4 was sparse because the PO block forced row 2 to be tall

Reorganized:
- PO Block now spans 2 grid ROWS (.row-span-2 class → grid-row: span 2)
- Customer Job # / Job Sorting flow into row 2 cols 3-4 (alongside PO top)
- Material/Process Tag / Lead Time flow into row 3 cols 3-4 (alongside
  PO bottom) — filling the previously-empty space next to the PO block
- Row 4 (after PO ends): Payment Terms / Delivery Method / Pricelist /
  Quote Validity — full 4-col width back
- Row 5: Blanket Sales Order + Invoice Strategy + conditional Deposit % /
  Progress Initial % (only show when relevant invoice strategy picked)

Inside the PO block:
- Label column tightened 130px → 110px so the input takes more width
- Inputs + Many2One wrappers now have width: 100% propagated, so PO #
  and Expected By inputs fill the available row width
- Upload button restyled with the accent colour (was the green default)

Net effect: same field count but tighter packing, no empty vertical
or horizontal space next to the PO block.
2026-05-26 22:54:35 -04:00
gsinghpal
faffdca592 fix(configurator): proper column widths via arch + show blanket SO checkbox
Root cause for column widths: Odoo 19's column_width_hook.js dynamically
sets inline widths on every cell at render time, overriding any CSS
width on td/th selectors. Confirmed by reading the hook source on
entech: 'A width can also be hardcoded in the arch (width="60px").'

Fix: set width='Npx' as an ARCH ATTRIBUTE on each <field> in the line
list:
- Part Number 230px, Line Job # 80px, Thickness 100px, Mask 55px,
  Bake 120px, Qty 55px, Price 80px, Subtotal 90px, Action stack 60px
- Specification + Internal Notes get NO width → take remaining flex
  space (responsive: layout adapts to viewport)

Root cause for missing checkbox: my SCSS underline-style override
selected ALL .o_field_widget input including type=checkbox, rendering
checkboxes as 30px-tall full-width transparent text inputs.

Fix: exclude type=checkbox/radio/file from the underline rule, and
add explicit rendering for type=checkbox (18px square, accent-coloured)
inside .o_fp_xpr_cell. The Blanket Sales Order checkbox + the inline
Block partial shipments checkbox are now both visible.
2026-05-26 22:48:59 -04:00
gsinghpal
15e25ca50b feat(configurator): Express form polish — 4 fixes per user review
1. Blanket Sales Order — match legacy field shape. Renamed label from
   'Blanket SO' to 'Blanket Sales Order' (matches legacy view), removed
   the boolean_toggle widget (defaults to checkbox), and added the
   sibling 'block_partial_shipments' field inline (only visible when
   blanket is checked, with 'Block partial shipments' helper text).

2. Column widths — give roomier columns where data needs space, tighten
   numeric columns. Part Number 230px, Specification min 220px,
   Internal Notes min 140px, Qty 60px, Price 80px, Subtotal 90px,
   Mask 55px, Bake 130px, Action stack 60px.

3. Stacked DWG / OPEN buttons — new OWL widget FpExpressActionBtns
   (express_action_btns.js + .xml) renders both buttons vertically in
   ONE column to save horizontal space. Widget binds to a new
   action_btns_anchor field (related from part_catalog_id) on the
   line. Each button shows tooltip + disabled state when no part is
   picked; DWG triggers the native file picker, OPEN navigates to the
   part record.

4. Field activation — clicking the cell anywhere now focuses the
   input, not just clicking the label. Achieved via:
   - cursor: text on .o_fp_xpr_cell
   - cursor: pointer on labels
   - min-height: 30px on all inputs (larger click target)
   - width: 100% propagated through Many2One wrappers (.o-dropdown,
     .o-autocomplete) so the input genuinely fills the cell
   - box-sizing: border-box so widths are predictable
   - Background tint on focus for visual feedback
2026-05-26 22:35:00 -04:00
gsinghpal
c71e61da77 fix(configurator): remove stray brace in express_order.scss that broke SCSS compile
A bad replacement in the previous commit left an extra '}' that
prematurely closed the .o_fp_xpr block, dumping all the legend bar /
PO status pill / part cell / bake pill styles OUTSIDE the namespace.
SCSS compile silently produced an unusable bundle and the form
rendered without any of the new visual treatment.

Brace balance now verified at 0.
2026-05-26 22:19:58 -04:00
gsinghpal
0f2ed5cc16 feat(configurator): OWL widgets + Express form polish to match mockup
NEW OWL widgets:
- FpExpressPartCell (static/src/js/express_part_cell.js + .xml) — multi-row
  Part cell. Wraps Many2OneField for the part picker (row 1: part# / rev,
  bold). Below it: row 2 part description (italic muted), row 3 serial #s
  joined + '+ bulk' button that triggers the existing bulk-add wizard.
- FpExpressBakePill (static/src/js/express_bake_pill.js + .xml) — click-
  to-edit Bake pill. Renders amber pill when set, italic muted 'no bake'
  when empty. Click swaps to inline textarea + Save / Clear / Cancel.

NEW fields:
- fp.direct.order.line.part_number_display / part_revision_display /
  part_name_display (related Char from fp.part.catalog) — fed to the
  Part cell widget so it can render multi-row without RPC.
- fp.direct.order.wizard.tooling_charge (Monetary) — surfaced in the
  Totals card on the Express form.
- fp.direct.order.wizard.po_status (computed Selection
  received/pending/missing) — drives the PO Block status badge.
- sale.order.x_fc_tooling_charge (Monetary) — receives wizard.tooling_charge
  at confirm.

View updates (fp_express_order_views.xml):
- PO block header now shows the PO status pill (green Received,
  amber Pending, red Missing)
- Order Lines legend bar (Mask / Bake pill / DWG / OPEN explainers)
- Part Number column uses widget='fp_express_part_cell' — single cell
  with 3 internal rows
- Bake column uses widget='fp_express_bake_pill' — interactive pill
- Totals card now has Subtotal / Tooling Charge / Total Lines / Total
  Quantity / Grand Total + currency pill

SCSS adds:
- Multi-row part cell styles (internal borders, bold/italic/muted rows)
- Bake pill (has-bake amber, no-bake italic muted) + inline editor
- Legend bar (section background, gap-spaced explainer chips)
- PO status pill colour scheme
- Bulk button styling

Wizard's action_create_order now carries tooling_charge to the SO at
confirm so it persists on the resulting sale.order.
2026-05-26 22:13:54 -04:00
gsinghpal
1d674e587c feat(configurator): Express form CSS-Grid rebuild — match mockup pixel layout
Previous rebuild used Odoo's <group col='4'> which renders as an HTML
table — colspan+nesting broke into a vertical stack. Replaced entirely
with raw <div> + CSS Grid (display: grid; grid-template-columns:
repeat(4, 1fr)) so the header layout matches the mockup exactly:

- Row 1: Customer (span 2) + Shipping Address (span 2)
- Row 2: PO block (span 2, accent-bordered card with PO#/PDF/Pending
  toggle/Expected date stacked + chase warning) + Customer Job # + Job Sorting
- Row 3: Material/Process Tag + Lead Time (inline X to Y) + Payment
  Terms + Delivery Method
- Row 4: Blanket SO + Currency/Pricelist + Quote Validity + Invoice Strategy

Footer also rebuilt as CSS Grid (1fr 320px) — Notes/Terms cards
stacked in the left column, Totals card with Grand Total + currency
pill in the right column. Each card has a title + subtitle + body
matching the mockup's card chrome.

SCSS overrides Odoo's default field chrome inside .o_fp_xpr_cell so
inputs render with the mockup's underline style (no Bootstrap form-
control border, just a 1px bottom-border that thickens on focus).
2026-05-26 21:51:02 -04:00
gsinghpal
713ba17e37 feat(configurator): Express form layout rebuild — match mockup
Restructures the Express form to align with the brainstorming mockup:

Header (4-column grid via <group col='4'>):
- Row 1: Customer (colspan=2) + Shipping Address (colspan=2)
- Row 2: Consolidated PO Block (colspan=2 with PO#/PDF/Pending toggle/
  Expected date stacked + chase warning inline) + Customer Job # + Job Sorting
- Row 3: Material/Process Tag + Lead Time (X to Y inline) + Payment Terms + Delivery Method
- Row 4: Blanket SO + Currency/Pricelist + Quote Validity + Invoice Strategy

Lines: 13 inline columns including the Express-specific Line Job #,
masking toggle, bake text, plus per-line action buttons (DWG, OPEN,
+ bulk) wired to the Phase B helpers.

Footer: side-by-side cards — Notes + Terms stacked in the left card,
Totals card on the right with Total Lines / Total Qty / Grand Total
+ currency pill.

SCSS adds:
- PO block: accent-bordered card-within-card
- Lines: tight spreadsheet borders, hover row highlight
- Bake column: amber pill style, italic 'no bake' for empty
- Customer Line Job #: bold, uppercase, narrow column
- Inline action buttons: small uppercase bordered chips
- Footer cards with prominent Grand Total + currency pill

OWL multi-row Part cell (FpExpressPartCell) and click-to-edit Bake
pill (FpExpressBakePill) are still deferred — they need real OWL
components, separate pass.
2026-05-26 21:35:54 -04:00
gsinghpal
43abb8ef25 feat(configurator): C1+C3 - pricelist currency picker + Express SCSS (light + dark)
C1: product.pricelist._compute_display_name override gated by the
'fp_express_currency_picker' context flag (set on the Express form's
pricelist_id field). When active, prefixes the dropdown label with
the currency code: 'CAD — Public Pricelist (CAD)'. Elsewhere the
standard display name is unchanged.

C3: SCSS tokens + base styles for the Express form. Tokens use the
compile-time @if $o-webclient-color-scheme branch per the project's
'Dark Mode' rule — same SCSS compiles into both bundles with different
hex values. Token vars wrapped in CSS custom properties so downstream
modules can override for per-shop branding without recompiling.
Base styles: spreadsheet-feel table borders, bake-cell inset-pill,
customer line ref bold/uppercase, accent section markers.
2026-05-26 21:25:59 -04:00
gsinghpal
27af984f28 docs(configurator): E1 - Express Orders smoke test runbook 2026-05-26 21:22:43 -04:00
gsinghpal
aab842d6d3 feat(configurator): D1+D2 - drafts dual-routing + legacy deprecation banner
D1: action_open_draft method routes drafts-list click to the matching
form view by view_source. view_source badge column added to the drafts
list (Express=blue, Legacy=muted).

D2: Deprecation banner on the legacy direct-order form pointing operators
to the new Express view, plus a Switch-to-Express header button. Legacy
action context defaults new drafts to view_source='legacy' (Express
action defaults to 'express') so newly-created drafts open in the right
view automatically.
2026-05-26 21:21:23 -04:00
gsinghpal
a9256dbed7 feat(configurator): B5+B6 - SO line carry-through + part-default write-back on confirm 2026-05-26 21:11:15 -04:00
gsinghpal
200a2efeb8 feat(configurator): B4+B8 - Express onchange auto-fill cascade + DWG/OPEN on direct-order line 2026-05-26 21:11:15 -04:00
gsinghpal
76a80badff feat(jobs): B3 - second hook in fp.job.action_confirm for bake step.instructions write 2026-05-26 21:11:15 -04:00
gsinghpal
095db7375c feat(jobs): B3 - hook Express override helper into _fp_auto_create_job 2026-05-26 21:11:15 -04:00
gsinghpal
299cae8a4e feat(configurator): B2+B7+B8 - Express override helper + serial-bulk + DWG/OPEN buttons on sale.order.line 2026-05-26 21:11:15 -04:00
gsinghpal
baf5c4158f feat(plating): B1 - recipe walker _fp_all_nodes_with_kind for Express Orders 2026-05-26 21:11:15 -04:00
gsinghpal
01df46f79f feat(configurator): C6 - Express Orders form view + menu (v1, stock widgets)
Spreadsheet-style flat entry view on the existing fp.direct.order.wizard
model. Renders:
- Customer + Shipping prominent (row 1)
- PO block (PO# + attachment + Pending toggle + Expected date)
- Scheduling/Lead Time + Pricing/Pricelist (row 2)
- Order Lines spreadsheet with: Part #, Specification, Line Job #,
  Thickness, Mask toggle, Bake text, Internal Notes, Serials, Qty,
  Price, Subtotal, Process/Recipe (optional-show)
- Notes + Terms split (internal vs customer-facing)
- View-switch buttons to bounce between Express and Legacy on a draft

New menu: Plating > Sales > '+ New Express Order' (sequence 3).
Same DB rows as legacy view (view_source='express' for routing).

v1 deferments: OWL FpExpressPartCell (multi-row part cell), OWL
FpExpressBakePill (click-to-edit), DWG/OPEN/+bulk inline buttons,
custom SCSS, quick-create part view, pricelist display_name override.
These come in later iterations.
2026-05-26 21:03:47 -04:00
gsinghpal
92b690aef1 feat(configurator): A5 - wizard schema (rename notes, add Express fields, retire manual currency_id) 2026-05-26 20:58:23 -04:00
gsinghpal
08bc2b6a89 feat(configurator): A4 - add Express header fields to sale.order 2026-05-26 20:58:23 -04:00
gsinghpal
ad3d6261af feat(configurator): A3 - add Express x_fc_* flags to sale.order.line 2026-05-26 20:58:23 -04:00
gsinghpal
f04b31cec7 feat(configurator): A2 - add Express flags to fp.direct.order.line 2026-05-26 20:58:23 -04:00
gsinghpal
5f898d4209 feat(configurator): A1 - add Express Orders per-part defaults to fp.part.catalog 2026-05-26 20:58:23 -04:00
gsinghpal
807ed86ef6 chore(configurator): bump to 19.0.22.0.0 for Express Orders 2026-05-26 20:06:19 -04:00
gsinghpal
525ed6a61d docs(plans): fix Express menu parent xmlid (menu_fp_sales not menu_fp_sales_quoting) 2026-05-26 20:05:02 -04:00
gsinghpal
b308380201 docs(plans): Express Orders implementation plan
22 bite-sized tasks across 6 phases (A: schema, B: backend logic,
C: views + OWL widgets, D: drafts routing + Phase 2 banner,
E: smoke test runbook, F: Phase 3/4 deferred cleanup). Each task
follows TDD pattern (test → fail → implement → pass → commit) with
exact file paths, complete code blocks, and runnable docker exec
test commands. Built from the 2026-05-26 design spec.

Locked decisions baked in: reuse fp.direct.order.wizard, NEW per-line
flags (masking_enabled / bake_instructions / customer_line_ref),
NEW per-part defaults, override-application helper at SO confirm,
pricelist-per-currency picker, quick-create part modal, drawing
upload to part, drafts-list dual-routing, soft-deprecation phase-out.
2026-05-26 20:00:48 -04:00
gsinghpal
7da51b4ec8 docs(specs): Express Orders design spec
Consolidates the brainstorming session into a single design spec. Covers:
header layout + field-to-model mapping, line widget (multi-row Part cell,
masking + bake pills, serial bulk-add trigger), masking/baking override
flow at SO confirm, currency/pricelist picker mechanics, inline part
create + drawing upload + open-part buttons, and phase-out path for the
legacy Direct Order view.

Reuses fp.direct.order.wizard model end-to-end (Q1=D). New fields:
material_process, customer_line_ref, masking_enabled, bake_instructions,
default_specification_text, default_bake_instructions,
default_masking_enabled, x_fc_internal_notes, x_fc_print_terms.
Rename: wizard.notes → terms_and_conditions. Retire: wizard.currency_id.

Mockup at .claude/mockups/express_orders.html (interactive, light + dark).
2026-05-26 19:50:01 -04:00
gsinghpal
5764d439c3 changes 2026-05-26 19:17:57 -04:00
gsinghpal
5f372b462a changes 2026-05-25 20:11:03 -04:00
gsinghpal
67af54b46e docs(CLAUDE.md): note Windows-side browser preview limitation
User is on Mac via Tailscale into this Windows host. Browser previews
bound to Windows localhost are unreachable from the Mac browser. Default
to text-based design discussion on this host instead of spinning up the
brainstorming visual companion. Has bitten three times now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:58:20 -04:00
gsinghpal
5a699de1ca docs: Express Orders brainstorm handoff to Mac session
Captures all clarifying-question answers + exploration findings so a
fresh Claude Code session on Mac can resume at 'propose architectural
approaches' without re-running the discovery work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 19:57:44 -04:00
gsinghpal
1b473a7873 fix(tablet_pin_reset): manifest data slot + drop notif wrapper (deploy fixes)
Two bugs caught by entech battle test on first deploy:

1. Manifest entry landed in the 'demo' list instead of 'data' because
   my anchor (fp_demo_shopfloor_data.xml) was already in 'demo' —
   the entry pattern-matched into the wrong section. Demo data
   doesn't load on entech (no --load demo), so the mail.template
   never existed. Moved fp_tablet_pin_reset_template.xml to 'data'.

2. The fp.notification.template wrapper record referenced a model
   that doesn't exist until fusion_plating_notifications loads;
   fusion_plating_shopfloor doesn't depend on notifications, so
   the data load ParseError'd. Removed the wrapper — the controller
   calls mail_template.send_mail() directly anyway, not via the
   notification dispatcher. Added an inline comment explaining why
   the wrapper isn't here.

Battle test updated to drop the (now removed) wrapper xmlid check.
Battle test ALL PASS on entech after fixes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 17:02:18 -04:00
gsinghpal
9223f8da7c test(bt): tablet PIN self-service entech smoke (Task 7)
10-step smoke via odoo-shell:
  1. Pick real no-PIN shop user
  2. _generate_for_user -> assert 4-digit code + active row
  3. Wrong code -> assert rejected + attempt_count incremented
  4. Correct code -> assert ok + used_at set
  5. _sign_reset_token + _verify_reset_token roundtrip
  6. set_tablet_pin (mirrors set_pin endpoint reset_token branch)
  7. verify_tablet_pin -> assert new PIN works
  8. mail.template ref resolves
  9. fp.notification.template ref resolves
  10. Cleanup cron ref resolves

Cleans up: reverts PIN + deletes reset rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:54:46 -04:00
gsinghpal
8c9b645196 feat(tablet_lock): PIN self-service wizard (Task 6)
4 new state-machine modes on FpTabletLock, reusing the existing
FpPinPad 4-cell component:
  - request_code    : 'Send temp PIN' button screen (no-PIN tile OR
                      after 3-fail Forgot button)
  - enter_temp_code : 4-cell pad for the emailed code
  - set_new_pin     : 4-cell pad — choose new PIN
  - confirm_new_pin : 4-cell pad — confirm new PIN

Trigger paths (per D1 + D2):
  - Tap no-PIN tile -> goes straight to request_code mode
    (onTileClick dispatches via tile.has_pin)
  - Wrong PIN 3 times -> 'Forgot? Reset PIN via email' button appears
    below the pad (gated by state.failedAttempts >= 3)

Client-side failedAttempts counter (resets on tile re-select per D14).
Server-side x_fc_tablet_pin_failed_count keeps incrementing to the
existing 5-fail lockout per D13.

After Confirm New PIN succeeds, auto-login fires unlock_session with
the new PIN. If unlock_session fails for any reason, falls back to
'PIN set, tap your tile to log in.' status.

SCSS reuses $lock-* tokens from _tablet_lock_tokens.scss — light +
dark handled by the existing token system (no new tokens needed).
Hand-Off gold gradient repeated for the primary 'Send temporary PIN'
button to match the existing tablet visual language.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:54:09 -04:00
gsinghpal
2aa4bce089 feat(tablet): mail template + notification + cleanup cron (Task 5)
Mail template renders the 4-digit code in both subject (mobile
notification glance) and body (big bold display). Per Rule 25 only
core res.users fields referenced; the code itself comes from ctx.

fp.notification.template wrapper enables admin UI customization of
the body without touching code. tablet_pin_reset_requested added to
TRIGGER_EVENTS selection.

Daily ir.cron purges used/expired rows > 7 days old (audit trail
lives in fp.tablet.session.event, not here, so aggressive cleanup
is safe).

Manifest bump 19.0.34.2.0 -> 19.0.35.0.0 (triggers asset cache
invalidation on -u so the new template + SCSS load cleanly).

Phase 1 backend complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:51:25 -04:00
gsinghpal
46c62ebefa feat(tablet): request/verify reset code endpoints + set_pin token (Tasks 2-4)
Three controller changes in one commit (tight code coupling):

1. /fp/tablet/request_reset_code (Task 2) — generates 4-digit code,
   emails it, returns masked_email. Specific error codes for the
   frontend to switch on (no_email + manager_name, rate_limited +
   wait_minutes, user_not_found, no_role, inactive). Shop-branch
   role check matches existing _check_credentials per Rule 13l + 23
   (all_group_ids transitive — Owners reach Technician through
   implication).

2. /fp/tablet/verify_reset_code (Task 3) — verifies the emailed
   code, on success mints a 5-min HMAC reset_token. Error responses
   are specific (no_active_code / expired / too_many_attempts /
   wrong_code with attempts_left).

3. set_pin extended to accept reset_token (Task 4) — three auth
   paths now: old_pin (existing), reset_token (new), or neither
   (existing — only for users with no current hash). reset_token
   path is the only one that operates on a user OTHER than env.user;
   token proves the legit user just verified their email.

Failure audit reuses existing failed_unlock event_type with a notes
field describing the reset-code-specific reason. Success audit uses
the new pin_reset_requested / pin_reset_code_verified /
pin_set_after_reset event_type values.

_mask_email helper added for the no-email-on-file edge case.

3 more tests cover: valid token roundtrip + set_pin, expired token
rejection, and lockout-cleared-on-reset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:50:18 -04:00
gsinghpal
152e6d4328 feat(tablet_pin_reset): new model + hash helpers + token sign (Task 1)
fp.tablet.pin.reset stores hashed 4-digit codes emailed for self-
service PIN create/reset. Per CLAUDE.md Rule 24 + Rule 13l it follows
the defensive patterns established elsewhere in the shopfloor module:
  - PBKDF2-SHA256 hashing (200k iterations, matches ResUsers PIN)
  - 72h TTL per D4
  - 5 wrong-attempt cap per D5 (invalidates code, used_at set)
  - 3 requests/60min rate limit per D6 (raises UserError)
  - SQL EXCLUDE constraint enforces one-active-row-per-user per D7
  - HMAC-SHA256 reset_token (300s TTL, single-use) for step 3 of
    the flow (set_pin via reset_token alternative to old_pin)

Audit event_type extended with 3 new values (pin_reset_requested,
pin_reset_code_verified, pin_set_after_reset). Manager-only ACL on
the new model; sudo when endpoints need access.

10 model-level tests cover generate / replace-active / rate-limit /
verify-correct / verify-wrong / 5-attempt-cap / expired / token sign
roundtrip / tampered-sig / purpose-mismatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:48:45 -04:00
gsinghpal
33fff5acba docs(plan): tablet PIN self-service implementation plan
8 tasks across 3 phases:
  Phase 1 — Backend foundation (Tasks 1-5)
    T1: New model fp.tablet.pin.reset + ACL + event_type extension
        + 10 model tests (hash helpers, lifecycle, rate limit,
        attempt cap, expired, token sign roundtrip + tamper checks)
    T2: /fp/tablet/request_reset_code endpoint
    T3: /fp/tablet/verify_reset_code endpoint
    T4: /fp/tablet/set_pin accepts reset_token alternative
        (+ 3 more tests)
    T5: mail.template + fp.notification.template + cleanup cron
  Phase 2 — Frontend (Task 6)
    T6: FpTabletLock wizard — 4 new state-machine modes
        (request_code, enter_temp_code, set_new_pin, confirm_new_pin),
        reuses FpPinPad 4-cell component, auto-login chain,
        client-side 3-fail counter for 'Forgot?' button
  Phase 3 — Deploy (Tasks 7-8)
    T7: Manifest bump 19.0.34.2.0 -> 19.0.35.0.0 + bt_pin_reset
        entech smoke
    T8: Sync 14 files + upgrade + asset bust + smoke + 8-step
        manual QA + tag deploy

Implements: docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:42:04 -04:00
gsinghpal
2ae1c867b5 docs(brainstorm): tablet PIN self-service (create + reset via email)
User goal: from the Shop Floor Terminal lock screen, a user with no
PIN (or who forgot their PIN) should be able to set / reset their
own PIN without a manager's help. Today, FpPinSetup runs only from
Preferences which requires being logged in — there's no path from
the lock screen.

Design (approved, with user-picked defaults):
- Tap tile of no-PIN user -> 'Send temporary PIN' button -> email
  4-digit code, valid 72 hours -> enter code -> choose new PIN ->
  auto-login.
- For existing-PIN users: 3 failed PIN entries -> 'Forgot? Reset
  PIN via email' button appears below keypad -> same email flow.
- Both flows merge at: enter temp code -> set new PIN.
- Email goes to res.users.login (or partner_id.email fallback).
  No-email-on-file -> 'Contact your manager: <owner>' message.
- Rate limit: 3 requests per user per rolling 60 min.
- Per-code cap: 5 wrong attempts invalidates the code.
- New model fp.tablet.pin.reset stores hashed code + expires_at
  with SQL constraint enforcing one-active-row-per-user.
- 2 new endpoints (request_reset_code, verify_reset_code) + extend
  existing /fp/tablet/set_pin to accept reset_token alternative
  to old_pin.
- Audit: 3 new event_type values on fp.tablet.session.event.
- Reuses existing PBKDF2 helpers, FpPinPad component (mode prop),
  fp.notification.template dispatch, mail.template pattern.

Per CLAUDE.md Rule 25 the mail template references ONLY core
res.users fields (object.name, object.email, object.login,
object.company_id) — ctx.code is dispatched as extra_context, not
a model field. Safe at parse-time.

Self-review fixed 2 issues:
- event_kind -> event_type (real field name on fp.tablet.session.event)
- Listed existing event_type values explicitly for context

Spec: docs/superpowers/specs/2026-05-25-tablet-pin-self-service-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:30:36 -04:00
gsinghpal
c990110646 chore: gitignore .claude/ preview-tooling state
The Claude Preview MCP writes launch.json + throwaway HTML mockups
to .claude/ during brainstorming sessions. Not project source.
2026-05-25 12:30:05 -04:00
gsinghpal
5872583fbb fix(quality_dashboard): correct kanban xmlids per battle test (Task 9 fix)
Plan-time xmlids were wrong — entech battle test caught all 5
non-cert kanban xmlids missing. Real xmlids (queried via
ir.model.data on entech):
  hold:  action_fp_quality_hold     (was action_fusion_plating_quality_hold)
  ncr:   action_fp_ncr              (was action_fusion_plating_ncr)
  rma:   action_fp_rma              (was action_fusion_plating_rma)
  capa:  action_fp_capa             (was action_fusion_plating_capa)
  check: action_fp_quality_check    (was action_fusion_plating_quality_check)
cert stays unchanged — action_fp_certificate was already correct.

After fix: battle test ALL PASS — 6 sections in canonical order,
all xmlids resolve, 3 banner items pulled from real entech data
(5 draft certs, 3 of them overdue past 24h).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:28:52 -04:00
gsinghpal
c8db3915ea feat(quality_dashboard): manifest bump + battle test (Tasks 7-8)
Version 19.0.7.0.0 → 19.0.8.0.0 (triggers asset cache invalidation
on -u so the new template + SCSS load cleanly).

Battle test script: 6-check entech smoke. Validates snapshot shape,
canonical section order, required section keys, open_kanban_xmlid
resolves to act_window, banner item shape when items exist. Summary
prints per-section counts so you can eyeball the entech state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:25:31 -04:00
gsinghpal
547e7d66a9 feat(quality_dashboard): rewrite OWL component + template + SCSS (Task 6)
JS: single FpQualityDashboard component + BannerCard / BannerItem /
SectionCard / SectionRow sibling sub-components in the same file.
Fetches /fp/quality/dashboard/snapshot, 60s poll, deep-link
?tab=certificates scrolls to section-cert via scrollIntoView.

XML: outer wrapper + banner + 6 sections (t-foreach over
state.snapshot.sections). Each section has id='section-<type>' so
the deep-link target works. SectionRow has overdue-conditional
class for red subtitle highlight.

SCSS: local tokens for urgent/good/section-head with light+dark via
$o-webclient-color-scheme branch. 135deg gradients matching the
plant kanban polish. Mobile breakpoint at 900px collapses banner
grid to 1 col and stacks row Open button.

OLD TABS array, selectTab, openTab, totalOpen, totalOverdue all
deleted. Old template's tab tiles + per-tab panels deleted. Existing
per-model kanbans untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:24:52 -04:00
gsinghpal
bfeca0ac32 test(quality_dashboard): defensive guard tests (Task 5)
Covers: missing-field critical-customer check returns empty without
crashing; computed_at is a valid ISO timestamp; every section ships
a non-empty open_kanban_xmlid in module.xmlid format.

(missing-model test from the plan omitted — patching env.__contains__
was unsafe; the in-self.env guard is already exercised by Tasks 2-4
in production behavior. The other 3 defensive tests still cover the
missing-field path, which is the more common scenario.)

Phase 1 backend complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:22:25 -04:00
gsinghpal
40d563801a feat(quality_dashboard): banner with overdue + critical (Task 4)
_fetch_banner_candidates collects (overdue) OR (critical-customer +
open) records per type. _critical_customer_ids reuses partner.x_fc_rush
and partner.x_fc_vip flags when defined (gracefully no-ops when
absent). _critical_badge returns RUSH/VIP/AEROSPACE/AS9100 label
when the banner reason is critical-customer (no badge when overdue).
_build_banner ranks: overdue first by oldest, then critical-customer
by oldest, takes top 6, reports total_matching.

build() now collects banner candidates from every section in one
pass + invokes _build_banner once.

Tests cover overdue hold pickup, 6-cap with overflow count, and
all_clear when DB is empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:22:01 -04:00
gsinghpal
e271908109 feat(quality_dashboard): populate section items (Task 3)
_fetch_section_items pulls top-5 open records per type, ranked
overdue-first by oldest create_date. _build_item shapes each row
with id/name/customer/subtitle/urgency/open_action. _resolve_partner
defensively walks partner_id -> job_id.partner_id -> ncr_id.partner_id
per type. _build_subtitle generates the human-readable second line.

Tests cover empty list, 5-cap on 8-record set, and required item
keys (id/name/customer/subtitle/urgency/open_action).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:20:50 -04:00
gsinghpal
72f75fe754 feat(quality_dashboard): snapshot endpoint scaffold (Task 2)
Replaces /counts with /snapshot. Helper class FpQualityDashboardSnapshot
returns response with correct shape — banner placeholder + per-type
sections with open/overdue counts (reuses old counts endpoint
thresholds). Items + critical-customer banner come in Tasks 3-5.

Per CLAUDE.md Rule 13m, Model.sudo() on cross-module reads. Per
Rule 24 the in-self.env check guards missing-model paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:19:48 -04:00
gsinghpal
6cb352629a test(quality_dashboard): scaffold + shape tests (Task 1)
Tests for empty-DB all-clear, canonical section order, and required
keys on each section. All fail until Task 2 lands the snapshot helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:19:06 -04:00
gsinghpal
d53bb73055 docs(plan): quality dashboard redesign implementation plan
9 tasks across 3 phases:
  Phase 1 — Backend snapshot endpoint (Tasks 1-5)
    T1: Test scaffold + shape tests
    T2: Minimal helper + endpoint making shape tests pass
    T3: Section items population + tests
    T4: Banner with overdue + critical-customer ranking + tests
    T5: Defensive guards + missing-model tests
  Phase 2 — Frontend (Task 6)
    T6: Wholesale rewrite of JS + XML + SCSS (banner card + 6
        section cards, sibling sub-components in same JS file,
        deep-link scrollIntoView, 60s poll, dark mode via
        compile-time SCSS branch)
  Phase 3 — Polish + deploy (Tasks 7-9)
    T7: Manifest version bump 19.0.7.0.0 → 19.0.8.0.0
    T8: Entech smoke battle test (7 checks)
    T9: Sync + upgrade + asset bust + smoke + manual QA

Implements: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:15:41 -04:00
gsinghpal
ff51035494 docs(brainstorm): quality dashboard redesign — action surface
User goal: 'all quality related updates at glance, all the flagged
tasks need to show right here so the manager can quickly follow up
and complete the task'. Current dashboard is a tab-router (6 numeric
tiles + click-to-drill) — flagged tasks aren't visible without
navigation.

Design (Hybrid layout, approved):
- Red 'Needs Attention Today' banner on top (up to 6 items, 2x3 grid)
  showing items that are overdue OR from critical customers
  (x_fc_rush / x_fc_vip / aerospace). Green 'all caught up' when zero.
- Per-type sections below in QM-urgency order: Certs / Holds / NCRs /
  RMAs / CAPAs / Checks. Each shows top 5 items inline + Open all
  link to the existing kanban.
- Single 'Open ->' button per row -> opens record form via act_window.
  No one-click action shortcuts (cert form is where Fischerscope +
  sign-off prereqs are validated).
- Drop the existing 'Quality Overview' header strip entirely.
- 60s poll cadence preserved.
- ?tab=certificates deep-link from awaiting-cert notification email
  preserved as scrollIntoView on the certs section.

Backend: replace /fp/quality/dashboard/counts with /snapshot. New
helper class FpQualityDashboardSnapshot builds banner + 6 sections in
one response. Cross-module reads sudo'd per Rule 13m; missing fields
gracefully degrade per Rule 13j defensive pattern.

Frontend: rewrite the OWL component. BannerCard + 6 SectionCards as
sub-components in the same JS file (not reused elsewhere yet).
Existing per-model kanbans untouched.

Self-review fixed 4 issues:
- _critical_customer_domain made per-type (was contradictory)
- OVERDUE_THRESHOLDS gained explicit use_due_date flag (CAPA branch)
- Template requirement called out: id='section-<type>' on each card
  for the deep-link scrollIntoView to work
- doAction call shape disambiguated for xmlid vs full dict

Spec: docs/superpowers/specs/2026-05-25-quality-dashboard-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:07:38 -04:00
gsinghpal
0ed4f88da2 feat(job_workspace): polish header buttons + workflow bar + next button
Same polish pass as the plant kanban — bigger touch targets, gradient
backgrounds (light + dark via existing token system), more readable
typography.

Header (top row):
  - Back: bigger padding, hover lift
  - Hand Off: was btn-sm \u2192 full size; gold gradient + shadow
    matching the plant kanban .toolbar-btn.handoff treatment
  - WO #: 1.1rem \u2192 1.3rem
  - Pills (qty done, due date, holds): bigger padding + font, subtle
    gradient bg, hold pills get tinted gradients
  - Customer / part name: 0.95rem (more readable)

Workflow bar (step dots + next button):
  - Step dots: 14px \u2192 18px, current scales 1.15x with bigger
    halo, gradient fills (green for done, blue for current)
  - Step labels: 0.65rem \u2192 0.8rem (the original was unreadable
    at arm's length on a tablet)
  - Connector links: 2px \u2192 3px, rounded
  - Next button: prominent green gradient (was generic btn-primary),
    bigger padding + font, hover lift + shadow

Manifest: fusion_plating_shopfloor 19.0.34.1.0 \u2192 19.0.34.2.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 11:03:06 -04:00
gsinghpal
caeba27846 feat(plant_kanban): polish KPI strip + chips + toolbar buttons
User feedback after first deploy: 7 KPI tiles wrapped to second line
(grid was repeat(5, 1fr) but I had added 2 new ones), and the
controls felt cramped.

Layout fix:
  - .kpi-strip grid: repeat(5, 1fr) → repeat(8, 1fr) so the row stays
    one line and there's room for the new Awaiting QC tile.

Missing KPI added:
  - Awaiting QC — fp.job.card_state='awaiting_qc' count. Operators
    couldn't see when QC was blocking job close from the KPI strip
    (only visible inside the column). Server-side count + filter
    clause + matching filter chip.

Visual polish (all light + dark via existing token system):
  - KPI tiles: padding 6→10px, value font 20→26px, label font 9→10px,
    subtle 135deg linear-gradient bg per kind (urgent/warn/good/qc),
    hover lifts the tile with translateY + shadow.
  - Filter chips: padding 4/12→7/16px, font 11→13px, gradient bg,
    active state has gradient blue + shadow.
  - Search input: padding 5/10→9/14px, font 12→14px, focus ring.
  - Toolbar buttons (Station/All Plant/Manager/Scan QR/Hand Off):
    padding 5/10→8/14px, font 12→14px, gradients, hover lift.

Dark mode handled automatically — all gradients reference
$plant-* tokens which already have @if $o-webclient-color-scheme ==
dark global overrides in _plant_tokens.scss.

Version bump fusion_plating_shopfloor 19.0.34.0.0 → 19.0.34.1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:58:11 -04:00
gsinghpal
a2e254b934 fix(fp.job): post-shop state machine entech smoke fixes (Task 23)
Three bugs caught + fixed during entech battle test:

1. _fp_check_finish_gates calling button_mark_done triggered the
   step-completion gate prematurely (step still in_progress at
   pre-super time). Pass fp_skip_step_gate=True alongside
   fp_check_gates_only — we know the operator is about to finish
   the last open step.

2. _fp_schedule_cert_activity used env.get('fp.notification.template')
   for presence check. env.get returns an EMPTY recordset (falsy),
   not None — 'if not Template: return' silently exited and no
   activity was ever scheduled. Switch to 'in self.env' check
   pattern + explicit indexing. CLAUDE.md Rule 24.

3. _fp_check_advance_after_cert_issue + _fp_check_regress_after_cert_void
   used 'state != issued' as outstanding-cert count. This made
   voided certs count as outstanding forever, so void+re-issue
   cycles never re-advanced. Switch to per-type coverage check:
   each required cert TYPE needs at least one issued cert.
   Regress mirrors: only fire if a type loses all issued certs.

CLAUDE.md gains Rule 24 (env.get falsy empty recordset trap).
Rule 25 (mail.template parse-time validation) renumbered.

Battle test ALL PASS on entech admin DB:
  10/10 steps green — auto-advance, kanban placement, activity
  schedule + auto-resolve, ACL guard, cert issue advance, void
  regress, re-issue advance, manual ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:45:35 -04:00
gsinghpal
8b14466da2 fix(notifications): mail.template only refs core fp.job fields
Entech deploy of 5a039ae3 hit:
  ParseError: Failed to render inline_template template
  AttributeError('fp.job' object has no attribute 'display_wo_name')

Root cause: mail.template data files are parse-time validated by
Odoo (template rendered against sample object). fusion_plating_notifications
loads BEFORE fusion_plating_jobs in dep order, so jobs-module fields
(display_wo_name, part_catalog_id) aren't on the Python class yet
even though the DB columns exist from previous installs.

Fix: strip display_wo_name → name and remove the Part row.
Recipe / qty_done / partner_id stay (all in fusion_plating core).

Logged as CLAUDE.md Rule #24 — same trap will bite anyone else
adding cross-module mail templates. Includes structural alternatives
for callers that really need downstream fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:35:29 -04:00
gsinghpal
5a039ae369 test(bt): post-shop state machine end-to-end smoke (Task 22)
10-step battle test covering: auto-advance on last step finish,
kanban placement, QM activity, ACL guard, cert issue advance,
activity auto-resolve, cert void regress, re-issue, manual ship.

Tolerant of partial state — branches around the awaiting_cert path
when partner doesn't require certs (uses awaiting_ship path instead),
SKIPs subsequent steps when prerequisites fail, rolls back at end so
the DB stays clean.

Run on entech via odoo-shell after deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:54:53 -04:00
gsinghpal
aab6b9275b feat(fp.job): migration 19.0.11.0.0 — backfill new states (Task 21)
Idempotent post-migrate that moves mid-flight in_progress jobs whose
recipe steps are all terminal into the appropriate new state:
  - draft cert exists → awaiting_cert
  - no cert required  → awaiting_ship
done jobs left alone (historically completed, already shipped).

Card_state + mini_timeline_json recomputed for affected rows so the
plant kanban renders correctly on first page load.

Version bump 19.0.10.31.0 → 19.0.11.0.0 triggers the migration on -u.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:53:57 -04:00
gsinghpal
26a1086623 feat(notifications): cert authority events + QM activity (Tasks 17-20)
TRIGGER_EVENTS extended with three new events:
  - cert_awaiting_issuance — fires on in_progress → awaiting_cert
  - cert_voided_re_notify  — fires on awaiting_ship → awaiting_cert
                             regress (cert voided post-issue)
  - job_shipped            — fires on button_mark_shipped

_dispatch routes cert events through new internal-recipient resolver
(QM/Manager/Owner via all_group_ids, transitive per Rule 13l)
instead of the partner-based stream lookup. Other events unchanged.

Mail templates (fp_cert_authority_templates.xml): two new
mail.template records bound to fp.job. Amber accent bar for awaiting,
red accent bar for void-re-issue. Deep-link to
/odoo/action-...?tab=certificates so QM lands on the right tab.

Activity type (fp_activity_types_data.xml): mail.activity.type
activity_type_issue_coc — bound to fp.job, 1-day delay, certificate
icon.

fp.job helpers:
  _fp_schedule_cert_activity: round-robin by oldest login_date,
    idempotent on existing open activity, soft-fails if helpers
    are missing.
  _fp_resolve_cert_activities: auto-resolves on awaiting_ship,
    soft-fails on per-activity exceptions.

Manifest bumps:
  fusion_plating_notifications 19.0.6.6.1 → 19.0.7.0.0
  fusion_plating_jobs: data list gains fp_activity_types_data.xml

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:53:09 -04:00
gsinghpal
c00831a72a feat(quality_dashboard): sixth 'Certificates' tab (Tasks 14-16)
Counts endpoint: certificates block — open=draft, overdue=draft+>24h.
Falls back to {open:0, overdue:0} when fp.certificate isn't installed.

JS: TABS array gains the 6th entry. Existing data-driven OWL template
auto-renders both the header tile and the body panel. Tab opens the
fp.certificate kanban grouped by state, filtered to draft by default.

Deep-link: setup() reads action.context.params.tab. The
cert_awaiting_issuance notification email links to
/odoo/action-fp_quality_dashboard?tab=certificates and lands the QM
on the right tab automatically.

Template: 'Open across all 5' → 'Open across all <tabs.length>' so
it stays correct if more tabs are added later.

Manifest: fusion_plating_quality 19.0.6.6.6 → 19.0.7.0.0
(fusion_plating_certificates already in depends — no change needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:49:10 -04:00
gsinghpal
3a120dd400 feat(plant_kanban): post-shop states visible on board (Tasks 9-13)
Controller (plant_kanban.py):
  - Widen domain: state IN (confirmed, in_progress, awaiting_cert,
    awaiting_ship). Done jobs still drop off.
  - _resolve_card_area: state=awaiting_cert → 'inspection' column,
    state=awaiting_ship → 'shipping' column. State drives column
    regardless of recipe shape.
  - _state_chip: 🏷️ Awaiting CoC (amber) + 📦 Ready to ship (green).
  - _SORT_PRIORITY: awaiting_cert=3.5, awaiting_ship=8.5.
  - KPI dict: awaiting_cert + awaiting_ship counts.
  - Filter clauses for the two new chips.

Model (fp_job.py):
  - _compute_card_state handles new states in BOTH branches: the
    no-active-step early return (where awaiting_cert/ship cards
    land — all steps terminal) AND the per-step branch (defensive).
  - _compute_mini_timeline_json: awaiting_cert paints inspection
    dot 'current'; awaiting_ship paints shipping dot 'current'.
    All earlier dots show 'done'.

SCSS (_plant_tokens.scss + _plant_card.scss):
  - New tokens for amber (cert) + green (ship), light + dark variants
    via the existing $o-webclient-color-scheme compile-time branch.
  - .state-awaiting_cert / .state-awaiting_ship modifier classes
    match the existing border-left pattern.

XML (plant_kanban.xml):
  - Two new KPI tiles + two new filter chips wired to the state
    filter clauses.

Manifest: fusion_plating_shopfloor 19.0.33.2.0 → 19.0.34.0.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:47:17 -04:00
gsinghpal
4dc0a7cca5 feat(fp.certificate): ACL guard + state hooks + age field (Tasks 6-8)
action_issue gated to Manager/QM/Owner via Python AccessError +
view-level groups= on the Issue button (two-layer enforcement).
Manager bypass via fp_skip_cert_authority_gate=True context flag
with chatter audit.

action_issue post-callback calls job._fp_check_advance_after_cert_issue
so the job auto-advances awaiting_cert → awaiting_ship when every
required cert is issued.

write({'state':'voided'}) override calls
job._fp_check_regress_after_cert_void so a previously-issued cert
being voided slides the job back to awaiting_cert and re-notifies
the QM.

x_fc_age_hours non-stored Float drives the Quality Dashboard age
chip + overdue filter.

Version bump 19.0.7.9.3 → 19.0.8.0.0 (spec said 19.0.6.0.0 but
current is already higher; bumped to next major instead).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:43:08 -04:00
gsinghpal
4930a89970 feat(fp.job): button_mark_shipped + milestone cascade integration (Task 5)
button_mark_shipped: manual transition awaiting_ship → done. Does
not re-run the bake/qty/QC gates — those passed at the in_progress
→ awaiting_cert/ship transition. Just the 'yes, shipped' stamp.

Milestone cascade (_compute_next_milestone_action) extended to
recognize the two new states:
  - awaiting_cert → 'issue_certs' button
  - awaiting_ship → 'mark_shipped' button
Legacy state='done' branch preserved for historical jobs.

action_advance_next_milestone now dispatches 'mark_shipped' via
_action_mark_shipped_dispatch which routes:
  awaiting_ship → button_mark_shipped (new path)
  done + active delivery → _action_mark_active_delivery_delivered
    (legacy, unchanged)

View: 'Mark Shipped' milestone button gated on Manager/Owner groups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:26:03 -04:00
gsinghpal
72f0f182a6 feat(fp.job.step): wrap button_finish with gate + advance (Task 4)
Pre-super: when finishing the last open step on an in_progress job,
run the bake/qty/QC gates from button_mark_done so failures surface
as UserError on the click (per spec D12). Without this the
auto-advance would silently fail with no error path.

Post-super: trigger _fp_check_advance_post_shop so the state
auto-advances cleanly (in_progress → awaiting_cert / awaiting_ship).

Added _fp_check_finish_gates helper on fp.job and a
fp_check_gates_only context flag honored by button_mark_done so the
gate logic is single-sourced (DRY).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:22:33 -04:00
gsinghpal
5173554281 feat(fp.job): post-shop transition helpers (Tasks 2+3)
_fp_check_advance_post_shop: in_progress + all steps terminal →
  awaiting_cert (cert required) or awaiting_ship. Auto-spawns cert
  + delivery and fires notifications. Idempotent. Does NOT raise —
  gate failures bubble up via fp.job.step.button_finish (Task 4).
_fp_check_advance_after_cert_issue: awaiting_cert → awaiting_ship
  when every required cert is state=issued.
_fp_check_regress_after_cert_void: awaiting_ship → awaiting_cert
  when a previously-issued cert is voided. Re-notifies QM.

hasattr guards on _fp_schedule_cert_activity + _fp_resolve_cert_activities
keep this safe during incremental rollout — those land in Task 20.

Test scaffolding added covering helper existence + idempotency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:21:22 -04:00
gsinghpal
c2b693c97e feat(fp.job): extend state with awaiting_cert + awaiting_ship
Per spec docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md.
Selection extension only; transitions wired in subsequent tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:20:12 -04:00
gsinghpal
051094813e docs(plan): post-shop cert + shipping job states implementation plan
23 tasks across 8 phases:
  Phase 1 — State machine foundation (extend Selection, advance helpers)
  Phase 2 — Step-level gating hooks (button_finish gates + advance)
  Phase 3 — Certificate-side hooks + ACL (action_issue guard + cert
            void regress + x_fc_age_hours + view groups gating)
  Phase 4 — Plant Kanban visibility (domain, _resolve_card_area,
            chips, SCSS, KPI tiles + filter chips, mini-timeline)
  Phase 5 — Quality Dashboard sixth tab (Certificates)
  Phase 6 — Notification + Activity (events, resolver, templates,
            mail.activity schedule + auto-resolve)
  Phase 7 — Migration 19.0.11.0.0 — backfill mid-flight jobs
  Phase 8 — Battle test + entech deploy

Implements: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:15:27 -04:00
gsinghpal
edf3f95854 docs(brainstorm): post-shop cert + shipping job states spec
Trigger: WO-30058 (SO-30058) finished all recipe steps on entech and
disappeared from the Shop Floor kanban with a draft CoC and no QM
notification. Operators reported jobs feeling lost; risk that a job
could leave the building without paperwork.

Design (Approach A, approved):
- Two new fp.job.state values between in_progress and done:
  awaiting_cert + awaiting_ship
- Auto-advance on last step finish; auto-advance on cert issue
- Plant kanban widens domain, renders the two states in the existing
  Final inspection / Shipping columns
- 6th tab 'Certificates' on Quality Dashboard with kanban + filters
- ACL gate on fp.certificate.action_issue restricted to
  Manager / QM / Owner (transitive via all_group_ids)
- Email + mail.activity notification to QM authority group
- Migration script backfills mid-flight jobs

Shipping label printing, BoL, carrier dispatch are explicitly
out of scope; awaiting_ship is a parking column with a manual
Mark Shipped button.

Self-review pass found and fixed:
- round-robin field ambiguity (last_activity_at vs login_date)
- unstated behavior for button_mark_done gates (now in step.finish)
- placeholder version inlined (19.0.11.0.0)
- dead reference replaced with inline body

Spec: docs/superpowers/specs/2026-05-25-post-shop-cert-shipping-job-states-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:03:31 -04:00
gsinghpal
80887d6098 changes 2026-05-25 08:17:29 -04:00
gsinghpal
5d5964a327 fix(plant-kanban): full-height bordered columns + viewport-pinned scrollbar
Two layout polish fixes after persona-walk feedback on the new Plant
Kanban surface (`fp_plant_kanban`).

1. Columns now run full board height with visible borders
   Was: `.col` had `background: $plant-bg` (= page bg, invisible) and
   no border, so only the header card (`.o_fp_col_header`) drew any
   outline. Empty columns (BAKING / DE-RACKING / SHIPPING) looked
   unbounded — operators couldn't tell where one column ended and
   another began.

   Now: `.col` is the bordered white card (Trello / Asana style),
   stretches full height via grid + flex. `.o_fp_col_header` drops
   its standalone border / radius / background and is just a bottom-
   divider band inside the column card.

2. Horizontal scrollbar pinned to viewport bottom
   Was: `.o_fp_plant_kanban` was `min-height: 100vh` (block flow) +
   `.board` had `min-height: 520px; overflow-x: auto`. Scrollbar
   showed at the bottom of the .board element (~520px from top of
   board), floating mid-page below the empty columns.

   Now: parent is `height: 100vh; display: flex; flex-direction:
   column`. Header is `flex: 0 0 auto`; `.board` is `flex: 1 1 auto;
   min-height: 0` so it fills all remaining vertical and its
   scrollbar sits at the viewport bottom.

`.col-scroll` switched from `max-height: calc(100vh - 260px)` to
`flex: 1 1 auto; min-height: 0` so it expands inside the now-full-
height column instead of being capped at a magic number.

Version: fusion_plating_shopfloor 19.0.33.1.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:40:41 -04:00
gsinghpal
80f80fb707 fix(tablet): ACL, loading hang, timer offset + FP-tz clock
Four fixes shipped together — all surfaced during tablet UX walkthrough
on entech.

1. sale.order ACL on step completion
   Technicians hit "Access Denied... sale.order" when starting/finishing
   any step. _fp_check_receiving_gate + the serial-promotion helpers +
   _fp_resolve_contract_review_part read step.job_id.sale_order_id (and
   sale_order_line_ids) without sudo. Per Rule 13m, denormalized cross-
   module reads in tablet controllers must sudo() the source recordset.

2. Workspace stuck on "Loading Job Workspace…" after Hand Off + relogin
   Action params aren't URL-encoded, so the workspace remounts with
   jobId=null. refresh() exited early, state.data stayed null, "Loading"
   shown indefinitely. onMounted now redirects to the plant kanban
   when jobId is null or the initial load returns no data.

3. 4-hour timer offset on active steps
   workspace_controller used fp_format() to serialize date_started —
   which converts naive UTC to user tz wall time first. JS then
   appended 'Z' and parsed as UTC, so timer was offset by the user's
   tz (4h on EDT). Switched to fp_isoformat_utc() (proper +00:00 ISO)
   and dropped the Z-append in formatActiveStepElapsed +
   isActiveStepOvertime.

4. Lock-screen clock follows FP regional setting
   tablet_lock.js used d.getHours() / d.toLocaleDateString() — browser
   tz. Now /fp/tablet/tiles returns tz_name (fp_user_tz resolution:
   user.tz → company.x_fc_default_tz → UTC) and the formatters use
   Intl.DateTimeFormat with the explicit timeZone option. plant_overview
   now consumes server_time (already fp_format'd) instead of toLocaleTime
   String. Same chain Odoo backend uses, so PDF / view / tablet all
   agree on what time it is.

Versions: fusion_plating_jobs 19.0.10.30.0,
fusion_plating_shopfloor 19.0.33.1.12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:31:25 -04:00
gsinghpal
bfc138251a feat(fusion_plating): hide back-office menus from Plating Technicians
Per user request: technicians on the tablet should only see Discuss,
To-do, Plating, AI, Maintenance, Time Off. Every other top-level app
menu (Calendar, Contacts, CRM, Sales, Dashboards, RC, Faxes, Field
Service, Fusion Clock, Invoicing, Accounting, Project, Timesheets,
Planning, Shipping, Website, Purchase, Inventory, Sign, HR, Payroll,
Attendances, Recruitment, Expenses, IoT, Link Tracker, Apps) is now
restricted to a new group_fp_office_user.

Architecture:
- New group_fp_office_user (security/fp_menu_visibility.xml) — a
  marker group that controls back-office menu visibility.
- Owner / Manager / Quality Manager / Shop Manager / Sales Rep all
  imply office_user via implied_ids — they see everything they did
  before.
- Pure Technicians do NOT imply office_user — they see only the
  tablet-friendly menus.
- A "!technician" filter would have hit managers too (because Manager
  → ... → Technician via implication), so office_user is the inverse
  pattern that gets the right scoping.

Implementation:
- post_init_hook + migrations/19.0.21.4.0/post-migrate.py both call
  _fp_apply_office_user_menu_visibility(env) which iterates a curated
  list of menu xmlids and sets group_ids = [office_user] on each.
- Uses env.ref(..., raise_if_not_found=False) so menus from
  uninstalled modules silently skip — no hard depends added.
- ir.ui.menu uses `group_ids` in Odoo 19 (was groups_id pre-18 — same
  rename pattern as res.users; CLAUDE.md Rule 13c).
- Settings / Apps / Tests left untouched (already admin-restricted).
- Some menus (Field Service) end up with office_user OR their original
  group — that's correct behavior: Plating Techs have neither so still
  don't see them; explicit Field Technicians keep access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 20:00:15 -04:00
gsinghpal
7dab5fb9c6 feat(record-inputs): tap-to-adjust steppers + inputmode keypad hint
Adds [-] / [+] buttons around every numeric input in the Record Inputs
dialog (single-value, dual-entry, and pass_fail+range branches). Tap
to increment / decrement by the recipe-author-derived step size
(stepFor() already computes this from target_min/target_max precision,
falling back to input-type defaults).

- Decrement clamps at 0 (typical qty/time/temp on a plating floor
  doesn't go negative; if needed, operator can still tap the input
  and type a negative value)
- Increment uses _stepRound() to avoid floating-point fuzz on decimals
- Center-aligned monospace-ish input between the buttons for clarity
- inputmode='decimal' (or 'numeric' for time fields) hint so when the
  operator does tap the input, the iPad shows a number keypad instead
  of the full keyboard

Touches single-value, dual-entry (min/max), and pass_fail+range. Other
multi-field widgets (multi-point thickness, bath chemistry panel) still
use plain inputs — separate request if they need steppers too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:43:00 -04:00
gsinghpal
8d4c85cc52 fix(workspace): drop native confirm() on Close Receiving
Native browser confirm popups look out of place in the tablet UI.
Mark Counted is already a deliberate prior step, so requiring a
second confirmation on Close Receiving was just friction. If a
receiver hits Close prematurely, action_reset_to_counted on
fp.receiving from the back office is the recovery path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:36:48 -04:00
gsinghpal
fc17754996 fix(workspace): required-inputs gate fires + manager bypass dialog
Two bugs:

1. Gate silently passed when step.recipe_node_id was NULL — happened
   to every WO-30057 step after this morning's clone delete (the FK
   ON DELETE SET NULL wiped the link). _fp_missing_required_step_inputs
   returned an empty recordset when node was None, so the gate had
   nothing to fail on and button_finish succeeded with zero audit.
   Fix: _fp_check_step_inputs_complete now treats NULL recipe_node_id
   as an explicit "no recipe link" hard block. Operator can't finish;
   manager bypass posts chatter audit.

2. No tablet UI for the manager bypass. The gate's bypass was a
   Python context flag — invisible from the JS layer, so managers
   were stuck behind the same hard error as operators.
   Fix: new /fp/workspace/finish_step endpoint returns structured
   errors (gate type, missing_prompts list, bypass_available bool).
   Server-side enforces manager group when bypass=True (can't trust
   the client). New FpFinishBlockDialog OWL modal renders:
   - Non-manager: Cancel + Record Inputs
   - Manager:     Cancel + Record Inputs + ⚠ Bypass & Finish (audit)

JobWorkspace.onFinishStep routes plain finishes through the new
endpoint; signature-required steps still go through /fp/workspace/sign_off
(separate gate). Added is_manager to /fp/workspace/load payload so
the JS knows which dialog variant to render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:30:39 -04:00
gsinghpal
0371624afb feat(workspace): live HH:MM:SS timer on active step
Pure client-side tick — 1s setInterval bumps state.tickNow which the
template reads via formatActiveStepElapsed(step). No RPC per tick.
Reads step.date_started_iso (UTC) from the existing payload, parses
to ms, displays elapsed since.

- Green pill (#d1fae5 bg, monospace tabular-nums) on the ACTIVE badge
- Flips red (#fee2e2 + pulse animation) when elapsed > 1.5x
  duration_expected — visual cue for the operator that the step is
  running long against the recipe target

Cleanup interval on onWillUnmount alongside the existing 15s refresh
interval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:18:49 -04:00
gsinghpal
eed1c4619d feat(workspace): pre-recipe receiving card with box count + damage log
Adds the receiver workflow to the Job Workspace tablet view (was the
gap behind WO-30057 sitting in Receiving with no way to advance).
Receivers no longer need to go to the backend form.

Workspace card (renders above the step list when fp.receiving in
state draft/counted on the linked SO):
- Draft state: numeric box-count input + per-line received_qty /
  condition picker (good/damaged/mixed) + Damage Log panel + Mark
  Counted button. Autosaves on input blur.
- Counted state: read-only summary (boxes, parts, who/when) +
  Damage Log still editable + Close Receiving button.
- Closed: card disappears, recipe takes over.

New FpDamageDialog OWL modal:
- Severity pill picker (Cosmetic / Functional / Rejected) with
  color-coded active state
- Required description textarea
- Action Required pill picker (None / Notify / Return / As-Is)
- Photo capture: both "Take Photo" (input capture="environment"
  triggers tablet camera) AND "Upload" (file picker fallback).
  Multi-photo with preview grid + per-photo remove.

5 new endpoints on workspace_controller.py:
- receiving_save_lines (autosave box_count_in + per-line qty/cond)
- receiving_mark_counted (wraps action_mark_counted)
- receiving_close (wraps action_close)
- damage_create (creates fp.receiving.damage + attaches base64 photos)
- damage_delete (removes a damage row)

No model changes — wraps existing fp.receiving actions and damage
CRUD. C3 (outbound shipping carrier/label) is a separate spec.

Spec: in-conversation brainstorm (C1+C2) following the 2026-05-24
workspace step actions spec; no standalone doc since scope is small.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 19:08:30 -04:00
gsinghpal
170398ab6f feat(workspace): per-kind step action buttons in Job Workspace
Fix: in the Job Workspace tablet view, the Start button was buried
inside a parent t-if that required the step to already be in_progress
or blocked. So ready/paused steps showed no buttons at all -
operators couldn't advance the WO from this screen (the reason the
user couldn't complete anything on WO-30057).

Template restructure (job_workspace.xml):
- Always-visible line 1 (icon + step# + name + ACTIVE/PAUSED badge + meta)
- Non-terminal detail panel (chips + instructions + opt-out + GateViz)
  visible on every non-done step so operator reads ahead
- Action row dispatched per-kind via getStepActions() helper

Per-kind action dispatcher (job_workspace.js):
- in_progress -> Record Inputs, Pause, Finish (or Finish & Sign Off)
- paused      -> Resume, Record Inputs, Finish
- contract_review (ready) -> Open QA-005 Form
- gating (ready)          -> Mark Passed (1-click start+finish)
- requires_rack_assignment -> Start (Assign Rack) - opens FpRackPartsDialog
- else (ready)            -> Start

5 new handlers: onPauseStep / onResumeStep / onMarkPassed /
onOpenContractReview / onStartWithRack. Pause and Resume use ORM RPC
(button_pause/button_resume) since no HTTP endpoint exists.

New model method (fp.job.step.action_mark_gating_passed):
- 1-click pass for gating steps - does button_start + button_finish
  in one transaction, posts chatter "Gate X marked passed by Y"
- Raises UserError if called on a non-gating step (defensive)
- Bypasses S21 required-inputs gate (gating steps have no inputs)

Controller: workspace_controller.py adds requires_rack_assignment to
the step payload so the JS dispatcher can route correctly.

Spec: docs/superpowers/specs/2026-05-24-workspace-step-actions-design.md
Sub-B (Record Inputs tablet polish: inputmode/prefill/date pickers/
signature pad/camera) is brainstormed but deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:38:22 -04:00
gsinghpal
d4e95dcd47 docs(plating): spec + plan for recipe cleanup + receiving enforcement
Root causes documented:
1. Recipe 3620 ENP-ALUM-BASIC had duplicate sequences (Contract
   Review + Masking both at seq 10; Incoming Inspection + Racking
   both at seq 20). Clones inherited the ambiguity and resolved by
   id ordering, putting Masking before Incoming Inspection — which
   meant new jobs went straight to Plating column after the
   contract-review auto-complete.
2. 24 per-part clone recipes accumulated, all carrying the broken
   ordering.
3. ~10 kind=other stragglers across the base recipes (Blasting,
   Adhesion Test Coupon, Strip Process, Chemical Conversion etc.)
4. Recipe duplication had no kind safety net.

Implementation shipped in commits referenced from the plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:08:46 -04:00
gsinghpal
e1fedf7231 fix(fusion_plating): wet_process passthrough + per-clone unlink safety
Two follow-up fixes caught during the entech deploy of recipe cleanup:

1. RESOLVER_KIND_TO_ACTIVE_KIND was missing a self-pass entry for
   'wet_process'. The new aliases added in 19.0.21.3.0 (Chemical
   Conversion, Trivalent Chromate Conversion, Strip Process - AL,
   Plug The Threaded Holes via mask) directly return 'wet_process'
   from the resolver — without the passthrough they didn't translate
   to any active kind and stayed as 'other'. Added 'wet_process':
   'wet_process' so the migration's Phase 2 backfill catches them.

2. Migration 19.0.10.26.0 Phase 3 was using batch unlink
   (clone_recipes.unlink()) which tripped a PostgreSQL FK cascade
   ordering bug on entech ("insert or update on parent_id violates
   FK ..." during the CASCADE chain). Rewrote Phase 3 to delete one
   clone at a time with SAVEPOINT per clone — slower but immune to
   the batching bug, and one failed clone doesn't roll back the
   whole transaction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:08:35 -04:00
gsinghpal
9a2975b154 feat(jobs+shopfloor): recipe cleanup migration + no_parts column fix
Migration 19.0.10.26.0/post-migrate.py runs in 5 phases:
1. Resequence recipe 3620 ENP-ALUM-BASIC ops to fix the duplicate-
   sequence bug (Contract Review=10, Incoming Inspection=20,
   Masking=30, Racking=40, then the rest). Also delete the empty
   duplicate ENP-Alum Line sub_process (id 4056).
2. Backfill kind on all kind=other nodes via the extended resolver
   from fusion_plating 19.0.21.3.0
3. Delete all per-part clone recipes (name contains em-dash)
4. Recompute fp.job.step.area_kind on all steps
5. Recompute fp.job.active_step_id + card_state on in-flight jobs

Plant kanban: no_parts cards now always land in the Receiving column
regardless of active_step area_kind. The receiver works Receiving;
that's where the card belongs when parts haven't arrived.

Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:59:33 -04:00
gsinghpal
271a995455 feat(fusion_plating): extend resolver + auto-classify hook on process node
Resolver (fp_resolve_step_kind) extensions:
- New aliases: blasting/bead blast/media blast variants, adhesion
  testing, corrosion testing, lab testing, strip process, chemical
  conversion, trivalent chromate, plug the threaded holes, air dry,
  desmut, soak clean, cleaner, nickel strike/strip
- Parenthetical suffix stripping - "Masking (If Required)" resolves
  through "masking", "Incoming Inspection (Standard)" through
  "incoming inspection", "Trivalent Chromate Conversion (A-14 / A)"
  through "trivalent chromate conversion"
- New RESOLVER_KIND_TO_ACTIVE_KIND map translates the resolver's
  vocabulary (cleaning/electroclean/etch/rinse/strike/dry/wbf_test
  -> wet_process) so the resolver output lands on active kinds only

Auto-classify hook on fusion.plating.process.node:
- _fp_autoclassify_kind() upgrades kind_id when current is 'other'
  AND name resolves via the resolver. Idempotent - never overrides
  a non-'other' kind. Skip via context flag fp_skip_kind_autoclassify
- Wired into create() and write() (only fires when name or kind_id
  changed on write)
- Side-effects: recipe duplication via copy() auto-corrects newly
  copied nodes; Simple/Tree editor authoring auto-classifies as soon
  as the name is saved

Spec: docs/superpowers/specs/2026-05-24-recipe-cleanup-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:57:35 -04:00
gsinghpal
056178b433 fix(jobs): store=True on fp.job.active_step_id
Required because fp.job.card_state (stored) has @api.depends including
active_step_id.area_kind. When step.area_kind changes, Odoo's trigger
chain searches fp.job by active_step_id — non-stored fields can't be
queried in WHERE clauses, raising ValueError("Cannot convert ... to
SQL because it is not stored").

Caught during entech deploy of 19.0.10.25.0/post-migrate.py Phase 3
(steps._compute_area_kind() failed on first run). store=True makes
the column searchable and the trigger chain works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:14:19 -04:00
gsinghpal
2285c9def1 docs(plating): spec + plan for Shop Floor live-step + library cleanup
Spec documents the 4 code defects + structural vocabulary mismatch
between fp.step.kind taxonomy and the legacy _STEP_KIND_TO_AREA dict,
plus the 30 library templates missing metadata. Plan breaks the work
into 15 bite-sized tasks across 2 phases.

Implementation shipped in:
- c75d2bde (Odoo 19 session.authenticate signature fix — separate)
- 7b90f210 (Phase 1: fusion_plating)
- b06d28e7 (Phase 2: jobs + shopfloor)
- 6afc9e3c (follow-up tracking + pattern anchor)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:13:58 -04:00
gsinghpal
6afc9e3c0d fix(fusion_plating): tracking warning + De-Masking pattern anchor
- fp.step.kind.area_kind: drop tracking=True (model doesn't inherit
  mail.thread; tracking was a no-op emitting a startup warning).
- Migration 19.0.10.25.0: anchor the De-Masking ILIKE so it doesn't
  wildcard-match "Ready For De-Masking" (which the earlier "Ready %"
  rule already routes to gating). Also drop the cur_code='mask' filter
  so the 4 De-Masking nodes still classified as 'other' get picked up
  on fresh re-runs too.

Direct SQL applied the 4-row fix on entech (post-migrate doesn't
re-run for already-applied versions); this commit keeps fresh
installs and any future re-runs consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:13:42 -04:00
gsinghpal
b06d28e7f6 feat(jobs+shopfloor): live-step priority chain + done-job filter
Fix the Shop Floor plant kanban so cards land in the right column:
- fp.job._compute_active_step_id walks priority chain
  (in_progress > paused > ready > pending), not just in_progress
- fp.job._compute_card_state edge case respects job.state='done'
  (no more bogus 'contract_review' label on done jobs)
- fp.job.step._compute_area_kind reads kind.area_kind directly;
  legacy _STEP_KIND_TO_AREA dict removed (50+ lines deleted)
- /fp/landing/plant_kanban filters out done/cancelled jobs from
  the live board

Migration 19.0.10.25.0 backfills template metadata (codes,
descriptions, icons, kind_id) on 30 unfinished library templates
and repoints recipe nodes for 6 unambiguous name patterns
(Blasting -> blast, Ready For X -> gating, De-Masking -> demask,
Scheduling -> gating, Nickel Strip -> wet_process,
Pre-Meas/Check Sulfamate -> inspect).

Battle test bt_s24_between_steps.py covers between-step routing,
paused step lifecycle, and done-job board filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:06:53 -04:00
gsinghpal
7b90f210b9 feat(fusion_plating): kind.area_kind drives Shop Floor column routing
Add required area_kind Selection to fp.step.kind so each kind
self-declares which plant-view column its steps belong in. Replaces
the hardcoded _STEP_KIND_TO_AREA dict (removed in fp_job_step.py
in the follow-up commit).

- New `blast` kind for the Blasting column (sequence=35)
- 26 existing kind records seeded with area_kind in XML
- Pre-migrate 19.0.21.2.0 seeds existing rows BEFORE NOT NULL hits
  the schema; also activates derack/demask/gating that were
  deactivated in 19.0.20.6.0 but are needed for the full taxonomy
- Step Kind form + list views surface area_kind (badge + chip)
- Step Kind search adds Group By Shop Floor Column
- Simple Editor kind picker shows "Masking — Masking column"
  suffix so authors see the routing at pick time
- Add Hot Water Porosity Test (A-15) + Final Inspection / Packaging
  templates (used by 7+3 recipe nodes that previously had no
  library entry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:02:02 -04:00
gsinghpal
c75d2bde5a fix(shopfloor): session.authenticate signature update for Odoo 19
Odoo 19's Session.authenticate(env, credential) takes an Environment as
the first arg, not a db-name string. Passing request.db triggered
TypeError: 'str' object is not callable on the internal
env(user=None, su=False) reset.

Fixes the "Odoo Server Error" dialog operators saw when trying to PIN
unlock from the tablet. Same fix applies to lock_session (which was
silently masked by its broad except Exception).

Bumps fusion_plating_shopfloor to 19.0.33.1.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:56:05 -04:00
gsinghpal
9e6b88f60e chore(shopfloor): bump to 19.0.33.1.1 for lock_session kiosk-xmlid fix
Pure code change (no DB schema), but bumping the patch version
keeps repo manifest aligned with the deployed state so the next
-u doesn't no-op due to version match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:27:30 -04:00
gsinghpal
dc6afdd021 fix(shopfloor): lock_session resolves kiosk login via xmlid
The kiosk_login in /fp/tablet/lock_session was hardcoded to the
data XML's original value ('fp_tablet_kiosk@enplating.local'). The
data record is noupdate='1', so admins can (and on entech, did)
rename the kiosk user on the form for memorability — the rename
persists through -u, but the hardcoded string in the controller
silently breaks the re-auth-as-kiosk path.

Fix: resolve the kiosk login dynamically via env.ref of the xmlid
'fusion_plating_shopfloor.user_fp_tablet_kiosk'. Robust against any
future rename. CLAUDE.md updated to make 'identify by xmlid, never
by login string' an explicit convention for the tablet flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:26:46 -04:00
gsinghpal
978cd5953e docs(plating): update Shop-floor attribution section for Phase G
Tablet PIN session redesign Phase G removed all tablet_tech_id
plumbing. CLAUDE.md still documented the old session-pool + kwarg
flow which would mislead future-Claude. Updated to describe the
new per-tech-session attribution + kiosk re-auth flow, plus the
gotcha about keeping ir.config_parameter['fp.tablet.kiosk_password']
in sync with the actual user-record password.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:00:37 -04:00
gsinghpal
b869c31ba3 chore(shopfloor): bump to 19.0.33.1.0 after Phase G cleanup
Records the legacy-tablet-flow-removed state. Triggers -u so the
module's installed version reflects the post-cleanup code (the
ir_module_module row shows 19.0.33.1.0 after deploy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:38:03 -04:00
gsinghpal
67fc22237b cleanup(shopfloor): session_swap is the only tablet flow
Frontend cleanup completing Phase G of the tablet PIN session
redesign:

- tablet_lock.js: removed sessionMode branching (no legacy path).
  unlock() always calls /fp/tablet/unlock_session + reloads.
  handOff() always calls tabletSessionManager.lockBack('manual').
  isLocked uses currentUid vs kioskUid exclusively. _checkIdle
  still drives the warning UI via activity_tracker; the actual
  lock RPC is owned by tablet_session_manager.

- fp_rpc.js: simplified to a thin async pass-through around @web/core
  network rpc. tech_store-based tablet_tech_id injection is gone
  (the session uid IS the tech).

- tech_store.js: DELETED (replaced by per-session backend attribution
  + tablet_session_manager for lock state). Removed from manifest.

- Wrapper components (shopfloor_landing, job_workspace,
  manager_dashboard, plant_kanban): swapped useService('fp_shopfloor_tech_store')
  for useService('fp_tablet_session_manager'); techStore.lock() ->
  tabletSessionManager.lockBack('manual'). plant_kanban's defensive
  try/catch on the tech_store lookup is no longer needed.

- tablet_lock.xml: Hand-Off button no longer gated on sessionMode;
  always rendered.

- Tests: removed legacy TestTabletUnlock class from test_tablet_pin.py
  (covered the deleted /fp/tablet/unlock route). Dropped session_mode
  assertion from test_tiles_bootstrap_fields.py (the return key is
  gone post-Phase-G). kiosk_uid + current_uid assertions retained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:36:12 -04:00
gsinghpal
d9f2983ea7 cleanup(shopfloor): remove legacy /fp/tablet/unlock + _tablet_audit helper
Session-swap is now the only flow. Legacy /fp/tablet/unlock endpoint
deleted. _tablet_audit.py (env_for_tablet_tech helper) deleted with
its last caller gone. /fp/tablet/ping no longer takes current_tech_id
(session uid IS the tech). /fp/tablet/tiles drops tablet_session_mode
return key (kiosk_uid + current_uid retained for OWL isLocked logic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:31:21 -04:00
gsinghpal
3120612e35 cleanup(shopfloor): strip tablet_tech_id from 17 endpoints
Session swap makes attribution automatic via request.env.user — the
tablet_tech_id plumbing is dead code after the kiosk + per-tech-session
architecture lands. Removed kwarg from 3 endpoints in
manager_controller, 11 in shopfloor_controller, 3 in
workspace_controller. _tablet_audit.env_for_tablet_tech import gone
from all 3 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 14:29:51 -04:00
gsinghpal
2a93ece4ba feat(shopfloor): per-user 7-day tablet event smart button
Owner-only smart button on res.users form. Click opens the audit log
filtered to that user (both user_id and attempted_user_id, so
failed unlock attempts against a tile show up too).

Compute is non-stored: search_count on the audit model per user on
demand. Sudo'd because the audit model has Owner-only ACL — the
compute fires for the form-viewing user (Owner) who would see the
results anyway via the menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:34:45 -04:00
gsinghpal
b26fa13044 feat(shopfloor): audit log list+form views, Owner-only menu
Plating > Configuration > Tablet Audit Log. Read-only list with
decoration (green=unlock, red=failed, warning=ceiling/force,
muted=manual/idle). Form shows full forensic detail incl. ip/ua.
Owner-only via groups=fusion_plating.group_fp_owner on the menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:34:00 -04:00
gsinghpal
7ff46af192 fix(shopfloor): Phase D review findings — defensive cleanups + bootstrap test
Important I1: tablet_session_manager.beginSession() now calls
_removeListeners() (and clears any pending _tickHandle) defensively
at start. Prevents DOM listener leak on dev hot-reload or any path
that re-bootstraps without a clean endSession() first.

Important I2: tablet_lock._checkIdle() early-returns in session_swap
mode. The tablet_session_manager owns idle tracking there (5s poll,
calls /fp/tablet/lock_session directly). Was previously dormant by
accident because session_swap never populates the legacy techStore;
explicit guard makes the decoupling intentional.

Minor M5: session_swap unlock success now resets selectedTileUserId
before window.location.reload(), matching the legacy path''s
cleanup pattern. Cosmetic before reload kicks in.

Minor M9: New test_tiles_bootstrap_fields with 3 HttpCase tests
asserting /fp/tablet/tiles returns tablet_session_mode, kiosk_uid,
and current_uid. The OWL lock screen branches on all three — a
contract regression would silently break session_swap.

Minor M10: Added inline comment near _sessionModeCache declaration
in fp_rpc.js explaining the page-reload-invalidates-cache lifecycle.

Deferred (for future polish, NOT in this commit):
- I3 (_getSessionMode ACL gap for tech users — functionally correct,
  just suboptimal; cache fallback to ''legacy'' kicks in)
- M6 (wrapper component Hand-Off buttons no-op in session_swap)
- M7 (hardcoded idle/ceiling thresholds — server-configurable later)
- M8 (timer divergence vs activity_tracker — unify later)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:30:29 -04:00
gsinghpal
6d4b6284ad feat(shopfloor): fpRpc skips tablet_tech_id injection in session_swap mode
When tablet_session_mode='session_swap', the server attributes every
write via request.env.user — there's no need to pass tablet_tech_id
in the RPC params. Caches the mode lookup at module level so we don't
round-trip on every RPC.

Legacy mode unchanged — fpRpc still injects tablet_tech_id from
techStore.currentTechId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:18:33 -04:00
gsinghpal
d8456fb9a3 feat(shopfloor): tablet_lock branches on tablet_session_mode
When ir.config_parameter[fp.shopfloor.tablet_session_mode]='session_swap',
PIN submit calls /fp/tablet/unlock_session and reloads the page; the
new session manager service kicks in on next mount. handOff() calls
lockBack('manual') which destroys the tech session server-side and
re-auths as kiosk.

Legacy mode unchanged — same /fp/tablet/unlock + techStore flow.

The feature flag, kiosk_uid, and current_uid arrive via the existing
/fp/tablet/tiles bootstrap response (Task D0).

Adds a tablet_lock-owned Hand-Off button visible only in session_swap
mode (in legacy mode wrapper components own their own buttons that hit
techStore.lock(); session_swap renders our own button so the manual
hand-off goes through lockBack() + page reload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:17:53 -04:00
gsinghpal
b41d9629e1 feat(shopfloor): tablet_session_manager OWL service
Tracks idle + ceiling timers for an unlocked tech session. Fires
/fp/tablet/lock_session when either trips, then reloads the page so
the browser re-bootstraps under the fresh kiosk session.

Defaults: 10min idle, 8hr ceiling, 5s tick interval. Listens for
click/touchstart/keydown/mousemove as activity signals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:15:28 -04:00
gsinghpal
10477a7c8f feat(shopfloor): /fp/tablet/tiles returns session_mode + kiosk_uid
OWL lock screen needs to know (a) the active session mode (legacy or
session_swap) to branch between endpoints, and (b) the kiosk uid to
determine 'is the current browser session the kiosk?' Both come from
the bootstrap response so no extra round-trips on every render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:14:41 -04:00
gsinghpal
8f6302b446 fix(shopfloor): Phase C review findings — lock_session closes unlock event + cron test
Important 1: lock_session now closes the original unlock event's
session_ended_at via the same parameterized-SQL bypass pattern used
by the force-lock cron. Without this, every Hand-Off click became
a duplicate force_lock event 8 hours later (cron saw the unlock still
open and re-processed).

Important 2: test_unlock_lock_session_endpoints setUp now
unconditionally overrides the kiosk password (was gated on
'if not get_param(...)' which broke on entech where the post-migrate
hook already generated a random password — tests failed against the
real value). HttpCase rolls back per test so no persistence.

Minor 4: _cron_force_lock_stale_sessions now routes the force_lock
create through write_event helper for consistency (single audit-write
path; helper captures acting_uid/ip/ua uniformly).

Minor 5: Hoisted local imports inside method bodies to top-of-file
in tablet_controller.py (AccessDenied, _tablet_session_audit) and
fp_tablet_session_event.py (timedelta, write_event).

Minor 6: New test_force_lock_cron.py with 3 tests: stale session
emits force_lock + closes original; recent session unaffected;
already-closed session not re-processed. Would have caught
Important 1 if it had existed during Phase C review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 13:08:30 -04:00
gsinghpal
87e924d1d8 test(shopfloor): HTTP tests for unlock_session + lock_session
5 tests covering correct/wrong PIN, audit event writes, manual/idle
lock reasons. Uses HttpCase to actually drive the JSONRPC endpoint
end-to-end with session cookie handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:55:19 -04:00
gsinghpal
7fab01e5cb feat(shopfloor): force-lock cron for stale tablet sessions
Every 5 minutes, find active unlock events past 8-hour ceiling and
mark them force-locked. SQL bypass of the model's read-only ACL is
the only path that can update existing rows (no Python write() works
because the model override blocks even sudo writes without the
explicit fp_tablet_audit_admin_write context flag).

Ceiling configurable via ir.config_parameter[fp.tablet.session_ceiling_hours].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:54:44 -04:00
gsinghpal
4911088dc1 feat(shopfloor): /fp/tablet/lock_session destroys tech session
Writes lock event (manual/idle/ceiling) with duration computed from
the open unlock event. Then logout + re-authenticate as kiosk via
the password stored in ir.config_parameter['fp.tablet.kiosk_password'].

Falls back to 'needs_kiosk_relogin' if the kiosk password is missing
(sysadmin must log in manually). Logs every event for forensic
review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:54:08 -04:00
gsinghpal
086ff512b6 feat(shopfloor): /fp/tablet/unlock_session mints real Odoo session
PIN verify -> request.session.authenticate(type=fp_tablet_pin) -> new
session sid, cookie swap, audit event written. Failed attempts also
written to audit log (failed_unlock, failure_reason=wrong_pin or
locked_out or no_pin_set or user_inactive).

OLD /fp/tablet/unlock stays alive during the 1-week overlap window
per spec Section 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:53:36 -04:00
gsinghpal
96e33834bd feat(shopfloor): _tablet_session_audit helper for audit-log writes
Single source for sha256(session sid), ua trim, ip/acting_uid capture
from request. Used by unlock_session, lock_session, and force-lock cron.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:53:03 -04:00
gsinghpal
765b095035 fix(shopfloor): Phase B review findings — C1/I1/I2/I3/M1
C1: Add placeholder fp_tablet_cron.xml + fp_tablet_session_event_views.xml
so the module is installable now (real content lands in Phase C task C4
and Phase E task E1 respectively).

I1: test_tablet_pin_auth_manager now passes {} (not self.env) as the
env arg to _check_credentials — matches what request.session.authenticate
provides and what the base implementation expects.

I2: Auth manager role check now uses user_sudo.all_group_ids (transitive)
instead of group_ids (direct) per CLAUDE.md rules 13l + 23. Owner users
who hold Owner directly still match all 5 shop-branch xmlids via the
implication chain.

I3: fp.tablet.session.event gains Python-layer write() + unlink()
overrides that always raise AccessError unless the explicit
fp_tablet_audit_admin_write / fp_tablet_audit_admin_purge context flag
is set. Closes the gap between the model's append-only docstring and
its actual enforcement (ACL-only previously).

M1: Hoisted 'from odoo.exceptions import AccessDenied' to top-of-file
imports next to existing UserError import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:47:26 -04:00
gsinghpal
358b90516b test(shopfloor): fp_tablet_pin auth manager handles all cases
8 tests: correct/wrong/missing PIN, missing/unknown login, inactive
user, no shop-branch role, and pass-through of other credential types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:30:56 -04:00
gsinghpal
dd0dc26232 feat(shopfloor): fp_tablet_pin custom auth manager
Validates PIN hash + shop-branch role membership when the credential
type is fp_tablet_pin. Goes through Odoo's standard _check_credentials
chain so future 2FA / IP-gate modules layer cleanly on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:30:24 -04:00
gsinghpal
1dea752a29 test(shopfloor): fp.tablet.session.event is append-only
Owner reads. Technician cannot read. Owner cannot write or unlink.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:29:52 -04:00
gsinghpal
9f3edd60ae feat(shopfloor): fp.tablet.session.event append-only audit log
Captures unlock / failed_unlock / manual_lock / idle_lock /
ceiling_lock / force_lock / admin_reset events with session hash,
ip, user-agent, duration, failure reason, acting uid.

Read-only ACL granted to Owner in Phase A; no write/unlink anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:29:22 -04:00
gsinghpal
0b92294586 fix(shopfloor-sec): narrow kiosk ir.config_parameter scope + doc accuracy
Code-review findings on Phase A (Tablet PIN Session Redesign):

I1: Security XML comment now honestly describes the kiosk as Internal
User + explicit reads, not 'near-zero ACL'. base.group_user is kept
(required for auth='user' HTTP routes to function) but the comment
no longer overstates how locked-down the kiosk is.

I2: New ir.rule scopes the kiosk's ir.config_parameter read to keys
matching 'fp.tablet.%' or 'fp.shopfloor.%'. Combined with the
existing model-level read ACL, kiosk can no longer enumerate
third-party secrets (e.g. fusion_tasks.vapid_private_key) or
arbitrary API keys stored in ICP.

I3: post-migrate docstring now advises sysadmins to unlink the
plaintext ICP password row after kiosk tablets are paired, to
minimise plaintext-in-backups risk. Rotation procedure documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:22:40 -04:00
gsinghpal
a52ef29a84 test(shopfloor): kiosk user ACL has near-zero access
7 tests covering allowed reads (res.users, ir.config_parameter)
and forbidden everything else (fp.job, sale.order, fp.certificate,
fp.part.catalog, res.users write).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:06:52 -04:00
gsinghpal
97deb93ee7 feat(shopfloor): post-migrate hook for kiosk password init
Generates a random kiosk password on first deploy, stores in
ir.config_parameter for sysadmin retrieval. Idempotent — re-runs
on subsequent -u leave the password alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:06:22 -04:00
gsinghpal
b67186a25b feat(shopfloor): create fp_tablet_kiosk user
Kiosk holds the tablet session when no tech is PIN-unlocked.
Password is auto-generated by the post-migrate hook (Task A5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:06:00 -04:00
gsinghpal
258782e3c3 feat(shopfloor-sec): kiosk ACL — read res.users + ir.config_parameter
Owner gets read on fp.tablet.session.event (audit log).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:05:42 -04:00
gsinghpal
acc95d8ee0 feat(shopfloor-sec): group_fp_tablet_kiosk for tablet kiosk session
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:05:14 -04:00
gsinghpal
e9b82fbe9d chore(shopfloor): bump to 19.0.33.0.0 for tablet PIN session redesign
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:04:55 -04:00
gsinghpal
c3bcb4b99d docs(plating): tablet PIN session redesign implementation plan
7 phases (A-G), ~25 tasks. Phase A-E build the new auth flow,
audit model, endpoints, OWL service, and audit UI. Phase F is the
entech rollout (manual, inline by main session per hybrid pattern).
Phase G is the post-overlap cleanup (rip out tablet_tech_id,
delete legacy endpoint, archive shopfloor service user).

Bakes in 7 known gotchas from the permissions overhaul (rules
13c, 13i, 13k, 13m, 13d, AUDIT-1, always-push-to-main) so the
implementer doesn't repeat them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:53:23 -04:00
gsinghpal
cfaf4657ce docs(plating): tablet PIN session redesign spec
Real per-tech Odoo sessions on PIN unlock (not just attribution).
Closes the audit-trail gap from Phase 1 permissions overhaul: today
the tablet runs as a persistent 'shopfloor service' user and the PIN
is just an OWL overlay — every action is attributed to whoever the
session user is, not the tech who tapped their tile.

Locked decisions:
1. Real per-tech sessions (impersonation, cookie swap)
2. Idle timeout 10min + manual lock + 8hr hard ceiling
3. Dedicated kiosk user (fp_tablet_kiosk, near-zero ACL)
4. No manager override — Mgr/Owner PIN in as themselves
5. Two-step deploy with 1-week overlap; OLD endpoint removed after
   successful rollout

Audit: fp.tablet.session.event append-only log captures unlock /
manual_lock / idle_lock / ceiling_lock / force_lock / failed_unlock
/ admin_reset events with ip, ua, session hash, duration.

Effort: ~4 dev days + 1 week observation. Plan via writing-plans
skill next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:42:00 -04:00
gsinghpal
7966f8d505 fix(shopfloor): tablet tiles domain uses group_ids (Odoo 19 rename)
Same mistake as the original implementer wave — used the deprecated
groups_id field name on res.users in the search domain. Odoo 19 raises
ValueError: Invalid field res.users.groups_id. Should be group_ids.

CLAUDE.md rule 13l example also fixed so future-Claude doesn't copy
the bug from the documentation.

Module version: 19.0.32.0.12 -> 19.0.32.0.13

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:48:29 -04:00
gsinghpal
839a7f0abc fix(shopfloor): tablet tile grid includes shop-branch role holders
Previously only direct Technicians appeared on the lock-screen tile
grid because env.ref('group_fp_technician').user_ids returns DIRECT
members only — Odoo's implication chain (Owner -> ... -> Technician)
is read-time only, not stored in res_groups_users_rel.

Search res.users with ('groups_id', 'in', shop_branch_ids) where
shop_branch_ids covers all 5 shop-branch role groups (Technician,
Shop Manager v2, Manager, Quality Manager, Owner). Sales branch
intentionally excluded — they don't operate the tablet.

Verified on entech: 18 technicians + 1 shop_manager + 2 managers
+ 1 quality_manager + 2 owners = 24 tiles (was 18).

CLAUDE.md rule 13l corrected — previous version wrongly claimed
res.groups.user_ids surfaced implied members. Now documents the
search-based query as the canonical 'enumerate role X or higher'
pattern.

Module version: 19.0.32.0.11 -> 19.0.32.0.12

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:47:01 -04:00
gsinghpal
0f751d82cc feat(shopfloor): add Record Inputs button to Job Workspace step row
Operators trying to Finish a step with required step_input prompts
got the S21 gate error telling them to 'Click Record Inputs on the
step row' — but the workspace UI never exposed that button. Only the
job-form view had it.

Adds a 'Record Inputs' secondary button next to Finish/Finish & Sign
Off when the step is active. Click opens the fp_record_inputs_dialog
(via action_open_input_wizard on fp.job.step). On dialog close the
workspace refreshes so the step's progress chip updates.

Module version: 19.0.32.0.10 -> 19.0.32.0.11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:34:09 -04:00
gsinghpal
aa8161f764 fix(shopfloor): sudo job recordset in /fp/workspace/load (rule 13m)
Same pattern as plant_kanban — workspace payload denormalizes
cross-module fields Technician can't read directly (sale.order,
fp.part.catalog, customer_spec, etc.). job.sudo() at the top so
the whole render path is sudo'd.

Job Workspace was stuck on 'Loading...' with a server-error toast
because the route returned {ok:false, error:'...'} (27-byte response)
when the first cross-module field access AccessError'd.

Module version: 19.0.32.0.9 -> 19.0.32.0.10

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:28:58 -04:00
gsinghpal
31740b3949 fix(shopfloor): sudo cross-module reads in Plant Kanban _render_card
Post-migration, Technicians (now group_fp_technician) have read on
fp.job but NOT on sale.order / fp.part.catalog / fusion.plating.customer.spec.
The kanban render path tries to access job.sale_order_id.x_fc_po_number
and AccessErrors silently — kanban returns empty, user sees blank
'Shop Floor' page.

Fix: `job = job.sudo()` at the top of _render_card. The output is
denormalized display data, no security concerns; ACL gating is still
enforced by the caller's access to fp.job (which Technician does have).

CLAUDE.md rule 13m documents the broader pattern: any dashboard /
tablet / kanban controller surfacing cross-module data to low-priv
roles needs this sudo at the helper top.

Module version: 19.0.32.0.8 -> 19.0.32.0.9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:19:39 -04:00
gsinghpal
e99cf20887 style(shopfloor): tablet lock clock 24h -> 12h with AM/PM
Operators read phone-style clocks; 24-hour was off-norm for North
American shop. Hour no longer zero-padded (1:05 PM, not 01:05 PM)
to match the iPhone/Android idiom.

Module version: 19.0.32.0.7 -> 19.0.32.0.8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:16:17 -04:00
gsinghpal
cc5542833f style(shopfloor): tablet lock screen tile grid 3 -> 5 columns
Wider tablets fit 5 tiles per row comfortably; 3 was too sparse with
a 20-person operator roster (forced a long vertical scroll). Bumped
.o_fp_lock_tiles max-width from 480px to 800px so the tiles don't
stretch wide at 5 columns.

Module version: 19.0.32.0.6 -> 19.0.32.0.7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:13:52 -04:00
gsinghpal
0568d8ae87 fix(plating-perms): widen settings landing field to ir.actions.actions
res.config.settings.x_fc_default_landing_action_id is related= to
res.company.x_fc_default_landing_action_id, which was widened from
ir.actions.act_window to ir.actions.actions in the Phase I post-deploy
fixes (so the picker accepts both window AND client actions). The
settings field's comodel was left at the old type and tripped on
opening Settings: 'Wrong value for ...: ir.actions.actions()' when
the related compute tried to write the client-action value into the
narrower settings field.

Module version: 19.0.21.1.2 -> 19.0.21.1.3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:11:40 -04:00
gsinghpal
c2180d3691 Merge: Fusion Plating Permissions Overhaul Phase 1
Consolidates 12 res.groups into 8 clean roles:
  Owner -> Quality Manager -> Manager -> [Shop Manager, Sales Manager]
       -> [Technician, Sales Rep], plus implicit 'No' (no plating group).

Phase A — 7 new res.groups with implied_ids chains + backward-compat;
old groups marked [DEPRECATED] and queued for 30-day cron purge.
Phase B — mechanical ACL sweep across 24 ir.model.access.csv files.
Phase C — Manager/QM quality permission split + FAIR/Nadcap ir.rule.
Phase D — 3-layer menu/submenu/field/button visibility hardening.
Phase E — role-based landing-page dispatch (Owner -> Manager Desk,
QM -> Quality Dashboard, Sales Rep -> Quotations, Tech -> Plant
Kanban, etc.) + picker domain over ir.actions.actions so window AND
client actions are both pickable.
Phase F — Owner-only Plating > Configuration > Team kanban for
drag-and-drop role assignment, plus Designated Officials (CGP DO +
Nadcap Authority) fields on res.company.
Phase G — Sales Manager + required to confirm SO; fixed the
audit-finding-11 _administrator typo that had made the account-hold
bypass dead code; swept all Python has_group() refs to new xmlids.
Phase H — dry-run + Owner-approval migration workflow with
fp.migration.preview model, mail.activity notification, 30-day
rollback window, daily purge cron.
Phase 9 — final-reviewer fixes (groups_id->group_ids, server-action
wiring, migrations/19.0.21.1.0/post-migrate.py for -u dispatch,
Odoo 19 kanban card template, FAIR/Nadcap cert_type field name,
user_has_groups removed from invisible attrs).
Phase I — pre-deploy backup, entech deploy (5 cascade fixes
discovered live), Owner approval of migration #1 (25 users
migrated cleanly), post-approval SQL verification, sample login
tests, deprecated-group picker cleanup (Option A SQL UPDATE),
and 11 post-deploy bug fixes (picker model swap to ir.actions.actions,
ACL grant for ir.actions.actions read to plating users, SELF_WRITEABLE_FIELDS
extension for non-admin Preferences save, res.users.message_post ->
partner_id.message_post, tablet lock screen group ref swap,
PIN-pad dark-mode dot contrast, lock-screen logo plate dark mode).

Spec: docs/superpowers/specs/2026-05-23-permissions-overhaul-design.md
Plan: docs/superpowers/plans/2026-05-24-permissions-overhaul-phase1-plan.md
CLAUDE.md rules added: 13b-13l (Odoo 19 gotchas surfaced during build/deploy)

Live state on entech: 25 users migrated, 30-day rollback open
until 2026-06-23, deprecated groups hidden from picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:05:25 -04:00
gsinghpal
42036c23ab fix(plating-perms): Phase I post-deploy fixes (live entech test catches)
8 distinct bugs caught + fixed while testing the live admin DB on entech
after the migration was approved. Each surfaced a real Odoo 19 gotcha
now codified in CLAUDE.md (rules 13b-13l).

Picker architecture:
- res.users.x_fc_plating_landing_action_id and res.company.x_fc_default_landing_action_id
  now Many2one('ir.actions.actions') instead of ('ir.actions.act_window'),
  so the picker accepts BOTH window actions (Sale Orders / Quotations /
  Process Recipes) AND client actions (Manager Desk / Plant Kanban /
  Quality Dashboard). Picker went from 3 entries to 6.
- x_fc_pickable_landing field moved from the two subclasses to the
  ir.actions.actions base. Single source of truth.
- _render_resolved on the base dispatches to the correct subclass by
  action type.

Non-admin Preferences access:
- Added ACL grant: group_fp_technician (and all higher roles via
  implication) get read on ir.actions.actions. Without this, opening
  Preferences raised AccessError on the picker domain evaluation.
- Removed the accessible_landing_action_ids Many2many compute (failed
  for non-admins because field assignment requires write access on
  the comodel relation, even with sudo'd search). Picker now shows all
  6 entries to all users; resolver falls through gracefully if the
  user picks an action they can't reach.
- res.users SELF_WRITEABLE_FIELDS / SELF_READABLE_FIELDS extended via
  @property + super() (NOT class attribute — Odoo 19 changed the
  pattern). Non-admin users can now save the Preferences dialog with
  plating fields without hitting the standard write ACL.

Migration workflow:
- res.groups.users -> .user_ids (Odoo 19 rename; deprecated alias
  removed). Was crashing _fp_notify_owners and _cron_purge_expired.
- user.message_post -> user.partner_id.message_post (res.users uses
  _inherits delegation which doesn't expose mail.thread methods).
  Was crashing the Owner approval click.

Tablet lock screen:
- /fp/tablet/tiles points at group_fp_technician instead of the old
  group_fusion_plating_operator. Post-migration nobody holds the old
  group directly (only via implication), so res.groups.user_ids on
  the old xmlid returned empty — 'No operators configured' shown
  even with PIN set.
- PIN pad dots dark mode: empty dot now dark gray (#424245), filled
  dot now pure white. Previous version had both at light shades so
  user couldn't see PIN entry progress.
- Lock-screen logo frame dark mode: near-opaque white plate
  (rgba 0.95) so company logos designed for light backgrounds
  render correctly. Previous 0.08 alpha let the dark page bleed
  through.

Pre-deploy collision fix (already committed before deploy but
documented here for completeness):
- pre-migrate.py to rename old configurator's 'Shop Manager' group
  display name before new fp_security_v2.xml loads the new
  group_fp_shop_manager_v2 with the same display name (avoids
  res_groups_name_uniq violation).

Module versions bumped:
  fusion_plating: 19.0.21.1.0 -> 19.0.21.1.2
  fusion_plating_shopfloor: 19.0.32.0.4 -> 19.0.32.0.6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 10:02:32 -04:00
gsinghpal
7bcbcb4008 fix(plating-perms): deploy-time cascade fixes from entech I3
5 fixes discovered during the live deploy to entech LXC 111:

1. pre-migrate.py to rename old configurator's 'Shop Manager' group BEFORE
   new core 'Shop Manager v2' XML loads (cross-module name collision on
   res_groups_name_uniq).

2. res_company_views.xml: dropped ref() inside <field domain=> attribute
   (Odoo 19 view validator interprets it as a field name).

3. sale_order_views.xml: replaced 3 separate xpaths for amount_total /
   amount_untaxed / amount_tax with a single xpath on tax_totals widget
   (Odoo 19 sale.view_order_form uses one widget instead of separate fields).

4. fp_cert_security.xml: certificate_type field, not cert_type. FAIR is a
   separate model so the rule only restricts cert_type='nadcap_cert' now.

5. fp_certificate_views.xml + fp_capa_views.xml + fp_customer_spec_views.xml:
   stripped user_has_groups() from invisible= / readonly= attrs (Odoo 19
   view validator interprets as field name). Model-layer ACLs and ir.rules
   already enforce the same restrictions.

Also fixed res.groups.users -> user_ids in fp_migration.py (Odoo 19 rename,
caught when manually invoking _fp_notify_owners post-deploy).

CLAUDE.md updated with 4 new rules (13e cross-module name collisions,
13f ref() in domain, 13g tax_totals widget, 13h user_has_groups in attrs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 09:07:13 -04:00
gsinghpal
0047f49d2c fix(plating-perms): address final-reviewer critical + important issues
Pre-deploy fixes for Phase 1 permissions overhaul branch (catches by
final-reviewer subagent + main session).

CRITICAL FIXES:

C1: groups_id -> group_ids (Odoo 19 field rename). Affected ~30 sites
    across 4 model files, 1 view, 7 test files. Documented project
    gotcha (feedback_odoo19_groups_id_renamed.md) that the implementer
    subagents missed because they don't see user memory.

C2: action_fp_resolve_plating_landing server action now calls
    env['ir.actions.act_window'].sudo()._fp_resolve_landing_for_current_user()
    instead of the old inline priority chain. Phase E's role-based
    dispatch was previously dead code.

C3: New migrations/19.0.21.1.0/post-migrate.py triggers
    _fp_post_init_role_migration(env) on -u. post_init_hook only fires
    on INSTALL in Odoo 19, not UPGRADE -- so Phase H's preview creation
    wouldn't have auto-fired on entech without this script. Module
    version bumped to 19.0.21.1.0 to match the migration directory.

C4: Team kanban template rewritten for Odoo 19 (<t t-name='card'> with
    semantic <aside>/<main>) instead of legacy <t t-name='kanban-box'>.
    Previous template threw 'Missing card template' at render.

IMPORTANT FIXES:

I1: SO state=sent Confirm button (id='action_confirm') now also gated
    to group_fp_sales_manager. Previously only the state=draft button
    was gated; Sales Reps could send-and-confirm via the secondary path.

I2: Designated Officials picker domain uses all_group_ids (transitive)
    instead of group_ids (explicit only). Owner users now correctly
    appear as eligible CGP DO candidates via the implied_ids chain.

I3: test_menu_visibility.py compliance hub xmlid corrected to
    fusion_plating.menu_fp_compliance_hub (was
    fusion_plating_compliance.menu_fp_compliance_hub which doesn't exist
    -- the hub menu is defined in core's fp_menu.xml). Tests were
    silently skipTest-ing.

I4: _inverse_plating_role chatter audit reads old role from DB via SQL
    (bypasses cache) so 'old -> new' displays actual values, and
    short-circuits no-op writes.

I5: _FP_ROLE_MAPPING_RULES reordered: cgp_designated_official fires
    BEFORE admin/uid_1_or_2 so admin+DO users keep the capability_delta
    marker that triggers res.company.x_fc_cgp_designated_official_id
    auto-set during migration.

I6: _cron_purge_expired_migrations skips groups with active users
    instead of unlink-ing unconditionally. Defense against rollback
    safety being bypassed by manual role assignments post-migration.

CLAUDE.md updated with 3 new durable rules (13b kanban card template,
13c group_ids vs all_group_ids, 13d post_init_hook only on install).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:37:13 -04:00
gsinghpal
5cc1117f75 feat(plating-migration): dry-run + Owner-approval workflow
Phase H of permissions overhaul (LAST subagent phase).

New models:
- fp.migration.preview (state: pending/approved/cancelled/rolled_back)
- fp.migration.preview.line (one per active internal user)

On -u, post_init_hook creates a preview in 'pending' state, walks all
active non-share users through the 12-rule mapping predicate chain
(first match wins, highest precedence first), and schedules a
mail.activity on every Owner.

Mapping table (per spec Section 5):
  uid 1/2 / Administrator   -> owner
  CGP DO (existing)          -> owner + res.company DO field set
  CGP Officer                -> quality_manager
  Manager / Shop Mgr (old)   -> manager
  Accounting                 -> manager
  Estimator-without-Manager  -> sales_rep (flagged: loses confirm)
  Supervisor / Receiving     -> shop_manager
  Operator                   -> technician
  catchall                   -> 'no'

Owner clicks 'Approve & Run' on the preview form -> sudo write removes
old plating groups, adds new role's group, posts Markup chatter audit.
Optionally sets res.company.x_fc_cgp_designated_official_id for the DO.

30-day rollback window via JSON snapshot of groups_id per line. Daily
cron (Fusion Plating: Purge Expired Role Migrations) clears snapshots
+ unlinks old [DEPRECATED] groups after 30 days.

ACL: fp.migration.preview + .line both Owner-only (CRUD).
Menu: Plating > Configuration > Role Migrations (Owner-only).

Tests cover: only-Owner-can-approve, approve advances state, cancel
blocks after approval, rollback restores groups_id, Estimator warning
flagged, uid 2 maps to owner, rollback blocked after 30 days.

Per CLAUDE.md: ir.cron uses only Odoo-19-valid fields (no numbercall,
no doall). Post-init hook is idempotent — won't double-create previews
or re-fire if all users already migrated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:21:43 -04:00
gsinghpal
de3ec7d97a feat(plating-sec): SO confirm gate + fix _administrator typo + Python sweep
Phase G of permissions overhaul.

G2: sale.order.action_confirm now requires group_fp_sales_manager
(spec Section 2.B). Sales Reps can save drafts but cannot move SOs
to 'sale' state. UserError raised with clear message if attempted.

G3: Fixed audit-finding-11 typo bug in 2 files. The original code
checked has_group('fusion_plating.group_fusion_plating_administrator'),
an xmlid that has NEVER existed - so the gate always returned False
and only the Manager-side check actually fired. Fixed both:
  - fusion_plating_invoicing/models/res_partner.py:34
  - fusion_plating_configurator/wizard/fp_direct_order_wizard.py:467
Both now check has_group('fusion_plating.group_fp_manager') which
transitively includes Owner via implied_ids.

G4: Swept all Python has_group() calls to reference new group xmlids.
Backward-compat keeps old refs working today (Phase A's implied_ids),
but the sweep ensures correctness after the 30-day rollback window
deletes old groups. Replacements:
  group_fusion_plating_operator    -> group_fp_technician
  group_fusion_plating_supervisor  -> group_fp_shop_manager_v2
  group_fusion_plating_manager     -> group_fp_manager
  group_fusion_plating_admin       -> group_fp_owner
  group_fusion_plating_cgp_officer -> group_fp_quality_manager
  group_fusion_plating_cgp_designated_official -> group_fp_owner
  group_fp_estimator               -> group_fp_sales_rep
  group_fp_accounting              -> group_fp_manager
  group_fp_receiving               -> group_fp_shop_manager_v2
  group_fp_shop_manager (legacy)   -> group_fp_manager

G1: test_sales_manager_gate.py covers the new confirm gate (SR
blocked, SMg allowed, Manager allowed via diamond implication).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:11:35 -04:00
gsinghpal
89a937fb32 feat(plating-team): Owner-only Team kanban + Designated Official fields
Phase F of permissions overhaul.

Adds res.users.x_fc_plating_role Selection field (8 options matching
the role hierarchy). Compute reads highest plating group from
groups_id (precedence: owner > QM > manager > sales_manager >
shop_manager > sales_rep > technician). Inverse uses sudo().write()
to clear all plating-role groups (additive-by-default m2m (3, id))
then adds the chosen one, and posts a Markup-wrapped chatter audit
naming the actor.

New Owner-only menu: Plating > Configuration > Team. Standard
res.users kanban grouped by x_fc_plating_role with records_draggable
for drag-and-drop role changes. Domain hides shared/portal users
and archived users.

res.company gains two Designated Official fields:
- x_fc_cgp_designated_official_id (CGP DO per Defence Production Act §22)
- x_fc_nadcap_authority_user_id (Nadcap signer)

Both tracking=True for audit. View-level domain restricts picker to
Owner or Quality Manager users via [(ref('...'), ref('...'))] xmlid
domains. New 'Plating Designated Officials' page on res.company form,
Owner-only visibility.

Tests in test_team_page.py cover compute/inverse/chatter/menu.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:03:44 -04:00
gsinghpal
830b29ce49 feat(plating-landing): role-based dispatch resolver + picklist expansion
Phase E of permissions overhaul. The landing resolver now dispatches
based on the user's highest role (per spec Section 3):

  Owner          -> Manager Desk
  Quality Mgr    -> Quality Dashboard
  Manager        -> Manager Desk
  Sales Manager  -> Sale Orders
  Shop Manager   -> Plant Kanban (v2 layout) or Workstation (legacy)
  Sales Rep      -> Quotations
  Technician     -> Plant Kanban / Workstation

User override (x_fc_plating_landing_action_id) still wins; company
default and hardcoded Sale Orders are fallbacks. Layout-flag-aware via
ir.config_parameter['fusion_plating_shopfloor.layout'] (v2 vs legacy).

x_fc_pickable_landing field added to BOTH ir.actions.act_window AND
ir.actions.client (Manager Desk / Plant Kanban / Quality Dashboard
are client actions). Resolver helper polymorphically calls
_render_resolved() on either model.

Tagged 3 of 4 new actions pickable: Manager Desk, Plant Kanban,
Quality Dashboard. (action_fp_shopfloor_landing doesn't exist as an
XML record — it's a JS component name only; legacy layout falls
through to company default gracefully via raise_if_not_found=False.)

Tightened picklist domain to filter by user accessibility (Technician
no longer sees Manager Desk in the dropdown). New compute field
res.users.accessible_landing_action_ids runs check_access_rights on
each pickable action.

Tests in fusion_plating/tests/test_landing_resolver.py.

CLAUDE.md updated with two durable rules:
  - x_fc_pickable_landing lives on BOTH act_window and actions.client
  - Role-based dispatch precedence and helper API

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:56:37 -04:00
gsinghpal
269f9984ef feat(plating-views): Layer 3 — field/button gates per role
Phase D Task D5 of permissions overhaul. Adds explicit groups= to
form-level elements so non-matching roles don't even SEE the buttons
they can't use:

- SO Confirm button → group_fp_sales_manager (Sales Rep sees the SO
  in draft but no Confirm button — matches model-level gate from Phase G)
- SO pricing fields (price_unit/subtotal/total/untaxed/tax) →
  group_fp_sales_rep (Technician/Shop Manager don't see pricing if
  they navigate to an SO)
- Partner Account Hold tab → group_fp_manager (was the fold-in
  group_fp_accounting; the audit-finding-11 _administrator typo lives
  in res_partner.py and is Phase G's fix)
- CAPA Close + all state-transition buttons → group_fp_quality_manager;
  edit fields use readonly="not user_has_groups(...)" so Manager
  retains read+comment per spec section 2.C
- Audit Start/Findings/Close buttons → group_fp_quality_manager
- AVL Approve/Suspend/Reinstate/Remove → group_fp_quality_manager
  (model uses Suspend+Remove instead of spec's literal 'Disqualify';
  both surfaces gated, semantics match)
- Customer Spec edit fields → readonly for non-QM (Manager keeps
  read access per spec; only inputs lock)
- FAIR Approve/Reject buttons → group_fp_quality_manager (Submit-
  for-Review and Reset stay open to whoever created the FAIR)
- Certificate Issue button — Strategy B chosen: single button hidden
  when cert_type=nadcap_cert AND user is not QM. Cleaner than splitting
  into two buttons; no separate action_sign exists on fp.certificate
  (Issue is the sign+publish action). FAIR lives in its own model;
  fp.certificate only has nadcap_cert as a special type. The ir.rule
  from Phase C enforces model-level writes independently.
- CGP form buttons (7 view files: ai, controlled_good, psa,
  receipt_shipment, registration, security_incident, visitor) →
  group_fp_quality_manager on every action button

Defense in depth: ir.rules and ACLs (from Phases B + C) already
restrict model access. These view gates are the UI layer that
matches.

Concerns:
- Spec line 192 names 'sale.order view — x_fc_account_hold_override'
  but no such field exists in the codebase. Closest practical match
  was the partner-side Account Hold management tab, which already had
  a group= attribute. Re-gated there; no SO-side field to gate.
- AVL model has no action_disqualify per spec; uses suspend+remove.
  Both gated to QM.
- fp.certificate has no action_sign (only action_issue). FAIR's
  approve/reject covers the FAIR side; nadcap-cert Issue covers the
  cert side via Strategy B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:45:39 -04:00
gsinghpal
9e5c23f37d fix(plating-tests): correct menu xmlids (menu_fp_sales, menu_fp_shopfloor)
Implementer concern from D1-D4 dispatch: plan template referenced
menu_fp_sales_root / menu_fp_shopfloor_root but actual xmlids drop
the _root suffix. Tests were silently skipping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:36:28 -04:00
gsinghpal
36cd4341a7 feat(plating-menu): Layer 1+2 — explicit groups on top-level menus + submenus
Phase D Tasks D1-D4 of permissions overhaul. Adds explicit groups=
attributes to:
- 9 top-level Plating menus (matrix per spec Section 2.E)
- Quality submenus: Audits, Customer Specs, AVL → QM-only
- Compliance hub child submenus (CGP, General, Safety, Aerospace,
  Nuclear) → QM-only
- Operations submenus: Maintenance, Move Log, Labor History → Shop
  Manager+; Replenishment Suggestions → Manager+

Replaces fragile inheritance + action-ACL-based visibility with
explicit per-menu gates. Now every role's menu tree is deterministic.

Also adds fusion_plating/tests/test_menu_visibility.py — per-role
matrix tests using ir.ui.menu.search_count with the test user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:35:11 -04:00
gsinghpal
c34dfce6c3 fix(plating-tests): correct AVL model name (fusion.plating.avl)
Implementer-flagged concern: plan template referenced fp.approved.vendor.list
but actual model id is fusion.plating.avl. Tests were silently skipping
instead of exercising the AVL split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:24:24 -04:00
gsinghpal
84ed406c8e feat(plating-quality): split Manager vs Quality Manager permissions
Phase C of permissions overhaul (spec Section 2.C).

Manager keeps reactive Quality (NCR/Hold/Check/Cert/RMA — already gated
via Phase B sweep). QM gains exclusive write/create/unlink on strategic
Quality records:

- fusion.plating.capa: Manager → read-only (1,0,0,0); QM → full
- fusion.plating.audit: same split (if model present)
- fp.approved.vendor.list: same split (if model present)
- fusion.plating.customer.spec: same split
- Doc Control models: same split

Plus FAIR/Nadcap cert restriction via two new ir.rule records on
fp.certificate:
- Manager: write/create/unlink on certs where cert_type NOT in
  ('fair', 'nadcap')
- QM: write/create/unlink on all certs (overrides via OR within group)
- Read access unchanged for both (perm_read=False on the rules)

Tests in fusion_plating/tests/test_quality_split.py verify each side
of the split. Models that may not exist on all DBs (audit, AVL) use
skipTest gracefully.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:23:32 -04:00
gsinghpal
f4e1f9d218 refactor(plating-sec): extend ACL sweep to 13 missed modules
The Phase B plan (commit 8eb2c2de) listed 12 modules to sweep, but the
codebase has 13 more modules with ACL CSVs referencing the old role
group xmlids. Backward-compat (Phase A's implied_ids chains) keeps
these working today, but the old groups will be deleted after the
30-day rollback window — so the sweep must cover ALL modules with
plating-group ACL refs to avoid post-rollback breakage.

Sweeps: batch, bridge_documents, bridge_maintenance, bridge_mrp
(uninstalled but file present), bridge_quality (planned removal),
bridge_sign, compliance, culture (retired), kpi, logistics,
notifications, portal, reports.

Pattern matches the original sweep:
  group_fusion_plating_operator → group_fp_technician
  group_fusion_plating_supervisor → group_fp_shop_manager_v2
  group_fusion_plating_manager → group_fp_manager
  group_fusion_plating_admin → group_fp_owner
  group_fp_accounting → group_fp_manager
  group_fp_receiving → group_fp_shop_manager_v2
  group_fp_estimator → group_fp_sales_rep
  group_fp_shop_manager (legacy) → group_fp_manager
  cgp_officer → group_fp_quality_manager
  cgp_designated_official → group_fp_owner

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:18:52 -04:00
gsinghpal
8eb2c2de95 refactor(plating-sec): sweep all ACL CSVs to new role group xmlids
Phase B of permissions overhaul. Mechanical text replacement across
11 ir.model.access.csv files:
  - group_fusion_plating_operator    -> fusion_plating.group_fp_technician
  - group_fusion_plating_supervisor  -> fusion_plating.group_fp_shop_manager_v2
  - group_fusion_plating_manager     -> fusion_plating.group_fp_manager
  - group_fusion_plating_admin       -> fusion_plating.group_fp_owner
  - group_fp_estimator (configurator)-> fusion_plating.group_fp_sales_rep
  - group_fp_accounting              -> fusion_plating.group_fp_manager
  - group_fp_receiving               -> fusion_plating.group_fp_shop_manager_v2
  - group_fp_shop_manager (legacy)   -> fusion_plating.group_fp_manager
  - group_fusion_plating_cgp_officer -> fusion_plating.group_fp_quality_manager
  - group_fusion_plating_cgp_designated_official -> fusion_plating.group_fp_owner

Backward-compat: old group xmlids still resolve (Phase A's implied_ids
chains keep old ACLs working for users still holding old groups).
This sweep ensures future-state correctness: when old groups are deleted
after the 30-day rollback window, ACLs continue resolving via the new
group xmlids.

Also adds fusion_plating/tests/test_acl_migration.py with sample-based
per-role access checks. The 2 CAPA tests are expected to fail until
Phase C implements the Manager/QM quality split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:14:02 -04:00
gsinghpal
bdf676e05a test(plating-sec): verify 8-role hierarchy + implied_ids chains
Group-structure tests for Phase 1 permissions overhaul. Covers:
- All 7 new res.groups records present (8th role "No" is implicit)
- Owner transitively implies base.group_system + every old group
- Manager forms the diamond (implies both Shop Manager and Sales Manager)
- Sales and Shop branches remain orthogonal at the leaf (Tech != Sales Rep)
- uid 1/2 auto-assigned to Owner
- Sequence numbers unique (renders dropdown predictably)
- New groups imply old for backward-compat (30-day rollback safety)
- Cross-module backward-compat chain works (e.g., Owner -> CGP DO)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:03:48 -04:00
gsinghpal
6c7e11db4d fix(plating-sec): move cross-module implied_ids out of fp_security_v2.xml
The previous commit (a53b0326) added implied_ids in fp_security_v2.xml
that referenced 5 xmlids from downstream modules (configurator/receiving/
invoicing/cgp). Since fusion_plating is the BASE module and loads first
at fresh install, those refs raised External-ID-not-found at install.

Fix: relocate the 5 cross-module implications into each downstream module's
own security file via additive (4, ref()) writes to the core group's
implied_ids. Odoo's XML data loader treats these as additive updates so
they stack cleanly across install + -u cycles.

Also: drop redundant <data noupdate="0"> wrapper in fp_security_v2.xml
to match sibling fp_security.xml's bare <odoo> shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:59:20 -04:00
gsinghpal
a53b03265d feat(plating-sec): add 8 consolidated role groups + mark old groups deprecated
Phase A of permissions overhaul (see docs/superpowers/specs/2026-05-23-*).
New groups (technician/sales_rep/shop_manager_v2/sales_manager/manager/
quality_manager/owner) defined in fp_security_v2.xml with implied_ids
chains that include old groups for backward-compat during 30-day rollback
window. Old groups display as [DEPRECATED] in user form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:47:54 -04:00
gsinghpal
560ffa2cdf docs(plating): permissions overhaul Phase 1 — spec + implementation plan
Spec describes consolidation of 12 res.groups into 8 roles (No / Technician /
Sales Rep / Shop Manager / Sales Manager / Manager / Quality Manager / Owner),
role-based landing-page defaults, Owner-only Team management page, and
dry-run + Owner-approval migration workflow.

Plan breaks the work into 9 phases (A through I), ~40 TDD tasks, with
explicit file lists and entech deploy commands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:43:00 -04:00
gsinghpal
d89546bec7 fix(shopfloor): Back button + logo frame shape
Two fixes from live testing of the 2026-05-24 redesign:

1. Job Workspace Back button routed to deprecated component.
   onBack() hardcoded tag: 'fp_shopfloor_landing' so tapping a card on
   the new plant kanban -> opening the workspace -> clicking Back
   dropped the user into the OLD per-step kanban (the legacy OWL
   component the data-record redirects don't reach because doAction
   bypasses the data layer).
   Fix: change the hardcoded tag to 'fp_plant_kanban'. Grep
   confirmed it's the only such reference in JS.

2. Logo frame shape — wider, shorter, logo bigger.
   140x140 square -> 280x110 rectangle. Better fit for horizontal
   company logos (mark + name + tagline laid out left-to-right).
   Uniform 18px padding on all sides so the image breathes evenly.
   Image area is ~244x74 (vs old ~104x104), so a typical horizontal
   logo renders ~50% wider. border-radius 28->22 for the flatter
   rect; letter-mark placeholder font 52->44 to fit the shorter
   frame.

Also augmented CLAUDE.md 'Legacy-action redirect' rule with a new
'grep JS for hardcoded doAction' clause — the XML-record redirect
trick only covers ir.actions.client data; OWL components with inline
this.action.doAction({tag: ...}) calls bypass the data layer entirely
and need a separate sweep.

Asset cache cleared (3 stale attachments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:11:51 -04:00
gsinghpal
818dfa3882 fix(shopfloor): bigger logo frame on the tablet lock screen
User feedback after live testing: the 84px logo frame felt too small
and the image inside used only a fraction of the frame area.

Bumped the frame to 140px (1.67x) — image scales with the container
via the existing max-width/max-height: 100% rule. Proportional
adjustments to padding (14→18), border-radius (20→28), margin-bottom
(12→16), and the letter-mark placeholder font (32→52).

SCSS-only change. Asset cache cleared (3 stale attachments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:06:17 -04:00
gsinghpal
772107d25b feat(shopfloor): tablet lock-screen redesign — frontend + manifest
LS-T2..T6 of the tablet lock-screen redesign (LS-T1 backend shipped
separately in c6137100).

Files:
  - _tablet_lock_tokens.scss  (new — design tokens, dark/light branches
                               via $o-webclient-color-scheme, registered
                               first in manifest per project rule 8)
  - tablet_lock.scss          (full rewrite — gradient bg, glassmorphic
                               tiles, 4 entrance keyframes, hover lift,
                               click press, clocked-in pulse,
                               prefers-reduced-motion gate)
  - tablet_lock.xml           (extended — logo + clock + prompt blocks
                               wrapping the existing tile loop; tile
                               inner shape updated for avatar gradient,
                               has_photo conditional, is_clocked_in
                               modifier)
  - tablet_lock.js            (extended — state.clockText / dateText /
                               company, setInterval(60s) clock tick,
                               _formatTime / _formatDate / tileStyle /
                               avatarClass helpers per project rule 20)
  - __manifest__.py           (19.0.31.0.0 -> 19.0.32.0.0, registered
                               new tokens SCSS BEFORE tablet_lock.scss)

Verified live on entech:
  - Module upgrade clean, registry loaded in 15.5s
  - 6 stale asset attachments cleared
  - Helpers in tablet_controller.py emit company payload + initials +
    gradients correctly (Garry Singh -> GS, EN Tech -> ET, uid=5 ->
    pink gradient)
  - res.company.logo present (has_logo: True)
  - All animations gated by prefers-reduced-motion per spec §6

CLAUDE.md updated with new Critical Rule 22 about Odoo 19 HTML fields
auto-wrapping plain-string writes — caught during Task 1 testing when
the original 'tagline equality' test failed because res.company.report
_header is an HTML field that wraps writes with <p> tags.

Closes the 6-task plan in
  docs/superpowers/plans/2026-05-24-tablet-lock-screen-redesign-plan.md
Spec: docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:56:32 -04:00
gsinghpal
c61371005a feat(shopfloor): extend /fp/tablet/tiles payload with company block
LS-T1 of the tablet lock-screen redesign.

Adds 3 module-level helpers in tablet_controller.py:
  _initials_from(name)       — first/last initials for letter-mark fallback
  _avatar_gradient_for(uid)  — deterministic per-user color (8 gradients)
  _lock_company_payload(env) — company name + tagline + logo URL block

Endpoint /fp/tablet/tiles now returns:
  {ok, company:{id,name,tagline,logo_url,has_logo,initials},
   tiles:[{user_id, name, initials, avatar_url, has_photo,
           avatar_gradient, is_clocked_in, has_pin}, ...]}

Tagline reuses res.company.report_header (the existing invoice-letterhead
field) — no new model field. Falls back to 'Shop Floor Terminal' when
empty.

10 tests pass (initials edge cases, gradient determinism, payload shape).
The 'tagline matches input string' assertion was intentionally NOT added
— see new CLAUDE.md Critical Rule 22 about Odoo 19 HTML field
auto-wrapping that makes such an equality test brittle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:52:17 -04:00
gsinghpal
7a0bd67fc0 docs(shopfloor): implementation plan for tablet lock-screen redesign
6 tasks covering the visual + interaction redesign:

  Task 1 — Backend: 3 module-level helpers in tablet_controller.py
           (_initials_from, _avatar_gradient_for, _lock_company_payload)
           + extended /fp/tablet/tiles payload + 3 test classes (TDD)
  Task 2 — Create _tablet_lock_tokens.scss design tokens (light + dark
           branches via $o-webclient-color-scheme)
  Task 3 — Full rewrite of tablet_lock.scss (gradient bg, glassmorphic
           tiles, 4 entrance keyframes, hover lift, click press,
           clocked-in pulse, prefers-reduced-motion gate)
  Task 4 — Extend tablet_lock.xml with logo + clock + prompt blocks
           wrapping the existing tile loop
  Task 5 — Extend tablet_lock.js with state.clockText / state.dateText /
           state.company + setInterval clock tick + _formatTime /
           _formatDate / tileStyle / avatarClass helpers (all per
           project rule 20 — coercion lives in JS, not in templates)
  Task 6 — Register the new tokens SCSS in manifest BEFORE
           tablet_lock.scss (per rule 8), bump version 19.0.32.0.0,
           deploy + verify

Each task has TDD-style steps with full code blocks. Self-review
confirms 1-to-1 coverage of every spec section + correct deferral of
every §12 Phase 2 item.

Plan: docs/superpowers/plans/2026-05-24-tablet-lock-screen-redesign-plan.md
Spec: docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:44:24 -04:00
gsinghpal
efc420b4ce docs(shopfloor): tablet lock-screen redesign spec
Hybrid Industrial Bold + Premium Glassmorphism direction approved
during brainstorming. Adds company branding (logo from
res.company.logo with letter-mark fallback), real-time clock, tighter
3-column tile grid for ~10-15 operator small shops, dual dark/light
mode via compile-time $o-webclient-color-scheme branch, 7-animation
catalogue gated by prefers-reduced-motion.

Backend touch: extend /fp/tablet/tiles payload with company block +
per-tile initials/avatar_gradient/has_photo. Two small helper
functions in tablet_controller. No DB migration.

Frontend touch: new _tablet_lock_tokens.scss (loads first), full
rewrite of tablet_lock.scss, extend XML + JS for clock + company.

Mockup: .superpowers/brainstorm/1983-1779585812/content/lock-final.html
(in-repo since the brainstorm session used --project-dir).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:38:11 -04:00
gsinghpal
fd2b2908f3 fix(shopfloor): plant-view card sizing — match the mockup proportions
User feedback after live testing: cards were too cramped on the 9-column
board. Restoring the Variant C mockup proportions and letting the board
scroll horizontally on smaller viewports (user explicitly accepted
side-scrolling).

Changes:
  - .board grid: repeat(9, 1fr) → repeat(9, minmax(300px, 1fr))
    plus overflow-x: auto. Each column ~300px so the card has room to
    breathe. ~6 columns visible on 1920px desktop, ~4 on 1366px tablet,
    smooth horizontal scroll for the rest.
  - .col-scroll: gap 4→8, max-height eased so cards aren't packed.
  - .o_fp_plant_card: padding 8/10→12, gap 4→6, base font 11→12.
  - card-wo: 13→16 (matches mockup header size).
  - card-step: 12→14.
  - chips: padding 1/6→2/8, font 10→11, radius 10→12.
  - mini-timeline blocks: 8→16px tall (current step 11→22px), labels
    8→9px. Matches the mockup's punchy timeline strip.
  - progress bar: max-width 60→100, height 3→4.
  - operator pill / icon-row: bumped to match card scale.

No backend changes. SCSS-only. Asset cache cleared (3 attachments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:20:01 -04:00
gsinghpal
eb1fd50add fix(shopfloor): legacy client actions redirect to fp_plant_kanban
The plant-view rollout left two legacy ir.actions.client data records
still claiming tag='fp_shopfloor_landing':
  - action_fp_plant_overview        (Plant Overview)
  - action_fp_shopfloor_tablet      (Shop Floor — Tablet Station)

The landing-action resolver dispatched the new view correctly when the
user clicked the Plating root menu, but bookmarks / breadcrumbs /
QR-scan landings / direct URLs still routed through these legacy
actions and loaded the per-step kanban (OWL component is still
registered for back-compat).

Flipping their tag to fp_plant_kanban means every entry point now
opens the new view. The legacy fp_shopfloor_landing OWL component
stays registered (no code removed) but no XMLID points at it
anymore — safe to delete in a future cleanup.

Also documented this as a durable convention in CLAUDE.md under
'Legacy-action redirect (general rule for OWL component swaps)'.

Verified on entech:
  - action 1129 (Shop Floor)     tag: fp_shopfloor_landing → fp_plant_kanban
  - action 1133 (Plant Overview) tag: fp_shopfloor_landing → fp_plant_kanban
  - 3 stale asset bundles cleared
  - Module re-upgraded clean, registry rebuilt in 15.7s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:14:33 -04:00
gsinghpal
a60506a645 feat(shopfloor): Phase 5 — flip default to v2 plant view + docs
PV-Phase5 of the plant-view redesign. Final phase — flips the default
of x_fc_shopfloor_layout from 'legacy' to 'v2' and updates CLAUDE.md
with the new architecture rule.

Verified on entech:
  - HTTP 200 on /web/login
  - Shopfloor module loads cleanly with all 19 new frontend files
  - /fp/landing/plant_kanban returns the assembled payload with 9
    columns + denormalized cards
  - Card state distribution: 22 contract_review + 8 no_parts + 1 running
    (sample data only — dev system)
  - Asset bundle re-compiled (9 stale attachments cleared)
  - ir.config_parameter['fusion_plating_shopfloor.layout'] = 'v2' set

To switch back to legacy: Settings → Fusion Plating → Shop Floor
Layout, or UPDATE ir_config_parameter SET value='legacy' WHERE
key='fusion_plating_shopfloor.layout'.

CLAUDE.md gets a new ~80-line section documenting:
  - Why the redesign (per-step kanban produced duplicate cards)
  - 9-column layout + step-kind → area mapping (spec D3, D4, D5)
  - 13-state catalog + precedence dispatch in _compute_card_state
  - Backend single-endpoint payload shape (/fp/landing/plant_kanban)
  - Frontend OWL component tree + critical implementation gotchas
    (rule 20 OWL scope, rule 8 SCSS @import, dark-mode compile-time)
  - How to switch back to legacy

Closes the 20-task plan in
  docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md

Spec: docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:59:44 -04:00
gsinghpal
8b9b4d60ad feat(shopfloor): Phase 4 — plant-view kanban frontend (OWL + SCSS + XML)
PV-Phase4 of the plant-view redesign. 19 new files implementing the
6-component OWL tree plus design tokens.

Components (each = JS + XML + SCSS triple):
  - FpMiniTimeline    — 9-step bar consuming mini_timeline_json
  - FpPlantCard       — Variant C card; 13 state-* CSS classes; tap
                        opens fp_job_workspace
  - FpColumnHeader    — column label + count badge + 'You're here'
                        badge when paired
  - FpKpiTile         — clickable KPI button with urgent/warn/good
                        variants and active state
  - FpFilterChip      — toggleable chip
  - FpPlantKanban     — top-level orchestrator: 10s polling, mode
                        toggle, search + 6 filter chips, board with
                        9 fixed columns, localStorage filter persistence

SCSS:
  - _plant_tokens.scss (loads first, exposes $plant-* vars to every
    later file — required because Odoo 19 forbids @import in custom
    SCSS, manifest order IS the concat order)
  - Dark mode via $o-webclient-color-scheme compile-time branch

Manifest registers all assets in dependency order: tokens → component
SCSS → component XML → leaf JS → top-level JS. Mirrors the existing
project pattern.

Critical patterns honored:
  - Project rule 20 (no String/Number/parseInt in OWL templates):
    all coercion in JS, string literals in foreach arrays.
  - No t-out without markup() (none in this batch — all card text is
    pre-formatted by the controller).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:57:55 -04:00
gsinghpal
a90eace4d0 feat(shopfloor): Phase 3 — plant_kanban endpoint + dispatch
PV-Phase3 of the plant-view redesign.

- /fp/landing/plant_kanban JSONRPC endpoint returns {kpis, columns,
  cards} in one payload. One card per fp.job; cards denormalized so
  the OWL component doesn't fan out RPCs. Server-side filter handling
  for All / Mine / Running / Blocked / Overdue / FAIR. Within-column
  sort by (overdue, _SORT_PRIORITY[card_state], due_date).
- fusion_plating_shopfloor.action_fp_plant_kanban client action
  registered alongside the existing fp_shopfloor_landing action.
- fp_landing_data.xml resolver extended to read the layout flag and
  dispatch to v2 when x_fc_shopfloor_layout='v2' (default still legacy).

Card payload (23 fields): WO, customer, PN+rev, qty, PO, recipe, spec,
tags, current step + work centre, state chip, mini_timeline, operator,
icons (signoff / bake / tracking / etc.), progress.

State-chip mapping per spec §6.1 — one map keyed by card_state with
running-time elapsed, idle-hours, and operator-name interpolation.

Verified live — card payload sample on WO-30036 (contract_review state)
produces all expected keys + 9-element mini_timeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:51:36 -04:00
gsinghpal
7c2ae84e32 feat(jobs): Phase 2 — card_state + mini_timeline + precedence helpers
PV-Phase2 of the plant-view redesign.

Implements the 13-state classifier on fp.job:
  - card_state Char field, stored + indexed for fast filtering
  - _compute_card_state with explicit precedence dispatch matching
    spec §6.2 / §9.3 exactly (no_parts → on_hold → awaiting_signoff
    → awaiting_qc → bake_due → predecessor_locked → idle_warning →
    done → contract_review → running/_mine → ready/_mine)

Six precedence helpers, each isolated for testability:
  _fp_inbound_not_received, _fp_has_open_hold, _fp_has_pending_qc,
  _fp_bake_window_due_soon, _fp_is_mine + _fp_has_unfinished_predecessors
  on fp.job.step.

mini_timeline_json compute: 9-element array (one per column) with
state in {done, current, upcoming} and an optional 'variant' on the
current marker keyed to card_state for renderer color mapping.

Verified live:
  - 14 jobs in contract_review (no active step yet)
  - 8 in no_parts (confirmed + draft fp.receiving)
  - 1 running (WO-30051 with Pre-Measurements at Plating column)
  - mini_timeline JSON renders the full 9-area structure with the
    plating slot marked current+variant=running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:48:14 -04:00
gsinghpal
63d692b322 feat(plating): Phase 1 — plant-view kanban data model foundation
PV-T1: fp.work.centre.area_kind Selection (9 floor columns)
PV-T2: fp.job.step.area_kind compute + _STEP_KIND_TO_AREA fallback
       (covers all 30+ step kinds in the project library, plus the
       spec D4 rule that de_mask folds into de_racking)
PV-T3: fp.job.step.last_activity_at + write hook + message_post
       override + fp.job.step.move.create() hook + _fp_is_idle helper
PV-T4: res.users.paired_work_centre_ids M2M (single-station for MVP,
       forward-compatible for Phase 2 multi-station picker)
PV-T5: res.config.settings.x_fc_shopfloor_layout feature flag backed
       by ir.config_parameter for the landing-action resolver

Migrations:
  fusion_plating 19.0.21.0.0      — backfill area_kind from kind
  fusion_plating_jobs 19.0.10.24.0 — backfill last_activity_at

Deployed + verified on entech:
  - 9/9 fp.work.centre rows have area_kind set
  - 400/400 fp.job.step rows have area_kind + last_activity_at
  - paired_work_centre_ids M2M relation table created
  - All 271 modules loaded cleanly, registry rebuilt in 27s

Part of the 2026-05-23 Shop Floor plant-view kanban redesign.
Plan: docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md
Spec: docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:43:15 -04:00
gsinghpal
1a3ca8704e feat(plating): session 2026-05-23 deploys — F1/F7/S22/S23 + UI fixes
Consolidated commit of session work already deployed to entech and
verified via the deep audit + the persona walk:

S22 — Signoff gate (fp.job.step.requires_signoff was 100% unenforced,
42/42 done steps had NULL signoff_user_id). Three-piece fix:
_fp_autosign_if_required (captures finisher on button_finish),
_fp_check_signoff_complete (raises UserError if NULL after autosign),
action_signoff (explicit supervisor pre-sign). Bypass:
fp_skip_signoff_gate=True.

S23 — Transition-form gate (same dormant-field shape as S22, caught
preventively before recipe authors flipped requires_transition_form
on). Model helpers on fp.job.step.move + controller gate in
move_controller (parts commit) + pre-reject in rack commit.

F7 — Chatter standardization: _fp_create_qc_check_if_needed,
_fp_fire_notification, _fp_create_delivery silent failures now also
post to job chatter instead of only logging to file.

UI fixes:
- Critical Rule 20 documented + applied: OWL templates only expose
  Math as a global. Calling String(d) inside t-on-click throws
  'v2 is not a function'. Fixed pin_pad.xml (string array instead of
  number array with String() coercion). Also swept parseInt/
  parseFloat in recipe_tree_editor + simple_recipe_editor.
- Notes panel HTML escape fix: chatter messages off /fp/workspace/load
  were rendered via t-out, escaping the HTML. Wrap with markup() in
  job_workspace.js refresh() before assigning to state.

Versions:
  fusion_plating         19.0.20.8.0 → 19.0.20.9.0
  fusion_plating_jobs    19.0.10.20.0 → 19.0.10.23.0
  fusion_plating_shopfloor 19.0.30.2.0 → 19.0.30.5.0

All deployed to entech (LXC 111) and verified live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:37:17 -04:00
gsinghpal
d6ebcb6233 docs(shopfloor): implementation plan for plant-view kanban redesign
20 tasks across 5 phases:
  1. Data model foundation (area_kind, last_activity_at, paired
     work centres, feature flag) — 5 tasks
  2. Card state computation + mini-timeline (precedence helpers,
     card_state compute, mini_timeline_json) — 3 tasks
  3. Backend endpoint + landing dispatch — 2 tasks
  4. Frontend components bottom-up (tokens, mini-timeline, card,
     column header, KPI tile, filter chip, top-level action) —
     7 tasks
  5. QA + flip default — 3 tasks

Each task has TDD-style steps (write failing test → run → implement
→ run → commit) with full code blocks and exact file paths. Bakes
in project-specific patterns from CLAUDE.md (OWL template scope
rule 20, t-out markup wrap, no SCSS @import, dark-mode compile-
time branch).

Self-review pass confirms 1-to-1 coverage of every spec section
and explicit deferral of every §13 Phase 2 item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:34:42 -04:00
gsinghpal
48805b5988 docs(shopfloor): plant-view kanban redesign spec
Replaces per-step-grouped kanban with department-grouped (9 fixed columns).
One card per fp.job; recipe step count no longer drives layout width.

- 9 fixed columns in process sequence: Receiving / Masking / Blasting /
  Racking / Plating / Baking / De-Racking / Final inspection / Shipping
- new fp.work.centre.area_kind Selection + step_id.area_kind related
- 13 mutually-exclusive card states with explicit precedence list and
  matching _compute_card_state dispatcher
- Variant C card: WO header, customer/PN/qty/PO, recipe/spec, tag chips,
  current step + tank + state chip, 9-step mini-timeline, progress +
  operator pill + icon row
- /fp/landing/plant_kanban endpoint returns columns + denormalized cards
- MVP uses existing single-station pairing UX; M2M field structure is
  forward-compatible for cross-trained operators (Phase 2)
- Feature flag x_fc_shopfloor_layout for parallel rollout

Deferred to Phase 2: drag-drop, sibling grouping, bottleneck heatmap,
manager-specific KPIs, phone breakpoint, sort customization,
quick-action sheet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:22:17 -04:00
gsinghpal
005daade55 changes 2026-05-23 07:53:41 -04:00
gsinghpal
27e12dd544 chore(shopfloor): register fp_rpc.js asset + bump to 19.0.30.2.0 (P6.3.6)
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Adds the Phase 6.3 fpRpc wrapper to the web.assets_backend bundle.
Placed before its consumers so the `import { fpRpc } from "./services/fp_rpc"`
calls in job_workspace, shopfloor_landing, manager_dashboard, and
hold_composer resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:47:51 -04:00
gsinghpal
5f03080374 feat(shopfloor): switch action-path RPCs to fpRpc + wire plant_overview/move_card (P6.3.5)
JobWorkspace, ShopfloorLanding, ManagerDashboard, and the embedded
FpHoldComposer now call fpRpc() for write-path endpoints (start/finish
step, hold create, sign-off, milestone advance, work-centre move,
assign-worker, assign-tank, manager takeover). fpRpc auto-injects
tablet_tech_id from the tech_store so the server can rebind env via
env_for_tablet_tech() and credit the right user.

Read-path RPCs (workspace/load, landing/kanban, manager/overview,
manager/funnel, manager/approval_inbox, manager/at_risk, shopfloor/scan)
stay as plain rpc() — no audit benefit, no need for the extra plumbing.

Also wires tablet_tech_id into /fp/shopfloor/plant_overview/move_card
which I missed in P6.3.3 — surfaced when grepping JS for write callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:47:20 -04:00
gsinghpal
efaf16dffb feat(shopfloor): propagate tablet_tech_id to shopfloor + manager action endpoints (P6.3.3 + P6.3.4)
10 endpoints in shopfloor_controller (log_chemistry, start_bake, end_bake,
start_wo, stop_wo, bump_qty_done, bump_qty_scrapped, log_thickness_reading,
quality_hold, mark_gate) and 3 in manager_controller (assign_worker,
assign_tank, take_over) now accept a `tablet_tech_id` kwarg. Each rebinds
env via env_for_tablet_tech() so writes carry the correct uid even when
the OS session belongs to the persistent tablet user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:43:44 -04:00
gsinghpal
e4000374ca feat(fusion_plating_shopfloor): wire tablet_tech_id into workspace endpoints (P6.3.2)
hold, sign_off, advance_milestone each accept tablet_tech_id and
rebind env via env_for_tablet_tech. Writes (Hold.create, button_finish,
action_advance_next_milestone) now carry the tech-of-record's uid.
load endpoint is read-only and untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:37:58 -04:00
gsinghpal
fee4219703 feat(fusion_plating_shopfloor): fpRpc wrapper + env_for_tablet_tech helper (P6.3.1)
Client-side fpRpc() is a drop-in for rpc() that automatically injects
tablet_tech_id from the tech_store into every action call. Read-only
endpoints can keep using plain rpc().

Server-side env_for_tablet_tech(env, tablet_tech_id) returns an env
scoped via with_user() when the id is a valid active user; otherwise
returns the original env unchanged. Controllers call this at the top
of action methods so all subsequent writes carry the right uid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:37:02 -04:00
580 changed files with 79802 additions and 3762 deletions

BIN
.DS_Store vendored

Binary file not shown.

17
.gitignore vendored Normal file
View 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

View File

@@ -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 111 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
View File

@@ -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 111 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).

View 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 #914 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`
(~12 min). Syncs the module to the odoo-trial Enterprise sandbox (Proxmox VM 316, db
`trial`) and runs `--test-enable`. Local dev Odoo is Community and **cannot** install this
module.
## Branch hygiene (lesson from this session)
Cut each new feature branch from `main`, and land it before starting the next. For any
cross-branch git surgery, use a **throwaway `git worktree`** — never switch the shared
working dir's branch, because a concurrent session may be working on it.
---
## UPDATE — sub-project #2 complete (2026-05-27, later session)
All four chunks of #2 are now built. The brainstorm "which slice" question resolved to
2a-first; everything else followed.
**Done + on `main` in `Odoo-Modules` (fully tested on odoo-trial, suite `FCB_EXIT=0`):**
- **2a — importer** (`fusion.billing.import.wizard`): read-only `psycopg2` reader split
from pure-Odoo writes; users→partners+links, plans→`cpu_seconds` charge catalog
(`plan_id` NULL), deployments→one **draft shadow** `sale.order` each with the flat price.
Shadow-safe by construction (draft + no token + NULL `plan_id`). Idempotent, dry-run,
Test-Connection guard, README runbook.
- **2d — reconciliation** (`fusion.billing.reconciliation`): `_compute_reconciliation` +
`_reconcile_rows` (Odoo flat+overage vs NexaCloud actual, status match/delta), reader for
NexaCloud usage+invoice actuals, "Run Reconciliation" button. **Upsert key is
`(service, external_subscription_id, period)`** — per subscription, so a customer with
two deployments doesn't collide.
- **/usage enabler**: `_api_record_usage` resolves a subscription by the source app's own
id (`x_fc_nexacloud_subscription_id`) so NexaCloud can push against shadow subs.
- Core-engine bug fixed in passing: `charge.price_per_unit` is now `Float(16,6)` and
`_compute_billable` keeps 6-dp precision (was `Monetary`/cent-rounded → would under-bill
sub-cent rates and drift from NexaCloud's 4-dp amounts).
**Code-complete in `Nexa-Cloud` (feature-flagged, NOT deployed, NOT integration-tested):**
- **2b — usage push**: `services/odoo_billing_client.py` + a hook in `usage_metering.py`
posting cpu-seconds to Odoo `/usage`. **2c — control loop**:
`routers/odoo_billing.py` (`POST /api/v1/billing/webhooks/central`, HMAC-verified) +
`services/odoo_billing_integration.py` (suspend/restore/deprovision). All INERT unless
`ODOO_BILLING_ENABLED`. Implemented as NEW modules + edits to clean files only —
NexaCloud `main` had concurrent **Cursor uncommitted WIP** (`routers/billing.py`,
`scheduler.py`, `stripe_service.py`, `models/billing.py`, …) which was deliberately not
touched. Commits: `94542ec` + `956abb2` (only my files staged).
**Deployment status (2026-05-27):**
- **odoo-nexa (production `nexamain`): DEPLOYED** — `fusion_centralize_billing` (core + 2a
+ 2d) **fresh-installed** (#1 had never actually been deployed here; `DIR_ABSENT` before).
`ir_module_module.state = installed`, `odoo-nexa-app` healthy. **INERT**: no
`nexacloud_dsn`, all charges `plan_id` NULL (rating cron no-op), no webhooks queued
(dispatch cron no-op), inbound API 401s with no key configured. Synced to
`/opt/odoo/custom-addons` + `-i` via the restart-safe recipe.
- **NexaCloud (prod, `vps.nexasystems.ca` / 192.168.1.250): DEPLOYED — INERT.** Did NOT
use `./deploy.sh` (it `rsync --delete`s the working tree → would have shipped the
concurrent **uncommitted Cursor WIP** (7 files) AND wiped the gitignored prod `.env`
files). Instead deployed **surgically**: rsync of ONLY my 6 committed billing files (no
`--delete`; `.env` + Cursor's files untouched), `docker compose build backend`,
**boot-tested in a throwaway container** (`run --rm --no-deps backend python -c "import
app.main"` → BOOT_OK) before swapping, then `up -d backend`. `nexacloud-api` healthy,
`/health` OK. Feature OFF: `ODOO_BILLING_ENABLED` unset → `/billing/webhooks/central`
returns 404 and no usage is pushed. Activate later by setting `ODOO_BILLING_*` in
`/opt/nexacloud/.env` (+ compose env passthrough) once the Odoo side is wired.
**NOTE:** Cursor's 7-file WIP remains uncommitted locally and was never deployed — when
Cursor finishes, a normal `./deploy.sh` will ship it (and re-sync `.env`).
**Dual-run stand-up results (2026-05-27) — STOPPED here for review, NOT flipped:**
- Read-only role `odoo_billing_ro` created on nexacloud Postgres (192.168.1.50); DSN set in
`ir.config_parameter` `fusion_billing.nexacloud_dsn` on nexamain. Test Connection OK
(read 7 users / 232 plans / 87 subscriptions).
- **Shadow import committed on nexamain**: 7 partners, 232 plan catalogs, 87 draft shadow
subscriptions; 0 skipped, 0 failed. (NOTE: importer takes ALL plans/subs regardless of
active status → ~464 NC-* products now in the prod ERP catalog. Consider filtering to
`is_active` plans / active subscriptions, or prune the shadow records — all reversible.)
- **Reconciliation pass**: 9 (sub,period) rows had real billing activity → **2 match, 7
delta**, 0 failed. The 7 deltas, MUST resolve before flipping:
1. **One-off / non-subscription invoices** (3 rows: $877.99, $872.66, $32.20) — nexacloud
invoices with NULL subscription_id (fees/manual/credits); not modeled per-subscription.
2. **List-price ≠ actual-invoiced** (4 rows: Odoo $200/$50 vs actual ~$9.1x) — likely
proration or NexaCloud invoicing ≠ plan list price.
- **2d bug surfaced (analysis-only, not safety):** `_reconcile_rows` with an empty
`subscription_external_id` matches NULL-field orders instead of skipping → spurious
delta rows for the one-off invoices. Add `if not sub_ext: skip`.
**Remaining before go-live (gated on infra / ops you do):**
1. Grant the read-only DSN (`fusion_billing.nexacloud_dsn`) — see the module README — then
Test Connection → dry-run import → review → real import.
2. Run a dual-run cycle (Run Reconciliation), confirm all rows `match`.
3. **2c needs the Odoo side to actually EMIT** `invoice.payment_failed` /
`payment_succeeded` / `subscription.terminated` webhooks with `deployment_id` in the
payload — that emission isn't wired yet (it belongs to the live billing flow). The
NexaCloud receiver is built to that contract; confirm the payload shape when wiring it.
4. Integration-test + deploy the NexaCloud changes (no test harness in that repo).
5. The flip: set `charge.plan_id`, attach Stripe tokens, confirm the shadow subs.
Specs/plans: `specs/2026-05-27-nexacloud-billing-importer-design.md`,
`specs/2026-05-27-nexacloud-reconciliation-design.md`, and the matching plans.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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. ✓

View 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, ~12 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 24 + 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).

View 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, ~12 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.

View 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.

View File

@@ -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 111); 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`

View 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`.

View File

@@ -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` ≈ L564572).
- **`create()` subscribes the customer as a follower** (the "make customer follower" loop, ≈ L600620),
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 (≈L564572) + follower subscription
(≈L600620).
- `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`.

View File

@@ -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`).

View File

@@ -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.

View File

@@ -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`).

View File

@@ -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`).

View 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:
- 46 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.

View File

@@ -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 95 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 95 default with
`is_off = False`**. So everyone is treated as a 95 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 95 — 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 MonFri True, SatSun 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. "MonFri Day", "SatSun 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.

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ls /k/Github/Odoo-Modules/ | grep -i -E \"shopfloor|tablet|fusion_plating\")"
]
}
}

View 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.

View File

@@ -0,0 +1,3 @@
from . import models
from . import controllers
from . import wizards

View 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,
}

View File

@@ -0,0 +1 @@
from . import api

View 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)

View 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>

View 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

View 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,
})

View 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.",
)

View 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)

View 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.")

View 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

View 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.")

View 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

View 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}

View 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)

View 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))

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_billing_service_admin fusion.billing.service admin model_fusion_billing_service base.group_system 1 1 1 1
3 access_fusion_billing_account_link_admin fusion.billing.account.link admin model_fusion_billing_account_link base.group_system 1 1 1 1
4 access_fusion_billing_metric_admin fusion.billing.metric admin model_fusion_billing_metric base.group_system 1 1 1 1
5 access_fusion_billing_charge_admin fusion.billing.charge admin model_fusion_billing_charge base.group_system 1 1 1 1
6 access_fusion_billing_usage_admin fusion.billing.usage admin model_fusion_billing_usage base.group_system 1 1 1 1
7 access_fusion_billing_webhook_admin fusion.billing.webhook admin model_fusion_billing_webhook base.group_system 1 1 1 1
8 access_fusion_billing_reconciliation_admin fusion.billing.reconciliation admin model_fusion_billing_reconciliation base.group_system 1 1 1 1
9 access_fusion_billing_metric_acct fusion.billing.metric accountant model_fusion_billing_metric account.group_account_manager 1 1 1 0
10 access_fusion_billing_charge_acct fusion.billing.charge accountant model_fusion_billing_charge account.group_account_manager 1 1 1 0
11 access_fusion_billing_reconciliation_acct fusion.billing.reconciliation accountant model_fusion_billing_reconciliation account.group_account_manager 1 1 1 0
12 access_fusion_billing_import_wizard fusion.billing.import.wizard model_fusion_billing_import_wizard base.group_system 1 1 1 1
13 access_fc_invoice_ledger_wizard fusion.billing.invoice.ledger.wizard model_fusion_billing_invoice_ledger_wizard base.group_system 1 1 1 1

View 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

View 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)]))

View 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)

View 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

View 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)

View 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)

View 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'})

View 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)

View 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)

View 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>

View 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>

View File

@@ -0,0 +1,2 @@
from . import import_wizard
from . import invoice_ledger

View 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

View 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
View 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
```

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Clock',
'version': '19.0.3.3.0',
'version': '19.0.3.13.1',
'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,

Binary file not shown.

View File

@@ -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

View File

@@ -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,6 +404,7 @@ class FusionClockAPI(http.Controller):
self._apply_break_deduction(attendance, employee)
# Check for early clock-out penalty
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)
@@ -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),

View File

@@ -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,6 +130,15 @@ class FusionClockKiosk(http.Controller):
source='kiosk',
)
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)
@@ -135,6 +154,7 @@ class FusionClockKiosk(http.Controller):
})
api._apply_break_deduction(attendance, employee)
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)

View File

@@ -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:
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',
)
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,6 +376,7 @@ class FusionClockNfcKiosk(http.Controller):
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
})
api._apply_break_deduction(attendance, employee)
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(
@@ -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'])

View File

@@ -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),
])

View 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,
}

View File

@@ -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>

View File

@@ -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.

View File

@@ -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 1020 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.40.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".

View 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'
""")

View 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"
)

View File

@@ -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

View File

@@ -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')

View File

@@ -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:

View File

@@ -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))

View 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',
)

View File

@@ -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)."""

View File

@@ -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)
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,7 +317,6 @@ class HrAttendance(models.Model):
'hr.attendance',
att.id,
)
_logger.info(
"Fusion Clock: Auto clocked out %s (attendance %s)",
employee.name, att.id,
@@ -327,6 +327,57 @@ class HrAttendance(models.Model):
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):
"""Cron job: check for absent employees (no attendance on workday)."""
@@ -342,9 +393,14 @@ class HrAttendance(models.Model):
LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
for emp in employees:
try:
with self.env.cr.savepoint():
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
if yesterday.weekday() >= 5:
# 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)
@@ -367,7 +423,8 @@ class HrAttendance(models.Model):
leave = LeaveRequest.search([
('employee_id', '=', emp.id),
('leave_date', '=', yesterday),
('leave_date', '<=', yesterday),
('date_to', '>=', yesterday),
], limit=1)
if leave:
continue
@@ -401,38 +458,47 @@ class HrAttendance(models.Model):
)
_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:
try:
with self.env.cr.savepoint():
today = get_local_today(self.env, emp)
if today.weekday() >= 5:
if not emp._get_fclk_day_plan(today).get('scheduled'):
continue
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:
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),
@@ -446,17 +512,26 @@ class HrAttendance(models.Model):
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:
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}, your shift ends at {_fclk_utc_to_local_str(scheduled_out, emp)}. "
f"Don't forget to clock out.",
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):

View File

@@ -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)',
# 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()

View File

@@ -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()

View File

@@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_fusion_clock_leave_request_manager fusion.clock.leave.request.manager model_fusion_clock_leave_request group_fusion_clock_manager 1 1 1 1
12 access_fusion_clock_shift_user fusion.clock.shift.user model_fusion_clock_shift group_fusion_clock_user 1 0 0 0
13 access_fusion_clock_shift_manager fusion.clock.shift.manager model_fusion_clock_shift group_fusion_clock_manager 1 1 1 1
14 access_fusion_clock_schedule_user fusion.clock.schedule.user model_fusion_clock_schedule group_fusion_clock_user 1 0 0 0
15 access_fusion_clock_schedule_manager fusion.clock.schedule.manager model_fusion_clock_schedule group_fusion_clock_manager 1 1 1 1
16 access_fusion_clock_schedule_audit_manager fusion.clock.schedule.audit.manager model_fusion_clock_schedule_audit group_fusion_clock_manager 1 0 0 0
17 access_fusion_clock_correction_user fusion.clock.correction.user model_fusion_clock_correction group_fusion_clock_user 1 0 0 0
18 access_fusion_clock_correction_manager fusion.clock.correction.manager model_fusion_clock_correction group_fusion_clock_manager 1 1 1 1
19 access_fusion_clock_location_portal fusion.clock.location.portal model_fusion_clock_location base.group_portal 1 0 0 0
25 access_hr_attendance_portal hr.attendance.portal hr_attendance.model_hr_attendance base.group_portal 1 0 0 0
26 access_hr_employee_portal_clock hr.employee.portal.clock hr.model_hr_employee base.group_portal 1 0 0 0
27 access_fusion_clock_shift_portal fusion.clock.shift.portal model_fusion_clock_shift base.group_portal 1 0 0 0
28 access_fusion_clock_schedule_portal fusion.clock.schedule.portal model_fusion_clock_schedule base.group_portal 1 0 0 0
29 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

View File

@@ -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 &lt; Team Lead &lt; 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>

View File

@@ -21,7 +21,7 @@
--fclk-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
--fclk-toast-bg: #ffffff;
margin: -16px -15px;
margin: 0;
padding: 0;
min-height: 100vh;
background: var(--fclk-bg);
@@ -83,6 +83,47 @@ body:has(.fclk-app) .o_footer {
display: none !important;
}
/* Full-bleed: never let the portal layout's white chrome show as a border
around the dark app. Match the PAGE background to the app's background in
both themes (so the wrapper's container padding reads as an invisible
gutter) and clip horizontal overflow from the .fclk-app full-bleed margins. */
html:has(.fclk-app),
body:has(.fclk-app) {
background: #f3f4f6;
overflow-x: hidden;
}
@media (prefers-color-scheme: dark) {
html:has(.fclk-app),
body:has(.fclk-app) {
background: #0f1117;
}
}
html.o_dark:has(.fclk-app),
html.o_dark body:has(.fclk-app),
body:has(.fclk-app.fclk-dark) {
background: #0f1117;
}
/* Neutralise EVERY portal layout wrapper so the dark app fills edge-to-edge.
Confirmed from the live DOM that the chain is:
#wrapwrap > main > .o_fp_portal_shell > .o_fp_portal_main >
#wrap.o_portal_wrap > .container > .fclk-app
The white frame was .o_fp_portal_shell (the fusion_plating_portal "shell")
plus the Bootstrap .container max-width + "pt-3 pb-5" padding. Make them all
transparent, full-width, no padding/margin/border. */
body:has(.fclk-app) #wrapwrap,
body:has(.fclk-app) main,
body:has(.fclk-app) .o_fp_portal_shell,
body:has(.fclk-app) .o_fp_portal_main,
body:has(.fclk-app) #wrap.o_portal_wrap,
body:has(.fclk-app) #wrap.o_portal_wrap > .container {
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
max-width: 100% !important;
border: 0 !important;
}
.fclk-container {
max-width: 480px;
margin: 0 auto;
@@ -219,6 +260,63 @@ body:has(.fclk-app) .o_footer {
opacity: 0.5;
}
/* ---- Scheduled Shift Card ---- */
.fclk-schedule-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
border-radius: 14px;
padding: 14px 16px;
margin: -14px 0 28px;
box-shadow: var(--fclk-shadow);
}
.fclk-schedule-icon {
width: 38px;
height: 38px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(59, 130, 246, 0.12);
color: var(--fclk-blue);
font-size: 16px;
}
.fclk-schedule-info {
min-width: 0;
flex: 1;
}
.fclk-schedule-label {
color: var(--fclk-text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fclk-schedule-value {
color: var(--fclk-text);
font-size: 14px;
font-weight: 650;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fclk-schedule-hours {
color: var(--fclk-text);
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ---- Timer Section ---- */
.fclk-timer-section {
text-align: center;
@@ -1144,6 +1242,115 @@ html.o_dark .fclk-wizard-overlay {
border-radius: 4px;
}
/* Responsive timesheet entries — stacked cards instead of a cramped table.
Reads cleanly at any phone/tablet width; no horizontal overflow. */
.fclk-ts-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.fclk-ts-card {
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
border-radius: 12px;
padding: 14px 16px;
}
.fclk-ts-card-top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.fclk-ts-card-date {
color: var(--fclk-text);
font-size: 14px;
font-weight: 700;
}
.fclk-ts-card-date span {
color: var(--fclk-text-dim);
font-weight: 400;
margin-left: 6px;
}
.fclk-ts-card-net {
color: var(--fclk-green);
font-weight: 700;
font-size: 15px;
white-space: nowrap;
}
.fclk-ts-card-times {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
color: var(--fclk-text);
font-size: 14px;
}
.fclk-ts-arrow {
color: var(--fclk-text-dim);
}
.fclk-ts-k {
color: var(--fclk-text-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-right: 4px;
}
.fclk-ts-card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
color: var(--fclk-text-muted);
font-size: 12px;
}
.fclk-ts-dot {
color: var(--fclk-text-dim);
}
.fclk-ts-correct {
margin-left: auto;
color: var(--fclk-text-muted);
font-size: 12px;
text-decoration: none;
}
.fclk-ts-correct:hover {
color: var(--fclk-green);
text-decoration: underline;
}
/* Leave request: From / To date-range row.
Grid (not flex) so it stays two columns on every width — iOS date inputs
have a large intrinsic min-width that can break a flex row; grid 1fr 1fr +
min-width:0 forces them to share the row and shrink. */
.fclk-leave-daterange {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.fclk-leave-daterange-col {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.fclk-leave-daterange-col .fclk-wizard-input {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.fclk-leave-daterange-cap {
font-size: 11px;
color: var(--fclk-text-muted);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.fclk-leave-daterange-cap small {
text-transform: none;
letter-spacing: 0;
color: var(--fclk-text-dim);
}
/* ---- Reports Page ---- */
.fclk-reports-container {
max-width: 600px;
@@ -1604,3 +1811,91 @@ html.o_dark #fclk-portal-fab {
width: 260px;
}
}
/* ============================================================
Employee portal — Payslips, 4-item nav, sign out
(uses the --fclk-* palette above, so light/dark just works)
============================================================ */
/* Keep 4 nav items comfortable on narrow phones */
.fclk-nav-bar .fclk-nav-item { min-width: 64px; }
/* Sign out (clock header, top-right) */
.fclk-header { position: relative; }
.fclk-signout {
position: absolute;
top: 0;
right: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 10px;
color: var(--fclk-text-muted);
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
text-decoration: none;
}
.fclk-signout:hover { color: var(--fclk-text); }
/* Payslip list rows (extend .fclk-report-item) */
.fclk-payslip-item { text-decoration: none; color: inherit; cursor: pointer; }
.fclk-payslip-status {
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 999px;
white-space: nowrap;
}
.fclk-payslip-status--paid { background: var(--fclk-green-glow); color: var(--fclk-green); }
.fclk-payslip-status--done { background: var(--fclk-hover-bg); color: var(--fclk-text-muted); }
/* Payslip detail (inline paystub) */
.fclk-payslip-detail-header .fclk-payslip-back {
display: inline-block;
font-size: 13px;
color: var(--fclk-green);
text-decoration: none;
margin-bottom: 6px;
}
.fclk-payslip-net {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.fclk-payslip-net-label { font-size: 13px; color: var(--fclk-text-muted); }
.fclk-payslip-net-value { font-size: 26px; font-weight: 700; color: var(--fclk-green); }
.fclk-payslip-section { margin-bottom: 16px; }
.fclk-payslip-section-title {
font-size: 13px;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--fclk-text-muted);
margin: 0 0 10px;
}
.fclk-payslip-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
font-size: 14px;
color: var(--fclk-text);
border-bottom: 1px solid var(--fclk-card-border);
}
.fclk-payslip-row:last-child { border-bottom: none; }
.fclk-payslip-row--total { font-weight: 700; }
.fclk-payslip-pdf-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 14px;
margin-bottom: 90px; /* clear the fixed bottom nav */
border-radius: 12px;
background: var(--fclk-green);
color: #fff;
font-weight: 600;
text-decoration: none;
}

View File

@@ -15,6 +15,20 @@
const photoRequired = root.dataset.photoRequired === "1";
const debugEnabled = root.dataset.debugEnabled === "1";
const locationConfigured = root.dataset.locationConfigured === "1";
const soundsEnabled = root.dataset.soundsEnabled === "1";
// On a known device (set up before) the browser already remembers camera/NFC
// permission, so slim the prompt to a simple resume tap.
try {
if (localStorage.getItem("nfc_setup_done") === "1") {
const _h2 = document.querySelector(".nfc-kiosk__setup h2");
const _p = document.querySelector(".nfc-kiosk__setup p");
const _btn = document.getElementById("nfc_setup_start");
if (_h2) _h2.textContent = "Fusion Clock Kiosk";
if (_p) _p.textContent = "Tap to resume.";
if (_btn) _btn.textContent = "Tap to resume";
}
} catch (e) {}
// ──────────────────────────────────────────────────────────────
// Debug overlay (visible only when fusion_clock.nfc_kiosk_debug = True)
@@ -140,8 +154,10 @@
stroke="currentColor" stroke-width="4" fill="none"/>
<circle class="nfc-wave nfc-wave-1" cx="100" cy="100" r="58"
stroke="currentColor" stroke-width="4" fill="none"/>
<rect class="nfc-chip" x="68" y="68" width="64" height="64"
rx="11" fill="currentColor"/>
<g class="nfc-chip" fill="none" stroke="currentColor" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="100" cy="100" r="34"/>
<polyline points="100,80 100,100 116,108"/>
</g>
</svg>
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
</div>
@@ -157,11 +173,67 @@
`;
}
// ──────────────────────────────────────────────────────────────
// Clock sounds (Web Audio — synthesized, loud + distinct in/out).
// AudioContext is created/resumed on the setup tap (a user gesture),
// after which it can play on each clock event.
// ──────────────────────────────────────────────────────────────
let _audioCtx = null;
function unlockAudio() {
try {
if (!_audioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) _audioCtx = new AC();
}
if (_audioCtx && _audioCtx.state === "suspended") _audioCtx.resume();
} catch (e) { debugLog("audio: unlock failed " + e.message); }
}
function _note(freq, startAt, dur, peak, type) {
const osc = _audioCtx.createOscillator();
const g = _audioCtx.createGain();
osc.type = type || "sine";
osc.frequency.setValueAtTime(freq, startAt);
g.gain.setValueAtTime(0.0001, startAt);
g.gain.exponentialRampToValueAtTime(peak, startAt + 0.015); // soft attack (no click)
g.gain.exponentialRampToValueAtTime(0.0001, startAt + dur); // smooth decay
osc.connect(g); g.connect(_audioCtx.destination);
osc.start(startAt); osc.stop(startAt + dur + 0.04);
}
function playClockSound(action) {
if (!soundsEnabled || !_audioCtx) return;
try {
if (_audioCtx.state === "suspended") _audioCtx.resume();
const t = _audioCtx.currentTime;
if (action === "clock_out") {
// warm descending major triad (GEC) — a pleasant "goodbye"
_note(783.99, t, 0.20, 0.6, "sine"); // G5
_note(659.25, t + 0.13, 0.20, 0.6, "sine"); // E5
_note(523.25, t + 0.26, 0.42, 0.7, "sine"); // C5
} else {
// bright ascending major triad (CEG) — a cheerful "welcome"
_note(523.25, t, 0.18, 0.6, "sine"); // C5
_note(659.25, t + 0.13, 0.18, 0.6, "sine"); // E5
_note(783.99, t + 0.26, 0.42, 0.72, "sine"); // G5
}
} catch (e) { debugLog("audio: play failed " + e.message); }
}
// Distinct low "denied" tone for wrong / unknown taps — clearly not a success chime.
function playErrorSound() {
if (!soundsEnabled || !_audioCtx) return;
try {
if (_audioCtx.state === "suspended") _audioCtx.resume();
const t = _audioCtx.currentTime;
_note(311.13, t, 0.20, 0.55, "triangle"); // Eb4
_note(207.65, t + 0.18, 0.36, 0.6, "triangle"); // Ab3 (low → "wrong")
} catch (e) { debugLog("audio: play failed " + e.message); }
}
function renderResult(payload) {
const isError = payload && payload.error;
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
if (isError) {
playErrorSound();
stateContainer.innerHTML = `
<div class="nfc-kiosk__result ${cls}">
<div class="nfc-kiosk__result-text">
@@ -169,25 +241,43 @@
</div>
</div>
`;
setTimeout(() => setState(STATE.IDLE), 4000);
setTimeout(() => setState(STATE.IDLE), 3000);
} else {
playClockSound(payload.action);
const avatar = payload.employee_avatar_url || "";
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
const hours = payload.action === "clock_out" && payload.net_hours_today
? `${payload.net_hours_today.toFixed(1)}h today`
: "";
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
let hoursLine = "";
if (payload.action === "clock_out") {
// Gross clock-in → clock-out time. Adaptive units so short shifts
// (and quick tests) don't all read "0h 0m".
const totalSec = Math.round((payload.worked_hours || 0) * 3600);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
let dur;
if (h > 0) dur = `${h}h ${m}m`;
else if (m > 0) dur = `${m}m`;
else dur = `${s}s`;
hoursLine = `<div class="hours">Worked ${dur} this shift</div>`;
}
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: true });
stateContainer.innerHTML = `
<div class="nfc-kiosk__result ${cls}">
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
<div class="nfc-kiosk__result-text">
<div class="name">${escapeHtml(payload.employee_name)}</div>
<div class="action">${action} at ${time}</div>
${hours ? `<div class="hours">${hours}</div>` : ""}
${hoursLine}
</div>
</div>
`;
setTimeout(() => setState(STATE.IDLE), 3000);
setTimeout(() => {
if (payload.action === "clock_in" && payload.needs_photo && payload.employee_id) {
openPhotoCapture(payload.employee_id, payload.employee_name, () => setState(STATE.IDLE));
} else {
setState(STATE.IDLE);
}
}, 1800);
}
}
@@ -196,6 +286,7 @@
// ──────────────────────────────────────────────────────────────
let enrollPassword = "";
let enrollSelectedEmployee = null;
let pendingEnrollUid = null; // set when programming a just-tapped unknown card
let enrollIdleTimer = null;
function resetEnrollIdleTimer() {
@@ -209,22 +300,22 @@
function exitEnrollMode() {
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
enrollIdleTimer = null;
enrollPassword = "";
if (kioskLocked) enrollPassword = ""; // keep the PIN while unlocked (no re-prompt)
enrollSelectedEmployee = null;
pendingEnrollUid = null;
setState(STATE.IDLE);
}
function renderEnroll(payload) {
const phase = (payload && payload.phase) || "password";
resetEnrollIdleTimer();
if (phase === "password") {
const masked = "•".repeat(enrollPassword.length);
// Fixed PIN pad: renders the panel ONCE, then mutates only the masked
// display on each press — no innerHTML rebuild, no replayed entrance
// animation. (Fixes the per-digit "screen refresh" bug.)
function mountPinPad(opts) {
let pin = "";
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Enter Enroll Mode Password</h2>
<div class="pin-display">${masked}</div>
<div class="nfc-kiosk__enroll-panel nfc-kiosk__enroll-panel--pin">
<h2>${escapeHtml(opts.title || "Enter PIN")}</h2>
<div class="pin-display" id="nfc_pin_display"></div>
<div class="numpad">
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
<button data-n="back">⌫</button>
@@ -232,37 +323,155 @@
<button data-n="ok">OK</button>
</div>
<div class="actions">
<button class="cancel" id="enroll_cancel">Cancel</button>
<button class="cancel" id="nfc_pin_cancel">Cancel</button>
</div>
</div>
</div>
`;
const displayEl = stateContainer.querySelector("#nfc_pin_display");
const paint = () => { displayEl.textContent = "•".repeat(pin.length); };
paint();
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
btn.addEventListener("click", async () => {
btn.addEventListener("click", () => {
resetEnrollIdleTimer();
const n = btn.dataset.n;
if (n === "back") enrollPassword = enrollPassword.slice(0, -1);
else if (n === "ok") {
if (enrollPassword.length === 0) return;
renderEnroll({ phase: "search" });
return;
if (n === "back") { pin = pin.slice(0, -1); paint(); }
else if (n === "ok") { if (pin.length) opts.onOk(pin); }
else { pin += n; paint(); }
});
});
stateContainer.querySelector("#nfc_pin_cancel").addEventListener("click", opts.onCancel);
}
else enrollPassword += n;
renderEnroll({ phase: "password" });
// Reactive flow: an unknown card was tapped — offer to program it now.
function renderUnknownCard(uid) {
playErrorSound();
currentState = STATE.RESULT; // block taps while this prompt is up
if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; }
const autoCancel = setTimeout(() => {
if (currentState === STATE.RESULT) setState(STATE.IDLE);
}, 8000);
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel nfc-kiosk__unknown" style="text-align:center">
<div class="unknown-icon">⚠</div>
<h2>This card isn't programmed yet</h2>
<p style="color:var(--nfc-text-muted)">Program it now, or ask a manager.</p>
<div class="actions" style="justify-content:center">
<button class="confirm" id="uc_program">Program this card</button>
<button class="cancel" id="uc_cancel">Cancel</button>
</div>
</div>
</div>
`;
stateContainer.querySelector("#uc_program").addEventListener("click", () => {
clearTimeout(autoCancel);
pendingEnrollUid = uid;
enrollSelectedEmployee = null;
// If a manager already unlocked, skip the PIN; otherwise ask for it.
setState(STATE.ENROLL, { phase: enrollPassword ? "employee" : "password" });
});
stateContainer.querySelector("#uc_cancel").addEventListener("click", () => {
clearTimeout(autoCancel);
setState(STATE.IDLE);
});
}
function renderEnroll(payload) {
const phase = (payload && payload.phase) || "password";
resetEnrollIdleTimer();
if (phase === "password") {
mountPinPad({
title: "Manager PIN",
onOk: (pin) => { enrollPassword = pin; renderEnroll({ phase: "employee" }); },
onCancel: exitEnrollMode,
});
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "search") {
if (phase === "manager") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Pick the employee to enroll</h2>
<h2>Manage employees</h2>
<input class="employee-search" id="mgr_search" placeholder="Search by name…" autocomplete="off"/>
<div class="employee-list" id="mgr_list"></div>
<div class="actions" style="justify-content:space-between">
<button class="confirm" id="mgr_new">+ New employee</button>
<button class="cancel" id="mgr_close">Close</button>
</div>
</div>
</div>
`;
const searchEl = document.getElementById("mgr_search");
const listEl = document.getElementById("mgr_list");
let confirmDeleteId = null;
let debounceTimer = null;
async function refresh() {
resetEnrollIdleTimer();
let emps = [];
try { emps = (await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value })).employees || []; }
catch (e) { listEl.innerHTML = `<div style="opacity:.6;padding:1rem">Connection error.</div>`; return; }
if (!emps.length) { listEl.innerHTML = `<div style="opacity:.6;padding:1rem">No employees found.</div>`; return; }
listEl.innerHTML = emps.map(e => {
const tag = e.card_uid
? `<span class="m-tag m-tag--on">● ${escapeHtml(e.card_uid)}</span>`
: `<span class="m-tag">○ no tag</span>`;
const actions = (confirmDeleteId === e.id)
? `<button class="m-btn m-danger" data-act="delok" data-id="${e.id}">Confirm delete</button>
<button class="m-btn" data-act="delno" data-id="${e.id}">Cancel</button>`
: `<button class="m-btn" data-act="assign" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${e.card_uid ? "Re-tag" : "Assign"}</button>
<button class="m-btn" data-act="photo" data-id="${e.id}" data-name="${escapeHtml(e.name)}">📷 Photo</button>
${e.card_uid ? `<button class="m-btn" data-act="clear" data-id="${e.id}">Clear tag</button>` : ""}
<button class="m-btn m-danger" data-act="del" data-id="${e.id}">Delete</button>`;
return `<div class="manager-row">
<div class="m-info"><span class="m-name">${escapeHtml(e.name)}</span><small class="m-dept">${escapeHtml(e.department || "")}</small> ${tag}</div>
<div class="m-actions">${actions}</div>
</div>`;
}).join("");
listEl.querySelectorAll(".m-btn").forEach(btn => btn.addEventListener("click", async () => {
resetEnrollIdleTimer();
const id = parseInt(btn.dataset.id, 10);
const act = btn.dataset.act;
if (act === "assign") {
enrollSelectedEmployee = { id, name: btn.dataset.name };
pendingEnrollUid = null;
renderEnroll({ phase: "tap" });
} else if (act === "photo") {
openPhotoCapture(id, btn.dataset.name, () => renderEnroll({ phase: "manager" }));
} else if (act === "clear") {
try { await postJson("/fusion_clock/kiosk/nfc/clear_tag", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {}
refresh();
} else if (act === "del") {
confirmDeleteId = id; refresh();
} else if (act === "delno") {
confirmDeleteId = null; refresh();
} else if (act === "delok") {
try { await postJson("/fusion_clock/kiosk/nfc/delete_employee", { employee_id: id, enroll_password: enrollPassword }); } catch (e) {}
confirmDeleteId = null; refresh();
}
}));
}
searchEl.addEventListener("input", () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(refresh, 200);
});
document.getElementById("mgr_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
document.getElementById("mgr_close").addEventListener("click", exitEnrollMode);
refresh();
return;
}
if (phase === "employee") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>Who is this card for?</h2>
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
<div class="employee-list" id="enroll_list"></div>
<div class="actions">
<div class="actions" style="justify-content:space-between">
<button class="confirm" id="enroll_new">+ New employee</button>
<button class="cancel" id="enroll_cancel">Cancel</button>
</div>
</div>
@@ -281,17 +490,61 @@
).join("");
listEl.querySelectorAll(".employee-row").forEach(row => {
row.addEventListener("click", () => {
enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
renderEnroll({ phase: "tap" });
chooseEmployee({ id: parseInt(row.dataset.id, 10), name: row.dataset.name });
});
});
}, 200);
});
searchEl.focus();
document.getElementById("enroll_new").addEventListener("click", () => renderEnroll({ phase: "new_employee" }));
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
return;
}
if (phase === "new_employee") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel">
<h2>New employee</h2>
<input class="employee-search" id="new_emp_name" placeholder="Full name…" autocomplete="off"/>
<div class="enroll-msg" id="new_emp_msg"></div>
<div class="actions" style="justify-content:space-between">
<button class="cancel" id="new_emp_back">Back</button>
<button class="confirm" id="new_emp_create">Create &amp; assign</button>
</div>
</div>
</div>
`;
const nameEl = document.getElementById("new_emp_name");
const msgEl = document.getElementById("new_emp_msg");
nameEl.addEventListener("input", resetEnrollIdleTimer);
nameEl.focus();
const doCreate = async () => {
resetEnrollIdleTimer();
const nm = nameEl.value.trim();
if (nm.length < 2) { msgEl.textContent = "Enter the employee's full name."; return; }
msgEl.textContent = "Creating…";
let res;
try {
res = await postJson("/fusion_clock/kiosk/nfc/create_employee", { name: nm, enroll_password: enrollPassword });
} catch (e) {
msgEl.textContent = "No connection. Try again.";
return;
}
if (res.error) {
msgEl.textContent = res.error === "invalid_password" ? "Wrong Manager PIN."
: res.error === "invalid_name" ? "Enter a valid name."
: "Could not create employee.";
return;
}
chooseEmployee({ id: res.employee_id, name: res.employee_name });
};
document.getElementById("new_emp_create").addEventListener("click", doCreate);
nameEl.addEventListener("keydown", (e) => { if (e.key === "Enter") doCreate(); });
document.getElementById("new_emp_back").addEventListener("click", () => renderEnroll({ phase: "employee" }));
return;
}
if (phase === "tap") {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
@@ -312,52 +565,163 @@
if (phase === "result") {
const ok = !payload.error;
const msg = ok
? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}`
? `✓ Card enrolled to ${escapeHtml(payload.employee_name)}`
: (payload.error === "invalid_password"
? "Wrong password. Try again."
? "Wrong Manager PIN. Try again."
: payload.error === "card_already_assigned"
? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
: `Enroll failed: ${escapeHtml(payload.error)}`);
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
<h2 style="color:${ok ? "var(--nfc-success)" : "var(--nfc-error)"}">${msg}</h2>
<div class="actions" style="justify-content:center">
${ok && payload.employee_id ? `<button class="confirm" id="enroll_photo">📷 Take photo</button>` : ""}
<button class="confirm" id="enroll_another">Enroll another</button>
<button class="cancel" id="enroll_done">Done</button>
</div>
</div>
</div>
`;
if (ok && payload.employee_id) {
document.getElementById("enroll_photo").addEventListener("click", () => {
openPhotoCapture(payload.employee_id, payload.employee_name, () => {
if (enrollPassword) renderEnroll({ phase: "manager" }); else exitEnrollMode();
});
});
}
document.getElementById("enroll_another").addEventListener("click", () => {
enrollSelectedEmployee = null;
renderEnroll({ phase: ok ? "search" : "password" });
pendingEnrollUid = null;
renderEnroll({ phase: ok ? "employee" : "password" });
});
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
}
}
async function _onEnrollTap(uid) {
if (!enrollSelectedEmployee) return;
const result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
employee_id: enrollSelectedEmployee.id,
card_uid: uid,
enroll_password: enrollPassword,
});
renderEnroll({ phase: "result", ...result });
// Existing employee picked → if we already hold a tapped UID, bind it now
// (no re-tap); otherwise fall back to the proactive ⚙ "tap the card" step.
function chooseEmployee(emp) {
if (pendingEnrollUid) {
doEnroll(emp.id, emp.name, pendingEnrollUid, false);
} else {
enrollSelectedEmployee = emp;
renderEnroll({ phase: "tap" });
}
}
// ⚙ button → enter Enroll Mode
// Single enroll path (program flow, ⚙ tap flow, manager re-tag). A card already
// held by someone else triggers a reassign confirm rather than a hard error.
async function doEnroll(empId, empName, uid, force) {
resetEnrollIdleTimer();
let result;
try {
result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
employee_id: empId, card_uid: uid, enroll_password: enrollPassword, force: !!force,
});
} catch (e) {
renderEnroll({ phase: "result", employee_name: empName, error: "network" });
return;
}
if (result.error === "card_already_assigned" && !force) {
renderReassignConfirm(empId, empName, uid, result.existing_employee);
return;
}
renderEnroll({ phase: "result", employee_name: empName, ...result });
}
function renderReassignConfirm(empId, empName, uid, existingName) {
resetEnrollIdleTimer();
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
<h2>Reassign card?</h2>
<p style="color:var(--nfc-text-muted)">This card belongs to <b>${escapeHtml(existingName || "another employee")}</b>. Move it to <b>${escapeHtml(empName)}</b>?</p>
<div class="actions" style="justify-content:center">
<button class="cancel" id="ra_cancel">Cancel</button>
<button class="confirm" id="ra_move">Move</button>
</div>
</div>
</div>
`;
stateContainer.querySelector("#ra_move").addEventListener("click", () => doEnroll(empId, empName, uid, true));
stateContainer.querySelector("#ra_cancel").addEventListener("click", () => renderEnroll({ phase: "manager" }));
}
async function _onEnrollTap(uid) {
if (!enrollSelectedEmployee) return;
doEnroll(enrollSelectedEmployee.id, enrollSelectedEmployee.name, uid, false);
}
// ⚙ button → enter Enroll Mode (only when unlocked)
const settingsBtn = document.getElementById("nfc_settings_btn");
if (settingsBtn) {
settingsBtn.addEventListener("click", () => {
if (currentState !== STATE.IDLE) return;
enrollPassword = "";
if (kioskLocked || currentState !== STATE.IDLE) return;
enrollSelectedEmployee = null;
setState(STATE.ENROLL, { phase: "password" });
pendingEnrollUid = null;
// Already unlocked → reuse that PIN, open the manager page (no re-prompt).
setState(STATE.ENROLL, { phase: "manager" });
});
}
// ──────────────────────────────────────────────────────────────
// Screen lock — the kiosk starts LOCKED: only card taps work, ⚙ hidden.
// A manager long-presses the bottom-right corner and enters the Manager
// PIN to unlock (revealing ⚙ + 🔒). Re-locks via 🔒, on reload, or after
// inactivity. Unlock is in-memory only, so every reload starts locked.
// ──────────────────────────────────────────────────────────────
let kioskLocked = true;
let relockTimer = null;
const lockBtn = document.getElementById("nfc_lock_btn");
const unlockBtn = document.getElementById("nfc_unlock_btn");
function applyLockState() {
if (settingsBtn) settingsBtn.style.display = kioskLocked ? "none" : "flex";
if (lockBtn) lockBtn.style.display = kioskLocked ? "none" : "flex";
if (unlockBtn) unlockBtn.style.display = kioskLocked ? "flex" : "none";
}
function armRelock() {
if (relockTimer) clearTimeout(relockTimer);
relockTimer = setTimeout(() => {
relockTimer = null;
if (currentState === STATE.IDLE) lockKiosk();
else armRelock(); // don't re-lock mid-enroll
}, 120000);
}
function lockKiosk() {
kioskLocked = true;
enrollPassword = ""; // forget the PIN → unlocking requires it again
if (relockTimer) { clearTimeout(relockTimer); relockTimer = null; }
applyLockState();
}
function unlockKiosk() {
kioskLocked = false;
applyLockState();
armRelock();
}
if (lockBtn) lockBtn.addEventListener("click", lockKiosk);
function openUnlockPin() {
currentState = STATE.RESULT; // block card taps during PIN entry
mountPinPad({
title: "Manager PIN — unlock",
onOk: async (pin) => {
let ok = false;
try { ok = (await postJson("/fusion_clock/kiosk/nfc/verify_pin", { pin })).ok; } catch (e) {}
if (ok) { enrollPassword = pin; unlockKiosk(); setState(STATE.IDLE); }
else { const d = document.getElementById("nfc_pin_display"); if (d) d.textContent = "✕ wrong"; }
},
onCancel: () => setState(STATE.IDLE),
});
}
if (unlockBtn) unlockBtn.addEventListener("click", () => {
if (kioskLocked && currentState === STATE.IDLE) openUnlockPin();
});
applyLockState(); // start locked
function escapeHtml(s) {
return String(s || "").replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
@@ -387,6 +751,9 @@
updateClock();
setInterval(updateClock, 1000);
// Keep-alive: refresh the session every 4 min so the kiosk login never expires.
setInterval(() => { postJson("/fusion_clock/get_settings", {}).catch(() => {}); }, 240000);
// ──────────────────────────────────────────────────────────────
// Setup wizard
// ──────────────────────────────────────────────────────────────
@@ -577,6 +944,10 @@
setState(STATE.IDLE);
return;
}
if (result.error === "card_unknown") {
renderUnknownCard(uid);
return;
}
setState(STATE.RESULT, result);
} catch (e) {
debugLog("handleTap: POST failed: " + e.message);
@@ -624,6 +995,118 @@
return canvasEl.toDataURL("image/jpeg", 0.7);
}
// ──────────────────────────────────────────────────────────────
// Guided profile-photo capture (for employees with no picture).
// Live camera + oval face guide → Capture → preview → Use/Retake →
// saves a centered 512² square to the employee's profile image.
// ──────────────────────────────────────────────────────────────
function _captureProfileSquare(video) {
const vw = video.videoWidth, vh = video.videoHeight;
if (!vw || !vh) return "";
const side = Math.min(vw, vh);
const sx = (vw - side) / 2, sy = (vh - side) / 2;
const c = document.createElement("canvas");
c.width = 512; c.height = 512;
c.getContext("2d").drawImage(video, sx, sy, side, side, 0, 0, 512, 512);
return c.toDataURL("image/jpeg", 0.85);
}
function openPhotoCapture(employeeId, employeeName, onDone) {
currentState = STATE.RESULT; // block card taps during capture
if (enrollIdleTimer) { clearTimeout(enrollIdleTimer); enrollIdleTimer = null; }
const finish = () => { if (onDone) onDone(); };
if (!cameraStream) { finish(); return; } // no camera → skip silently
let captured = "";
const renderPreview = () => {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__photo-panel">
<h2>Use this photo?</h2>
<div class="nfc-photo-stage">
<img class="nfc-photo-preview" src="${captured}"/>
<div class="nfc-photo-guide"></div>
</div>
<div class="actions" style="justify-content:space-between">
<button class="cancel" id="photo_retake">Retake</button>
<button class="confirm" id="photo_use">Use photo</button>
</div>
</div>
</div>
`;
document.getElementById("photo_retake").addEventListener("click", renderLive);
document.getElementById("photo_use").addEventListener("click", async () => {
try { await postJson("/fusion_clock/kiosk/nfc/save_profile_photo", { employee_id: employeeId, photo_b64: captured }); } catch (e) {}
finish();
});
};
function renderLive() {
stateContainer.innerHTML = `
<div class="nfc-kiosk__enroll-overlay">
<div class="nfc-kiosk__photo-panel">
<h2>Take ${escapeHtml(employeeName)}'s photo</h2>
<div class="nfc-photo-stage" id="photo_stage">
<div class="nfc-photo-guide"></div>
<div class="nfc-photo-countdown" id="photo_countdown"></div>
<div class="nfc-photo-hint">Center the face in the oval</div>
</div>
<div class="actions" style="justify-content:space-between">
<button class="cancel" id="photo_skip">Skip</button>
<button class="confirm" id="photo_capture">Capture</button>
</div>
</div>
</div>
`;
const stage = document.getElementById("photo_stage");
const v = document.createElement("video");
v.autoplay = true; v.muted = true; v.playsInline = true;
v.className = "nfc-photo-video";
v.srcObject = cameraStream;
stage.insertBefore(v, stage.firstChild);
v.play().catch(() => {});
const captureBtn = document.getElementById("photo_capture");
const skipBtn = document.getElementById("photo_skip");
const cdEl = document.getElementById("photo_countdown");
const hintEl = stage.querySelector(".nfc-photo-hint");
let cdTimer = null;
const doCapture = () => {
captured = _captureProfileSquare(v);
if (captured) renderPreview(); else finish();
};
// Click Capture → 10s countdown (time to get into frame) → auto-snap.
// While counting, the button becomes Cancel (cdTimer also acts as the flag).
captureBtn.addEventListener("click", () => {
if (cdTimer) {
clearInterval(cdTimer); cdTimer = null;
cdEl.classList.remove("is-active"); cdEl.textContent = "";
if (hintEl) hintEl.textContent = "Center the face in the oval";
captureBtn.textContent = "Capture";
return;
}
let n = 10;
captureBtn.textContent = "Cancel";
if (hintEl) hintEl.textContent = "Get ready…";
cdEl.textContent = n;
cdEl.classList.add("is-active");
cdTimer = setInterval(() => {
n -= 1;
if (n <= 0) {
clearInterval(cdTimer); cdTimer = null;
cdEl.classList.remove("is-active"); cdEl.textContent = "";
doCapture();
return;
}
cdEl.textContent = n;
}, 1000);
});
skipBtn.addEventListener("click", () => {
if (cdTimer) { clearInterval(cdTimer); cdTimer = null; }
finish();
});
}
renderLive();
}
// ──────────────────────────────────────────────────────────────
// Wake Lock — keeps the screen on while the kiosk page is active.
// Released automatically on tab close/navigation; re-acquired on
@@ -668,6 +1151,7 @@
if (setupBtn) {
setupBtn.addEventListener("click", async () => {
debugLog("setup button clicked");
unlockAudio(); // user gesture → unlock Web Audio for clock sounds
// Try Web NFC, but don't fail if absent — USB HID reader is a
// first-class alternative (works on desktops/iOS too).
let webNfcOk = false;
@@ -704,6 +1188,7 @@
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
}
await acquireWakeLock();
try { localStorage.setItem("nfc_setup_done", "1"); } catch (e) {}
setState(STATE.IDLE);
debugLog("setup: IDLE — Web NFC: " + (webNfcOk ? "✓" : "✗") + " · USB HID: ✓");
});

Some files were not shown because too many files have changed in this diff Show More