From d4dbca5927348dab6fffd2a276dc03de2304903d Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 12:27:44 -0400 Subject: [PATCH] feat(fusion_accounting_bank_rec): OWL bank reconciliation service Central data layer + reactive state for the OWL widget. Wraps the 10 JSON-RPC endpoints from the bank_rec_controller (get_state, list_unreconciled, get_line_detail, suggest_matches, accept_suggestion, reconcile_manual, unreconcile, write_off, bulk_reconcile, get_partner_history). Components inject via useService("fusion_bank_reconciliation"). State held in OWL's reactive() so components auto-rerender on selection / pagination / reconcile-success changes. Verified: web.assets_backend bundle includes /fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js; 134/134 module tests pass. Made-with: Cursor --- fusion_accounting_bank_rec/__manifest__.py | 3 +- .../services/bank_reconciliation_service.js | 278 ++++++++++++++++++ 2 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js diff --git a/fusion_accounting_bank_rec/__manifest__.py b/fusion_accounting_bank_rec/__manifest__.py index 1dd147af..8304451c 100644 --- a/fusion_accounting_bank_rec/__manifest__.py +++ b/fusion_accounting_bank_rec/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting — Bank Reconciliation', - 'version': '19.0.1.0.9', + 'version': '19.0.1.0.10', 'category': 'Accounting/Accounting', 'sequence': 28, 'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.', @@ -38,6 +38,7 @@ Built by Nexa Systems Inc. 'fusion_accounting_bank_rec/static/src/scss/bank_reconciliation.scss', 'fusion_accounting_bank_rec/static/src/scss/ai_suggestion.scss', 'fusion_accounting_bank_rec/static/src/scss/dark_mode.scss', + 'fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js', ], }, 'installable': True, diff --git a/fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js b/fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js new file mode 100644 index 00000000..8f15e5d3 --- /dev/null +++ b/fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js @@ -0,0 +1,278 @@ +/** @odoo-module **/ + +/** + * Bank reconciliation service. + * + * Central data layer + reactive state for the OWL bank-rec widget. + * Components inject this service via useService("fusion_bank_reconciliation") + * and read/write state through its methods. + * + * Wraps the 10 JSON-RPC endpoints from controllers/bank_rec_controller.py. + */ + +import { registry } from "@web/core/registry"; +import { reactive } from "@odoo/owl"; + +const ENDPOINT_BASE = "/fusion/bank_rec"; + +export class BankReconciliationService { + constructor(env, services) { + this.env = env; + this.rpc = services.rpc; + this.notification = services.notification; + + // Reactive state — components depend on it via useState/reactive + this.state = reactive({ + journalId: null, + companyId: null, + unreconciledCount: 0, + totalPendingAmount: 0, + lines: [], + lineCache: {}, // {lineId: {detail, suggestions, attachments}} + selectedLineId: null, + isLoading: false, + isReconciling: false, + offset: 0, + limit: 50, + filters: {}, + // Cache of recently-applied actions for optimistic UI + recentActions: [], + }); + } + + // ============================================================ + // Initialization + // ============================================================ + + async initForJournal(journalId, companyId) { + this.state.journalId = journalId; + this.state.companyId = companyId; + this.state.isLoading = true; + try { + const stateInfo = await this.rpc(`${ENDPOINT_BASE}/get_state`, { + journal_id: journalId, company_id: companyId, + }); + this.state.unreconciledCount = stateInfo.unreconciled_count; + this.state.totalPendingAmount = stateInfo.total_pending_amount; + await this.loadLines({ reset: true }); + } finally { + this.state.isLoading = false; + } + } + + // ============================================================ + // List + pagination + // ============================================================ + + async loadLines({ reset = false } = {}) { + if (reset) { + this.state.offset = 0; + this.state.lines = []; + } + this.state.isLoading = true; + try { + const result = await this.rpc(`${ENDPOINT_BASE}/list_unreconciled`, { + journal_id: this.state.journalId, + company_id: this.state.companyId, + limit: this.state.limit, + offset: this.state.offset, + ...this.state.filters, + }); + if (reset) { + this.state.lines = result.lines; + } else { + this.state.lines = [...this.state.lines, ...result.lines]; + } + this.state.unreconciledCount = result.total; + } finally { + this.state.isLoading = false; + } + } + + async loadMore() { + this.state.offset += this.state.limit; + await this.loadLines({ reset: false }); + } + + setFilter(key, value) { + if (value === null || value === undefined || value === "") { + delete this.state.filters[key]; + } else { + this.state.filters[key] = value; + } + this.loadLines({ reset: true }); + } + + // ============================================================ + // Line detail + suggestions + // ============================================================ + + async selectLine(lineId) { + this.state.selectedLineId = lineId; + if (!this.state.lineCache[lineId]) { + await this.loadLineDetail(lineId); + } + } + + async loadLineDetail(lineId) { + const detail = await this.rpc(`${ENDPOINT_BASE}/get_line_detail`, { + statement_line_id: lineId, + }); + this.state.lineCache[lineId] = detail; + return detail; + } + + async refreshLineDetail(lineId) { + delete this.state.lineCache[lineId]; + return await this.loadLineDetail(lineId); + } + + async suggestMatches(lineIds, limitPerLine = 3) { + const result = await this.rpc(`${ENDPOINT_BASE}/suggest_matches`, { + statement_line_ids: lineIds, + limit_per_line: limitPerLine, + }); + // Refresh cache for each line + for (const lineId of lineIds) { + await this.refreshLineDetail(lineId); + } + return result.suggestions; + } + + // ============================================================ + // Reconciliation actions + // ============================================================ + + async acceptSuggestion(suggestionId) { + this.state.isReconciling = true; + try { + const result = await this.rpc(`${ENDPOINT_BASE}/accept_suggestion`, { + suggestion_id: suggestionId, + }); + this.state.unreconciledCount = result.unreconciled_count_after; + // Optimistic remove from list + this._removeReconciledLineFromState(this.state.selectedLineId); + this.notification.add("Reconciliation accepted", { type: "success" }); + return result; + } catch (err) { + this.notification.add(`Accept failed: ${err.message || err}`, { type: "danger" }); + throw err; + } finally { + this.state.isReconciling = false; + } + } + + async reconcileManual(statementLineId, againstMoveLineIds) { + this.state.isReconciling = true; + try { + const result = await this.rpc(`${ENDPOINT_BASE}/reconcile_manual`, { + statement_line_id: statementLineId, + against_move_line_ids: againstMoveLineIds, + }); + this._removeReconciledLineFromState(statementLineId); + this.notification.add("Reconciled", { type: "success" }); + return result; + } catch (err) { + this.notification.add(`Reconcile failed: ${err.message || err}`, { type: "danger" }); + throw err; + } finally { + this.state.isReconciling = false; + } + } + + async unreconcile(partialReconcileIds) { + try { + const result = await this.rpc(`${ENDPOINT_BASE}/unreconcile`, { + partial_reconcile_ids: partialReconcileIds, + }); + // Reload list since unreconciled lines come back + await this.loadLines({ reset: true }); + this.notification.add("Unreconciled", { type: "info" }); + return result; + } catch (err) { + this.notification.add(`Unreconcile failed: ${err.message || err}`, { type: "danger" }); + throw err; + } + } + + async writeOff({ statementLineId, accountId, amount, label, taxId = null }) { + this.state.isReconciling = true; + try { + const result = await this.rpc(`${ENDPOINT_BASE}/write_off`, { + statement_line_id: statementLineId, + account_id: accountId, + amount: amount, + label: label, + tax_id: taxId, + }); + this._removeReconciledLineFromState(statementLineId); + this.notification.add("Write-off applied", { type: "success" }); + return result; + } catch (err) { + this.notification.add(`Write-off failed: ${err.message || err}`, { type: "danger" }); + throw err; + } finally { + this.state.isReconciling = false; + } + } + + async bulkReconcile(statementLineIds, strategy = "auto") { + this.state.isReconciling = true; + try { + const result = await this.rpc(`${ENDPOINT_BASE}/bulk_reconcile`, { + statement_line_ids: statementLineIds, + strategy: strategy, + }); + await this.loadLines({ reset: true }); + const msg = `${result.reconciled_count} reconciled, ${result.skipped} skipped`; + this.notification.add(msg, { type: "success" }); + return result; + } catch (err) { + this.notification.add(`Bulk failed: ${err.message || err}`, { type: "danger" }); + throw err; + } finally { + this.state.isReconciling = false; + } + } + + // ============================================================ + // Partner history (right-side panel) + // ============================================================ + + async getPartnerHistory(partnerId, limit = 20) { + return await this.rpc(`${ENDPOINT_BASE}/get_partner_history`, { + partner_id: partnerId, + limit: limit, + }); + } + + // ============================================================ + // Helpers + // ============================================================ + + _removeReconciledLineFromState(lineId) { + if (!lineId) return; + this.state.lines = this.state.lines.filter((l) => l.id !== lineId); + if (this.state.selectedLineId === lineId) { + this.state.selectedLineId = null; + } + delete this.state.lineCache[lineId]; + if (this.state.unreconciledCount > 0) { + this.state.unreconciledCount -= 1; + } + } + + // Confidence band helper for templates + getBandClass(line) { + return `band-${line.fusion_confidence_band || "none"}`; + } +} + +export const bankReconciliationService = { + dependencies: ["rpc", "notification"], + start(env, services) { + return new BankReconciliationService(env, services); + }, +}; + +registry.category("services").add("fusion_bank_reconciliation", bankReconciliationService);