From 0a76a2b9aae1f6cc99e4e8ab3bb68d86f3374672 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 3 Apr 2026 21:59:50 +0200 Subject: [PATCH] =?UTF-8?q?Account=20module=20massive=20expansion:=202499?= =?UTF-8?q?=E2=86=925049=20LOC=20(+2550)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New models (12): - account.asset: depreciation (linear/degressive), journal entry generation - account.edi.format + account.edi.document: UBL 2.1 XML e-invoicing - account.followup.line: payment follow-up escalation levels - account.reconcile.model + lines: automatic bank reconciliation rules - crossovered.budget + lines + account.budget.post: budgeting system - account.cash.rounding: invoice rounding (UP/DOWN/HALF-UP) - account.payment.method + lines: payment method definitions - account.invoice.send: invoice sending wizard Enhanced existing: - account.move: action_reverse (credit notes), access_url, invoice_has_outstanding - account.move.line: tax_tag_ids, analytic_distribution, date_maturity, matching_number - Entry hash chain integrity (SHA-256, secure_sequence_number) - Report HTML rendering for all 6 report types - res.partner extended with followup status + overdue tracking Co-Authored-By: Claude Opus 4.6 (1M context) --- addons/account/models/account_asset.go | 499 ++++++++++++++++++ addons/account/models/account_budget.go | 226 ++++++++ .../account/models/account_cash_rounding.go | 159 ++++++ addons/account/models/account_edi.go | 365 +++++++++++++ addons/account/models/account_followup.go | 318 +++++++++++ addons/account/models/account_lock.go | 285 ++++++++++ addons/account/models/account_move.go | 224 ++++++++ .../account/models/account_payment_method.go | 58 ++ .../account/models/account_reconcile_model.go | 298 +++++++++++ addons/account/models/account_report_html.go | 129 +++++ addons/account/models/init.go | 11 + 11 files changed, 2572 insertions(+) create mode 100644 addons/account/models/account_asset.go create mode 100644 addons/account/models/account_budget.go create mode 100644 addons/account/models/account_cash_rounding.go create mode 100644 addons/account/models/account_edi.go create mode 100644 addons/account/models/account_followup.go create mode 100644 addons/account/models/account_lock.go create mode 100644 addons/account/models/account_payment_method.go create mode 100644 addons/account/models/account_reconcile_model.go create mode 100644 addons/account/models/account_report_html.go diff --git a/addons/account/models/account_asset.go b/addons/account/models/account_asset.go new file mode 100644 index 0000000..0a74db3 --- /dev/null +++ b/addons/account/models/account_asset.go @@ -0,0 +1,499 @@ +package models + +import ( + "fmt" + "math" + "time" + + "odoo-go/pkg/orm" +) + +// initAccountAsset registers account.asset — fixed asset management with depreciation. +// Mirrors: odoo/addons/account_asset/models/account_asset.py +// +// An asset represents a fixed asset (equipment, vehicle, building, etc.) that is +// depreciated over time. This model tracks the original value, current book value, +// and generates depreciation entries according to the chosen computation method. +func initAccountAsset() { + m := orm.NewModel("account.asset", orm.ModelOpts{ + Description: "Asset", + Order: "acquisition_date desc, id desc", + }) + + // -- Identity -- + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Asset Name", Required: true}), + orm.Selection("asset_type", []orm.SelectionItem{ + {Value: "purchase", Label: "Asset"}, + {Value: "sale", Label: "Deferred Revenue"}, + {Value: "expense", Label: "Deferred Expense"}, + }, orm.FieldOpts{String: "Asset Type", Default: "purchase", Required: true}), + ) + + // -- Accounts -- + m.AddFields( + orm.Many2one("account_asset_id", "account.account", orm.FieldOpts{ + String: "Asset Account", Help: "Account used to record the purchase of the asset", + }), + orm.Many2one("account_depreciation_id", "account.account", orm.FieldOpts{ + String: "Depreciation Account", Help: "Account used in depreciation entries for the depreciation amount", + }), + orm.Many2one("account_depreciation_expense_id", "account.account", orm.FieldOpts{ + String: "Expense Account", Help: "Account used in depreciation entries for the expense", + }), + orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), + ) + + // -- Values -- + m.AddFields( + orm.Monetary("original_value", orm.FieldOpts{String: "Original Value", CurrencyField: "currency_id"}), + orm.Monetary("book_value", orm.FieldOpts{String: "Book Value", Compute: "_compute_book_value", Store: true, CurrencyField: "currency_id"}), + orm.Monetary("salvage_value", orm.FieldOpts{String: "Not Depreciable Value", CurrencyField: "currency_id", Default: 0.0}), + orm.Monetary("value_residual", orm.FieldOpts{String: "Depreciable Value", Compute: "_compute_value_residual", Store: true, CurrencyField: "currency_id"}), + orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), + ) + + // -- Dates -- + m.AddFields( + orm.Date("acquisition_date", orm.FieldOpts{String: "Acquisition Date"}), + orm.Date("prorata_date", orm.FieldOpts{String: "Prorata Date", Help: "Prorata temporis start date for first depreciation"}), + orm.Boolean("prorata_computation_type", orm.FieldOpts{String: "Prorata Computation", Default: false}), + ) + + // -- Depreciation Method -- + m.AddFields( + orm.Selection("method", []orm.SelectionItem{ + {Value: "linear", Label: "Straight Line"}, + {Value: "degressive", Label: "Declining"}, + {Value: "degressive_then_linear", Label: "Declining then Straight Line"}, + }, orm.FieldOpts{String: "Computation Method", Default: "linear", Required: true}), + orm.Integer("method_number", orm.FieldOpts{String: "Number of Depreciations", Default: 5}), + orm.Selection("method_period", []orm.SelectionItem{ + {Value: "1", Label: "Monthly"}, + {Value: "12", Label: "Yearly"}, + }, orm.FieldOpts{String: "Duration", Default: "12"}), + orm.Float("method_progress_factor", orm.FieldOpts{String: "Declining Factor", Default: 0.3}), + ) + + // -- Status -- + m.AddFields( + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "Draft"}, + {Value: "open", Label: "Running"}, + {Value: "paused", Label: "On Hold"}, + {Value: "close", Label: "Closed"}, + }, orm.FieldOpts{String: "Status", Default: "draft", Required: true}), + orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), + ) + + // -- Related Entries -- + m.AddFields( + orm.Many2one("original_move_line_ids", "account.move.line", orm.FieldOpts{ + String: "Original Journal Item", + }), + orm.One2many("depreciation_move_ids", "account.move", "asset_id", orm.FieldOpts{ + String: "Depreciation Entries", + }), + orm.Integer("depreciation_entries_count", orm.FieldOpts{ + String: "Depreciation Entry Count", Compute: "_compute_depreciation_entries_count", + }), + ) + + // -- Computed fields -- + + // _compute_book_value: original - posted depreciation + salvage + m.RegisterCompute("book_value", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + assetID := rs.IDs()[0] + + var originalValue, salvageValue float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(original_value::float8, 0), COALESCE(salvage_value::float8, 0) + FROM account_asset WHERE id = $1`, assetID, + ).Scan(&originalValue, &salvageValue) + + var totalDepreciated float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(ABS(l.balance)::float8), 0) + FROM account_move m + JOIN account_move_line l ON l.move_id = m.id + WHERE m.asset_id = $1 AND m.state = 'posted' + AND l.account_id = (SELECT account_depreciation_expense_id FROM account_asset WHERE id = $1)`, + assetID, + ).Scan(&totalDepreciated) + + bookValue := originalValue - totalDepreciated + return orm.Values{"book_value": bookValue}, nil + }) + + // _compute_value_residual: depreciable = original - salvage + m.RegisterCompute("value_residual", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + assetID := rs.IDs()[0] + + var originalValue, salvageValue float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(original_value::float8, 0), COALESCE(salvage_value::float8, 0) + FROM account_asset WHERE id = $1`, assetID, + ).Scan(&originalValue, &salvageValue) + + return orm.Values{"value_residual": originalValue - salvageValue}, nil + }) + + // _compute_depreciation_entries_count + m.RegisterCompute("depreciation_entries_count", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + assetID := rs.IDs()[0] + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID, + ).Scan(&count) + return orm.Values{"depreciation_entries_count": count}, nil + }) + + // -- Business Methods -- + + // action_set_to_running: draft -> open + // Mirrors: odoo/addons/account_asset/models/account_asset.py validate() + m.RegisterMethod("action_set_to_running", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + var state string + env.Tx().QueryRow(env.Ctx(), + `SELECT state FROM account_asset WHERE id = $1`, id).Scan(&state) + if state != "draft" { + return nil, fmt.Errorf("account: can only confirm draft assets (current: %s)", state) + } + env.Tx().Exec(env.Ctx(), + `UPDATE account_asset SET state = 'open' WHERE id = $1`, id) + } + return true, nil + }) + + // action_set_to_close: open -> close + // Mirrors: odoo/addons/account_asset/models/account_asset.py set_to_close() + m.RegisterMethod("action_set_to_close", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE account_asset SET state = 'close' WHERE id = $1`, id) + } + return true, nil + }) + + // action_pause: open -> paused + m.RegisterMethod("action_pause", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE account_asset SET state = 'paused' WHERE id = $1 AND state = 'open'`, id) + } + return true, nil + }) + + // action_resume: paused -> open + m.RegisterMethod("action_resume", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE account_asset SET state = 'open' WHERE id = $1 AND state = 'paused'`, id) + } + return true, nil + }) + + // action_set_to_draft: revert to draft + m.RegisterMethod("action_set_to_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE account_asset SET state = 'draft' WHERE id = $1`, id) + } + return true, nil + }) + + // action_compute_depreciation: compute the depreciation schedule. + // Mirrors: odoo/addons/account_asset/models/account_asset.py compute_depreciation_board() + // + // Supports linear (straight-line) and degressive (declining balance) methods. + // Returns the computed schedule as a list of periods with amounts. + m.RegisterMethod("action_compute_depreciation", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + assetID := rs.IDs()[0] + + var originalValue, salvageValue, progressFactor float64 + var method, methodPeriod string + var numPeriods int + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(original_value::float8, 0), COALESCE(salvage_value::float8, 0), + COALESCE(method, 'linear'), COALESCE(method_number, 5), + COALESCE(method_period, '12'), COALESCE(method_progress_factor, 0.3) + FROM account_asset WHERE id = $1`, assetID, + ).Scan(&originalValue, &salvageValue, &method, &numPeriods, &methodPeriod, &progressFactor) + if err != nil { + return nil, fmt.Errorf("account: read asset %d: %w", assetID, err) + } + + depreciableValue := originalValue - salvageValue + if numPeriods <= 0 { + numPeriods = 1 + } + if depreciableValue <= 0 { + return map[string]interface{}{"schedule": []map[string]interface{}{}}, nil + } + + schedule := make([]map[string]interface{}, 0, numPeriods) + remaining := depreciableValue + + // Determine period length in months + periodMonths := 12 + if methodPeriod == "1" { + periodMonths = 1 + } + + startDate := time.Now() + + switch method { + case "linear": + periodicAmount := depreciableValue / float64(numPeriods) + for i := 0; i < numPeriods; i++ { + amt := periodicAmount + if i == numPeriods-1 { + amt = remaining // last period gets remainder to avoid rounding + } + remaining -= amt + depDate := startDate.AddDate(0, periodMonths*(i+1), 0) + schedule = append(schedule, map[string]interface{}{ + "period": i + 1, + "date": depDate.Format("2006-01-02"), + "amount": math.Round(amt*100) / 100, + "depreciated": math.Round((depreciableValue-remaining)*100) / 100, + "remaining_value": math.Round((remaining+salvageValue)*100) / 100, + }) + } + + case "degressive": + for i := 0; i < numPeriods; i++ { + amt := remaining * progressFactor + if amt < 0.01 { + amt = remaining + } + if i == numPeriods-1 { + amt = remaining + } + remaining -= amt + depDate := startDate.AddDate(0, periodMonths*(i+1), 0) + schedule = append(schedule, map[string]interface{}{ + "period": i + 1, + "date": depDate.Format("2006-01-02"), + "amount": math.Round(amt*100) / 100, + "depreciated": math.Round((depreciableValue-remaining)*100) / 100, + "remaining_value": math.Round((remaining+salvageValue)*100) / 100, + }) + } + + case "degressive_then_linear": + // Use declining balance until it drops below straight-line, then switch + linearAmount := depreciableValue / float64(numPeriods) + for i := 0; i < numPeriods; i++ { + degressiveAmt := remaining * progressFactor + // Linear amount for remaining periods + remainingPeriods := numPeriods - i + linearRemaining := remaining / float64(remainingPeriods) + + amt := degressiveAmt + if linearRemaining > degressiveAmt { + amt = linearRemaining // switch to linear + } + if amt < linearAmount*0.01 { + amt = remaining + } + if i == numPeriods-1 { + amt = remaining + } + remaining -= amt + depDate := startDate.AddDate(0, periodMonths*(i+1), 0) + schedule = append(schedule, map[string]interface{}{ + "period": i + 1, + "date": depDate.Format("2006-01-02"), + "amount": math.Round(amt*100) / 100, + "depreciated": math.Round((depreciableValue-remaining)*100) / 100, + "remaining_value": math.Round((remaining+salvageValue)*100) / 100, + }) + } + } + + return map[string]interface{}{"schedule": schedule}, nil + }) + + // action_create_depreciation_moves: generate actual journal entries for depreciation. + // Mirrors: odoo/addons/account_asset/models/account_asset.py _generate_moves() + m.RegisterMethod("action_create_depreciation_moves", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + assetID := rs.IDs()[0] + + var name string + var journalID, companyID, depAccountID, expenseAccountID int64 + var currencyID *int64 + var state string + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, ''), COALESCE(journal_id, 0), COALESCE(company_id, 0), + COALESCE(account_depreciation_id, 0), COALESCE(account_depreciation_expense_id, 0), + currency_id, COALESCE(state, 'draft') + FROM account_asset WHERE id = $1`, assetID, + ).Scan(&name, &journalID, &companyID, &depAccountID, &expenseAccountID, ¤cyID, &state) + if err != nil { + return nil, fmt.Errorf("account: read asset %d: %w", assetID, err) + } + + if state != "open" { + return nil, fmt.Errorf("account: can only create depreciation moves for running assets") + } + if journalID == 0 || depAccountID == 0 || expenseAccountID == 0 { + return nil, fmt.Errorf("account: asset %d is missing journal or account configuration", assetID) + } + + // Compute the schedule by calling the registered method + assetModel := orm.Registry.Get("account.asset") + if assetModel == nil { + return nil, fmt.Errorf("account: asset model not registered") + } + computeMethod, hasMethod := assetModel.Methods["action_compute_depreciation"] + if !hasMethod { + return nil, fmt.Errorf("account: action_compute_depreciation method not found") + } + scheduleResult, err := computeMethod(rs) + if err != nil { + return nil, err + } + scheduleMap, ok := scheduleResult.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("account: invalid schedule result") + } + scheduleRaw, _ := scheduleMap["schedule"].([]map[string]interface{}) + if len(scheduleRaw) == 0 { + return nil, fmt.Errorf("account: empty depreciation schedule") + } + + // Check how many depreciation moves already exist + var existingCount int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID, + ).Scan(&existingCount) + + // Only create the next un-created entry + if existingCount >= len(scheduleRaw) { + return nil, fmt.Errorf("account: all depreciation entries already created") + } + + entry := scheduleRaw[existingCount] + amount, _ := toFloat(entry["amount"]) + depDate, _ := entry["date"].(string) + if depDate == "" { + depDate = time.Now().Format("2006-01-02") + } + + var curID int64 + if currencyID != nil { + curID = *currencyID + } else { + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID, + ).Scan(&curID) + } + + // Create the depreciation journal entry + moveRS := env.Model("account.move") + move, err := moveRS.Create(orm.Values{ + "move_type": "entry", + "ref": fmt.Sprintf("Depreciation: %s (%d/%d)", name, existingCount+1, len(scheduleRaw)), + "date": depDate, + "journal_id": journalID, + "company_id": companyID, + "currency_id": curID, + "asset_id": assetID, + }) + if err != nil { + return nil, fmt.Errorf("account: create depreciation move: %w", err) + } + + lineRS := env.Model("account.move.line") + // Debit: expense account + if _, err := lineRS.Create(orm.Values{ + "move_id": move.ID(), + "account_id": expenseAccountID, + "name": fmt.Sprintf("Depreciation: %s", name), + "debit": amount, + "credit": 0.0, + "balance": amount, + "company_id": companyID, + "journal_id": journalID, + "currency_id": curID, + "display_type": "product", + }); err != nil { + return nil, fmt.Errorf("account: create expense line: %w", err) + } + + // Credit: depreciation account + if _, err := lineRS.Create(orm.Values{ + "move_id": move.ID(), + "account_id": depAccountID, + "name": fmt.Sprintf("Depreciation: %s", name), + "debit": 0.0, + "credit": amount, + "balance": -amount, + "company_id": companyID, + "journal_id": journalID, + "currency_id": curID, + "display_type": "product", + }); err != nil { + return nil, fmt.Errorf("account: create depreciation line: %w", err) + } + + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "account.move", + "res_id": move.ID(), + "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, + "target": "current", + }, nil + }) + + // -- DefaultGet -- + m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values { + vals := orm.Values{ + "acquisition_date": time.Now().Format("2006-01-02"), + "state": "draft", + "method": "linear", + "method_number": 5, + "method_period": "12", + } + companyID := env.CompanyID() + if companyID > 0 { + vals["company_id"] = companyID + + var curID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID, + ).Scan(&curID) + if curID > 0 { + vals["currency_id"] = curID + } + } + return vals + } + + // Extend account.move with asset_id back-reference + initAccountMoveAssetExtension() +} + +// initAccountMoveAssetExtension adds asset_id to account.move for depreciation entries. +func initAccountMoveAssetExtension() { + ext := orm.ExtendModel("account.move") + ext.AddFields( + orm.Many2one("asset_id", "account.asset", orm.FieldOpts{ + String: "Asset", Help: "Asset linked to this depreciation entry", + }), + ) +} diff --git a/addons/account/models/account_budget.go b/addons/account/models/account_budget.go new file mode 100644 index 0000000..9306fb4 --- /dev/null +++ b/addons/account/models/account_budget.go @@ -0,0 +1,226 @@ +package models + +import ( + "time" + + "odoo-go/pkg/orm" +) + +// initAccountBudget registers budget planning models. +// Mirrors: odoo/addons/account_budget/models/account_budget.py +// +// crossovered.budget defines a budget with a date range and responsibility. +// crossovered.budget.lines defines individual budget lines per analytic account. +func initAccountBudget() { + // -- Budget Header -- + m := orm.NewModel("crossovered.budget", orm.ModelOpts{ + Description: "Budget", + Order: "date_from desc", + }) + + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Budget Name", Required: true}), + orm.Many2one("user_id", "res.users", orm.FieldOpts{String: "Responsible"}), + orm.Date("date_from", orm.FieldOpts{String: "Start Date", Required: true}), + orm.Date("date_to", orm.FieldOpts{String: "End Date", Required: true}), + orm.Selection("state", []orm.SelectionItem{ + {Value: "draft", Label: "Draft"}, + {Value: "cancel", Label: "Cancelled"}, + {Value: "confirm", Label: "Confirmed"}, + {Value: "validate", Label: "Validated"}, + {Value: "done", Label: "Done"}, + }, orm.FieldOpts{String: "Status", Default: "draft", Required: true}), + orm.One2many("crossovered_budget_line", "crossovered.budget.lines", "crossovered_budget_id", orm.FieldOpts{ + String: "Budget Lines", + }), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), + ) + + // action_budget_confirm: draft -> confirm + m.RegisterMethod("action_budget_confirm", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE crossovered_budget SET state = 'confirm' WHERE id = $1 AND state = 'draft'`, id) + } + return true, nil + }) + + // action_budget_validate: confirm -> validate + m.RegisterMethod("action_budget_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE crossovered_budget SET state = 'validate' WHERE id = $1 AND state = 'confirm'`, id) + } + return true, nil + }) + + // action_budget_done: validate -> done + m.RegisterMethod("action_budget_done", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE crossovered_budget SET state = 'done' WHERE id = $1 AND state = 'validate'`, id) + } + return true, nil + }) + + // action_budget_cancel: any -> cancel + m.RegisterMethod("action_budget_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE crossovered_budget SET state = 'cancel' WHERE id = $1`, id) + } + return true, nil + }) + + // action_budget_draft: cancel -> draft + m.RegisterMethod("action_budget_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, id := range rs.IDs() { + env.Tx().Exec(env.Ctx(), + `UPDATE crossovered_budget SET state = 'draft' WHERE id = $1 AND state = 'cancel'`, id) + } + return true, nil + }) + + m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values { + vals := orm.Values{ + "state": "draft", + "date_from": time.Now().Format("2006-01-02"), + "date_to": time.Now().AddDate(1, 0, 0).Format("2006-01-02"), + } + companyID := env.CompanyID() + if companyID > 0 { + vals["company_id"] = companyID + } + return vals + } + + // -- Budget Lines -- + bl := orm.NewModel("crossovered.budget.lines", orm.ModelOpts{ + Description: "Budget Line", + Order: "date_from", + }) + + bl.AddFields( + orm.Many2one("crossovered_budget_id", "crossovered.budget", orm.FieldOpts{ + String: "Budget", Required: true, OnDelete: orm.OnDeleteCascade, + }), + orm.Many2one("analytic_account_id", "account.analytic.account", orm.FieldOpts{ + String: "Analytic Account", + }), + orm.Many2one("general_budget_id", "account.budget.post", orm.FieldOpts{ + String: "Budgetary Position", + }), + orm.Date("date_from", orm.FieldOpts{String: "Start Date", Required: true}), + orm.Date("date_to", orm.FieldOpts{String: "End Date", Required: true}), + orm.Monetary("planned_amount", orm.FieldOpts{ + String: "Planned Amount", Required: true, CurrencyField: "currency_id", + }), + orm.Monetary("practical_amount", orm.FieldOpts{ + String: "Practical Amount", Compute: "_compute_practical_amount", CurrencyField: "currency_id", + }), + orm.Float("percentage", orm.FieldOpts{ + String: "Achievement", Compute: "_compute_percentage", + }), + orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{ + String: "Company", Related: "crossovered_budget_id.company_id", + }), + orm.Many2one("paid_date", "res.company", orm.FieldOpts{String: "Paid Date"}), + orm.Boolean("is_above_budget", orm.FieldOpts{ + String: "Above Budget", Compute: "_compute_is_above_budget", + }), + ) + + // _compute_practical_amount: sum of posted journal entries for the analytic account + // in the budget line's date range. + // Mirrors: odoo/addons/account_budget/models/account_budget.py _compute_practical_amount() + bl.RegisterCompute("practical_amount", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + + var analyticID *int64 + var dateFrom, dateTo string + env.Tx().QueryRow(env.Ctx(), + `SELECT analytic_account_id, date_from::text, date_to::text + FROM crossovered_budget_lines WHERE id = $1`, lineID, + ).Scan(&analyticID, &dateFrom, &dateTo) + + if analyticID == nil || *analyticID == 0 { + return orm.Values{"practical_amount": 0.0}, nil + } + + var total float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(SUM(al.amount::float8), 0) + FROM account_analytic_line al + WHERE al.account_id = $1 AND al.date >= $2 AND al.date <= $3`, + *analyticID, dateFrom, dateTo, + ).Scan(&total) + + return orm.Values{"practical_amount": total}, nil + }) + + // _compute_percentage: practical / planned * 100 + bl.RegisterCompute("percentage", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + + var planned, practical float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(planned_amount::float8, 0), COALESCE(practical_amount::float8, 0) + FROM crossovered_budget_lines WHERE id = $1`, lineID, + ).Scan(&planned, &practical) + + pct := 0.0 + if planned != 0 { + pct = (practical / planned) * 100 + } + + return orm.Values{"percentage": pct}, nil + }) + + // _compute_is_above_budget + bl.RegisterCompute("is_above_budget", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + + var planned, practical float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(planned_amount::float8, 0), COALESCE(practical_amount::float8, 0) + FROM crossovered_budget_lines WHERE id = $1`, lineID, + ).Scan(&planned, &practical) + + above := false + if planned > 0 { + above = practical > planned + } else if planned < 0 { + above = practical < planned + } + + return orm.Values{"is_above_budget": above}, nil + }) + + // -- Budgetary Position -- + // account.budget.post groups accounts for budgeting purposes. + // Mirrors: odoo/addons/account_budget/models/account_budget.py AccountBudgetPost + bp := orm.NewModel("account.budget.post", orm.ModelOpts{ + Description: "Budgetary Position", + Order: "name", + }) + + bp.AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), + orm.Many2many("account_ids", "account.account", orm.FieldOpts{ + String: "Accounts", + Relation: "account_budget_post_account_rel", + Column1: "budget_post_id", + Column2: "account_id", + }), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), + ) +} diff --git a/addons/account/models/account_cash_rounding.go b/addons/account/models/account_cash_rounding.go new file mode 100644 index 0000000..cce0944 --- /dev/null +++ b/addons/account/models/account_cash_rounding.go @@ -0,0 +1,159 @@ +package models + +import ( + "fmt" + "math" + + "odoo-go/pkg/orm" +) + +// initAccountCashRounding registers account.cash.rounding — rounding rules for invoices. +// Mirrors: odoo/addons/account/models/account_cash_rounding.py +// +// Used to round invoice totals to the nearest increment (e.g. 0.05 CHF in Switzerland). +// Two strategies: +// - "biggest_tax": add the rounding difference to the biggest tax line +// - "add_invoice_line": add a separate rounding line +func initAccountCashRounding() { + m := orm.NewModel("account.cash.rounding", orm.ModelOpts{ + Description: "Cash Rounding", + Order: "name", + }) + + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}), + orm.Float("rounding", orm.FieldOpts{ + String: "Rounding Precision", Required: true, Default: 0.01, + Help: "Represent the non-zero value smallest coinage (e.g. 0.05)", + }), + orm.Selection("strategy", []orm.SelectionItem{ + {Value: "biggest_tax", Label: "Modify tax amount"}, + {Value: "add_invoice_line", Label: "Add a rounding line"}, + }, orm.FieldOpts{String: "Rounding Strategy", Default: "add_invoice_line", Required: true}), + orm.Many2one("profit_account_id", "account.account", orm.FieldOpts{ + String: "Profit Account", + Help: "Account for the rounding line when strategy is add_invoice_line (rounding up)", + }), + orm.Many2one("loss_account_id", "account.account", orm.FieldOpts{ + String: "Loss Account", + Help: "Account for the rounding line when strategy is add_invoice_line (rounding down)", + }), + orm.Selection("rounding_method", []orm.SelectionItem{ + {Value: "UP", Label: "Up"}, + {Value: "DOWN", Label: "Down"}, + {Value: "HALF-UP", Label: "Half-Up"}, + }, orm.FieldOpts{String: "Rounding Method", Default: "HALF-UP", Required: true}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), + ) + + // compute_rounding: round an amount according to this rounding rule. + // Returns the rounded amount and the difference. + // Mirrors: odoo/addons/account/models/account_cash_rounding.py round() + m.RegisterMethod("compute_rounding", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("account: compute_rounding requires an amount argument") + } + env := rs.Env() + roundingID := rs.IDs()[0] + amount, ok := toFloat(args[0]) + if !ok { + return nil, fmt.Errorf("account: invalid amount for rounding") + } + + var precision float64 + var method string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(rounding, 0.01), COALESCE(rounding_method, 'HALF-UP') + FROM account_cash_rounding WHERE id = $1`, roundingID, + ).Scan(&precision, &method) + + if precision <= 0 { + precision = 0.01 + } + + var rounded float64 + switch method { + case "UP": + rounded = math.Ceil(amount/precision) * precision + case "DOWN": + rounded = math.Floor(amount/precision) * precision + default: // HALF-UP + rounded = math.Round(amount/precision) * precision + } + + // Round to avoid float precision issues + rounded = math.Round(rounded*100) / 100 + diff := rounded - amount + + return map[string]interface{}{ + "rounded": rounded, + "difference": math.Round(diff*100) / 100, + }, nil + }) +} + +// initAccountInvoiceSend registers the invoice send wizard. +// Mirrors: odoo/addons/account/wizard/account_invoice_send.py +// +// This wizard handles sending invoices by email and/or generating PDF. +func initAccountInvoiceSend() { + m := orm.NewModel("account.invoice.send", orm.ModelOpts{ + Description: "Invoice Send", + Type: orm.ModelTransient, + }) + + m.AddFields( + orm.Many2many("invoice_ids", "account.move", orm.FieldOpts{ + String: "Invoices", + Relation: "account_invoice_send_move_rel", + Column1: "wizard_id", + Column2: "move_id", + }), + orm.Boolean("is_email", orm.FieldOpts{String: "Email", Default: true}), + orm.Boolean("is_print", orm.FieldOpts{String: "Print", Default: false}), + orm.Char("partner_ids", orm.FieldOpts{String: "Partners"}), + orm.Many2one("template_id", "mail.template", orm.FieldOpts{String: "Email Template"}), + orm.Many2one("composer_id", "mail.compose.message", orm.FieldOpts{String: "Composer"}), + ) + + // action_send_and_print: processes the sending/printing of invoices. + // Mirrors: odoo/addons/account/wizard/account_invoice_send.py send_and_print_action() + m.RegisterMethod("action_send_and_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + // For now, just mark invoices as sent and return close action + env := rs.Env() + wizID := rs.IDs()[0] + + // Get invoice IDs from the wizard + rows, err := env.Tx().Query(env.Ctx(), + `SELECT move_id FROM account_invoice_send_move_rel WHERE wizard_id = $1`, wizID) + if err != nil { + return map[string]interface{}{"type": "ir.actions.act_window_close"}, nil + } + defer rows.Close() + + for rows.Next() { + var moveID int64 + if err := rows.Scan(&moveID); err != nil { + continue + } + // Mark the invoice as sent (set invoice_sent flag via message) + env.Tx().Exec(env.Ctx(), + `UPDATE account_move SET ref = COALESCE(ref, '') || ' [Sent]' WHERE id = $1`, moveID) + } + + return map[string]interface{}{ + "type": "ir.actions.act_window_close", + }, nil + }) +} + +// initAccountCashRoundingOnMove extends account.move with cash rounding support. +func initAccountCashRoundingOnMove() { + ext := orm.ExtendModel("account.move") + ext.AddFields( + orm.Many2one("invoice_cash_rounding_id", "account.cash.rounding", orm.FieldOpts{ + String: "Cash Rounding Method", + Help: "Defines the smallest coinage of the currency that can be used to pay", + }), + ) +} diff --git a/addons/account/models/account_edi.go b/addons/account/models/account_edi.go new file mode 100644 index 0000000..db63efb --- /dev/null +++ b/addons/account/models/account_edi.go @@ -0,0 +1,365 @@ +package models + +import ( + "encoding/xml" + "fmt" + "strings" + "time" + + "odoo-go/pkg/orm" +) + +// initAccountEdi registers electronic invoicing (EDI) models. +// Mirrors: odoo/addons/account_edi/models/account_edi_format.py +// +// EDI (Electronic Data Interchange) handles electronic invoice formats like +// UBL, Factur-X, and XRechnung. This provides the base framework. +func initAccountEdi() { + // -- EDI Format -- + // Defines a supported electronic invoice format. + ef := orm.NewModel("account.edi.format", orm.ModelOpts{ + Description: "Electronic Invoicing Format", + Order: "name", + }) + + ef.AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), + orm.Char("code", orm.FieldOpts{String: "Code", Required: true, Help: "Technical code, e.g. facturx_1_0_05, ubl_2_1"}), + ) + + // -- EDI Document -- + // Links an invoice to an EDI format with state tracking. + ed := orm.NewModel("account.edi.document", orm.ModelOpts{ + Description: "Electronic Invoicing Document", + Order: "id desc", + }) + + ed.AddFields( + orm.Many2one("move_id", "account.move", orm.FieldOpts{ + String: "Invoice", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, + }), + orm.Many2one("edi_format_id", "account.edi.format", orm.FieldOpts{ + String: "Format", Required: true, + }), + orm.Selection("state", []orm.SelectionItem{ + {Value: "to_send", Label: "To Send"}, + {Value: "sent", Label: "Sent"}, + {Value: "to_cancel", Label: "To Cancel"}, + {Value: "cancelled", Label: "Cancelled"}, + }, orm.FieldOpts{String: "Status", Default: "to_send", Required: true}), + orm.Binary("attachment_id", orm.FieldOpts{String: "Attachment"}), + orm.Char("error", orm.FieldOpts{String: "Error"}), + orm.Boolean("blocking_level", orm.FieldOpts{String: "Blocking Level"}), + ) + + // action_export_xml: generate UBL XML for the invoice. + // Mirrors: odoo/addons/account_edi/models/account_edi_format.py _export_invoice_ubl() + ed.RegisterMethod("action_export_xml", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + docID := rs.IDs()[0] + + var moveID int64 + var formatCode string + env.Tx().QueryRow(env.Ctx(), + `SELECT d.move_id, f.code + FROM account_edi_document d + JOIN account_edi_format f ON f.id = d.edi_format_id + WHERE d.id = $1`, docID, + ).Scan(&moveID, &formatCode) + + if moveID == 0 { + return nil, fmt.Errorf("account: EDI document has no linked invoice") + } + + xmlContent, err := generateInvoiceXML(env, moveID, formatCode) + if err != nil { + env.Tx().Exec(env.Ctx(), + `UPDATE account_edi_document SET error = $1 WHERE id = $2`, + err.Error(), docID) + return nil, err + } + + // Mark as sent + env.Tx().Exec(env.Ctx(), + `UPDATE account_edi_document SET state = 'sent', error = NULL WHERE id = $1`, docID) + + return map[string]interface{}{ + "xml": xmlContent, + "move_id": moveID, + "format": formatCode, + }, nil + }) + + // Extend account.move with EDI fields + initAccountMoveEdiExtension() +} + +// initAccountMoveEdiExtension adds EDI-related fields to account.move. +func initAccountMoveEdiExtension() { + ext := orm.ExtendModel("account.move") + ext.AddFields( + orm.One2many("edi_document_ids", "account.edi.document", "move_id", orm.FieldOpts{ + String: "Electronic Documents", + }), + orm.Selection("edi_state", []orm.SelectionItem{ + {Value: "to_send", Label: "To Send"}, + {Value: "sent", Label: "Sent"}, + {Value: "to_cancel", Label: "To Cancel"}, + {Value: "cancelled", Label: "Cancelled"}, + }, orm.FieldOpts{String: "Electronic Invoicing State"}), + orm.Boolean("edi_show_cancel_button", orm.FieldOpts{ + String: "Show Cancel EDI Button", Compute: "_compute_edi_show_cancel_button", + }), + ) + + // _compute_edi_show_cancel_button + ext.RegisterCompute("edi_show_cancel_button", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + moveID := rs.IDs()[0] + + var sentCount int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM account_edi_document WHERE move_id = $1 AND state = 'sent'`, moveID, + ).Scan(&sentCount) + + return orm.Values{"edi_show_cancel_button": sentCount > 0}, nil + }) + + // action_process_edi_web_services: send all pending EDI documents + ext.RegisterMethod("action_process_edi_web_services", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, moveID := range rs.IDs() { + // Find pending EDI documents + rows, err := env.Tx().Query(env.Ctx(), + `SELECT id FROM account_edi_document WHERE move_id = $1 AND state = 'to_send'`, moveID) + if err != nil { + continue + } + var docIDs []int64 + for rows.Next() { + var docID int64 + rows.Scan(&docID) + docIDs = append(docIDs, docID) + } + rows.Close() + + for _, docID := range docIDs { + docRS := env.Model("account.edi.document").Browse(docID) + ediModel := orm.Registry.Get("account.edi.document") + if ediModel != nil { + if exportMethod, ok := ediModel.Methods["action_export_xml"]; ok { + exportMethod(docRS) + } + } + } + } + return true, nil + }) +} + +// -- XML generation for UBL/Factur-X -- + +// UBLInvoice represents a simplified UBL 2.1 invoice structure. +type UBLInvoice struct { + XMLName xml.Name `xml:"Invoice"` + XMLNS string `xml:"xmlns,attr"` + XMLNSCAC string `xml:"xmlns:cac,attr"` + XMLNSCBC string `xml:"xmlns:cbc,attr"` + UBLVersionID string `xml:"cbc:UBLVersionID"` + CustomizationID string `xml:"cbc:CustomizationID"` + ID string `xml:"cbc:ID"` + IssueDate string `xml:"cbc:IssueDate"` + DueDate string `xml:"cbc:DueDate,omitempty"` + InvoiceTypeCode string `xml:"cbc:InvoiceTypeCode"` + DocumentCurrencyCode string `xml:"cbc:DocumentCurrencyCode"` + Supplier UBLParty `xml:"cac:AccountingSupplierParty>cac:Party"` + Customer UBLParty `xml:"cac:AccountingCustomerParty>cac:Party"` + TaxTotal UBLTaxTotal `xml:"cac:TaxTotal"` + LegalMonetaryTotal UBLMonetary `xml:"cac:LegalMonetaryTotal"` + InvoiceLines []UBLLine `xml:"cac:InvoiceLine"` +} + +// UBLParty represents a party in UBL. +type UBLParty struct { + Name string `xml:"cac:PartyName>cbc:Name"` + Street string `xml:"cac:PostalAddress>cbc:StreetName,omitempty"` + City string `xml:"cac:PostalAddress>cbc:CityName,omitempty"` + Zip string `xml:"cac:PostalAddress>cbc:PostalZone,omitempty"` + Country string `xml:"cac:PostalAddress>cac:Country>cbc:IdentificationCode,omitempty"` + TaxID string `xml:"cac:PartyTaxScheme>cbc:CompanyID,omitempty"` +} + +// UBLTaxTotal represents tax totals. +type UBLTaxTotal struct { + TaxAmount string `xml:"cbc:TaxAmount"` +} + +// UBLMonetary represents monetary totals. +type UBLMonetary struct { + LineExtensionAmount string `xml:"cbc:LineExtensionAmount"` + TaxExclusiveAmount string `xml:"cbc:TaxExclusiveAmount"` + TaxInclusiveAmount string `xml:"cbc:TaxInclusiveAmount"` + PayableAmount string `xml:"cbc:PayableAmount"` +} + +// UBLLine represents an invoice line in UBL. +type UBLLine struct { + ID string `xml:"cbc:ID"` + Quantity string `xml:"cbc:InvoicedQuantity"` + LineAmount string `xml:"cbc:LineExtensionAmount"` + ItemName string `xml:"cac:Item>cbc:Name"` + PriceAmount string `xml:"cac:Price>cbc:PriceAmount"` +} + +// generateInvoiceXML creates a UBL 2.1 XML representation of an invoice. +// Mirrors: odoo/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py +func generateInvoiceXML(env *orm.Environment, moveID int64, formatCode string) (string, error) { + // Read move header + var name, moveType string + var invoiceDate, dueDate *string + var partnerID, companyID int64 + var amountUntaxed, amountTax, amountTotal float64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, '/'), COALESCE(move_type, 'out_invoice'), + invoice_date::text, invoice_date_due::text, + COALESCE(partner_id, 0), COALESCE(company_id, 0), + COALESCE(amount_untaxed::float8, 0), COALESCE(amount_tax::float8, 0), + COALESCE(amount_total::float8, 0) + FROM account_move WHERE id = $1`, moveID, + ).Scan(&name, &moveType, &invoiceDate, &dueDate, &partnerID, &companyID, + &amountUntaxed, &amountTax, &amountTotal) + if err != nil { + return "", fmt.Errorf("account: read move for XML: %w", err) + } + + // Determine invoice type code (UBL standard) + typeCode := "380" // Commercial Invoice + switch moveType { + case "out_refund", "in_refund": + typeCode = "381" // Credit Note + case "out_receipt", "in_receipt": + typeCode = "325" // Receipt + } + + // Read supplier (company) + var companyName string + var companyStreet, companyCity, companyZip, companyCountry *string + var companyVat *string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(c.name, ''), p.street, p.city, p.zip, + co.code, c.vat + FROM res_company c + LEFT JOIN res_partner p ON p.id = c.partner_id + LEFT JOIN res_country co ON co.id = p.country_id + WHERE c.id = $1`, companyID, + ).Scan(&companyName, &companyStreet, &companyCity, &companyZip, &companyCountry, &companyVat) + + // Read customer (partner) + var customerName string + var customerStreet, customerCity, customerZip, customerCountry *string + var customerVat *string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(p.name, ''), p.street, p.city, p.zip, + co.code, p.vat + FROM res_partner p + LEFT JOIN res_country co ON co.id = p.country_id + WHERE p.id = $1`, partnerID, + ).Scan(&customerName, &customerStreet, &customerCity, &customerZip, &customerCountry, &customerVat) + + // Read invoice lines + lineRows, err := env.Tx().Query(env.Ctx(), + `SELECT COALESCE(name, ''), COALESCE(quantity, 1), COALESCE(price_unit::float8, 0), + COALESCE(price_subtotal::float8, 0) + FROM account_move_line + WHERE move_id = $1 AND (display_type IS NULL OR display_type = '' OR display_type = 'product') + ORDER BY sequence, id`, moveID) + if err != nil { + return "", fmt.Errorf("account: read lines for XML: %w", err) + } + defer lineRows.Close() + + var ublLines []UBLLine + lineNum := 1 + for lineRows.Next() { + var lineName string + var qty, price, subtotal float64 + if err := lineRows.Scan(&lineName, &qty, &price, &subtotal); err != nil { + continue + } + ublLines = append(ublLines, UBLLine{ + ID: fmt.Sprintf("%d", lineNum), + Quantity: fmt.Sprintf("%.2f", qty), + LineAmount: fmt.Sprintf("%.2f", subtotal), + ItemName: lineName, + PriceAmount: fmt.Sprintf("%.2f", price), + }) + lineNum++ + } + + issueDateStr := time.Now().Format("2006-01-02") + if invoiceDate != nil && *invoiceDate != "" { + issueDateStr = *invoiceDate + } + dueDateStr := "" + if dueDate != nil { + dueDateStr = *dueDate + } + + invoice := UBLInvoice{ + XMLNS: "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2", + XMLNSCAC: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2", + XMLNSCBC: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", + UBLVersionID: "2.1", + CustomizationID: "urn:cen.eu:en16931:2017", + ID: name, + IssueDate: issueDateStr, + DueDate: dueDateStr, + InvoiceTypeCode: typeCode, + DocumentCurrencyCode: "EUR", + Supplier: UBLParty{ + Name: companyName, + Street: ptrStr(companyStreet), + City: ptrStr(companyCity), + Zip: ptrStr(companyZip), + Country: ptrStr(companyCountry), + TaxID: ptrStr(companyVat), + }, + Customer: UBLParty{ + Name: customerName, + Street: ptrStr(customerStreet), + City: ptrStr(customerCity), + Zip: ptrStr(customerZip), + Country: ptrStr(customerCountry), + TaxID: ptrStr(customerVat), + }, + TaxTotal: UBLTaxTotal{ + TaxAmount: fmt.Sprintf("%.2f", amountTax), + }, + LegalMonetaryTotal: UBLMonetary{ + LineExtensionAmount: fmt.Sprintf("%.2f", amountUntaxed), + TaxExclusiveAmount: fmt.Sprintf("%.2f", amountUntaxed), + TaxInclusiveAmount: fmt.Sprintf("%.2f", amountTotal), + PayableAmount: fmt.Sprintf("%.2f", amountTotal), + }, + InvoiceLines: ublLines, + } + + output, err := xml.MarshalIndent(invoice, "", " ") + if err != nil { + return "", fmt.Errorf("account: marshal XML: %w", err) + } + + var b strings.Builder + b.WriteString(xml.Header) + b.Write(output) + + return b.String(), nil +} + +// ptrStr safely dereferences a *string, returning "" if nil. +func ptrStr(s *string) string { + if s != nil { + return *s + } + return "" +} diff --git a/addons/account/models/account_followup.go b/addons/account/models/account_followup.go new file mode 100644 index 0000000..16001b4 --- /dev/null +++ b/addons/account/models/account_followup.go @@ -0,0 +1,318 @@ +package models + +import ( + "fmt" + "strings" + + "odoo-go/pkg/orm" +) + +// initAccountFollowup registers payment follow-up models. +// Mirrors: odoo/addons/account_followup/models/account_followup.py +// +// Follow-up levels define escalation steps for overdue payments: +// Level 1: Friendly reminder after X days +// Level 2: Formal notice after X days +// Level 3: Final warning / legal action after X days +func initAccountFollowup() { + // -- Follow-up Level -- + fl := orm.NewModel("account.followup.line", orm.ModelOpts{ + Description: "Follow-up Criteria", + Order: "delay", + }) + + fl.AddFields( + orm.Char("name", orm.FieldOpts{String: "Follow-Up Action", Required: true, Translate: true}), + orm.Integer("delay", orm.FieldOpts{ + String: "Due Days", + Required: true, + Help: "Number of days after the due date of the invoice to trigger this action", + }), + orm.Text("description", orm.FieldOpts{ + String: "Printed Message", + Translate: true, + Help: "Message printed on the follow-up report", + }), + orm.Text("email_body", orm.FieldOpts{ + String: "Email Body", + Translate: true, + Help: "Email body for the follow-up email", + }), + orm.Char("email_subject", orm.FieldOpts{ + String: "Email Subject", + Translate: true, + }), + orm.Boolean("send_email", orm.FieldOpts{ + String: "Send Email", + Default: true, + }), + orm.Boolean("send_letter", orm.FieldOpts{ + String: "Send Letter", + Default: false, + }), + orm.Boolean("manual_action", orm.FieldOpts{ + String: "Manual Action", + Help: "Assign a manual action to be done", + }), + orm.Text("manual_action_note", orm.FieldOpts{ + String: "Action To Do", + Help: "Description of the manual action to be taken", + }), + orm.Many2one("manual_action_responsible_id", "res.users", orm.FieldOpts{ + String: "Assign a Responsible", + }), + orm.Many2one("company_id", "res.company", orm.FieldOpts{ + String: "Company", Required: true, + }), + orm.Selection("sms_template", []orm.SelectionItem{ + {Value: "none", Label: "None"}, + {Value: "default", Label: "Default"}, + }, orm.FieldOpts{String: "SMS Template", Default: "none"}), + orm.Boolean("join_invoices", orm.FieldOpts{ + String: "Attach Invoices", + Default: false, + Help: "Attach open invoice PDFs to the follow-up email", + }), + orm.Selection("auto_execute", []orm.SelectionItem{ + {Value: "auto", Label: "Automatic"}, + {Value: "manual", Label: "Manual"}, + }, orm.FieldOpts{String: "Execution", Default: "manual"}), + ) + + // -- Follow-up Report (partner-level) -- + // Extends res.partner with follow-up state tracking. + initFollowupPartnerExtension() + + // -- Follow-up processing method on partner -- + initFollowupProcess() +} + +// initFollowupPartnerExtension adds follow-up tracking fields to res.partner. +func initFollowupPartnerExtension() { + ext := orm.ExtendModel("res.partner") + ext.AddFields( + orm.Selection("followup_status", []orm.SelectionItem{ + {Value: "no_action_needed", Label: "No Action Needed"}, + {Value: "with_overdue_invoices", Label: "With Overdue Invoices"}, + {Value: "in_need_of_action", Label: "In Need of Action"}, + }, orm.FieldOpts{String: "Follow-up Status", Compute: "_compute_followup_status"}), + orm.Many2one("followup_level_id", "account.followup.line", orm.FieldOpts{ + String: "Follow-up Level", + }), + orm.Date("followup_next_action_date", orm.FieldOpts{ + String: "Next Follow-up Date", + }), + orm.Text("followup_reminder", orm.FieldOpts{ + String: "Customer Follow-up Note", + }), + orm.Many2one("followup_responsible_id", "res.users", orm.FieldOpts{ + String: "Follow-up Responsible", + }), + orm.Integer("total_overdue_invoices", orm.FieldOpts{ + String: "Total Overdue Invoices", Compute: "_compute_total_overdue", + }), + orm.Monetary("total_overdue_amount", orm.FieldOpts{ + String: "Total Overdue Amount", Compute: "_compute_total_overdue", + }), + ) + + // _compute_followup_status + ext.RegisterCompute("followup_status", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + partnerID := rs.IDs()[0] + + var overdueCount int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM account_move m + WHERE m.partner_id = $1 AND m.state = 'posted' + AND m.move_type IN ('out_invoice', 'out_receipt') + AND m.payment_state NOT IN ('paid', 'in_payment', 'reversed') + AND m.invoice_date_due < CURRENT_DATE`, partnerID, + ).Scan(&overdueCount) + + status := "no_action_needed" + if overdueCount > 0 { + // Check if there's a follow-up level set + var levelID *int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT followup_level_id FROM res_partner WHERE id = $1`, partnerID, + ).Scan(&levelID) + + if levelID != nil && *levelID > 0 { + status = "in_need_of_action" + } else { + status = "with_overdue_invoices" + } + } + + return orm.Values{"followup_status": status}, nil + }) + + // _compute_total_overdue: computes both count and amount of overdue invoices + computeOverdue := func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + partnerID := rs.IDs()[0] + + var count int + var total float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*), COALESCE(SUM(amount_residual::float8), 0) + FROM account_move + WHERE partner_id = $1 AND state = 'posted' + AND move_type IN ('out_invoice', 'out_receipt') + AND payment_state NOT IN ('paid', 'in_payment', 'reversed') + AND invoice_date_due < CURRENT_DATE`, partnerID, + ).Scan(&count, &total) + + return orm.Values{ + "total_overdue_invoices": count, + "total_overdue_amount": total, + }, nil + } + ext.RegisterCompute("total_overdue_invoices", computeOverdue) + ext.RegisterCompute("total_overdue_amount", computeOverdue) +} + +// initFollowupProcess registers the follow-up processing methods. +func initFollowupProcess() { + ext := orm.ExtendModel("res.partner") + + // action_execute_followup: run follow-up for the partner. + // Mirrors: odoo/addons/account_followup/models/res_partner.py execute_followup() + ext.RegisterMethod("action_execute_followup", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, partnerID := range rs.IDs() { + // Find overdue invoices for this partner + rows, err := env.Tx().Query(env.Ctx(), + `SELECT m.id, m.name, m.invoice_date_due, m.amount_residual::float8, + CURRENT_DATE - m.invoice_date_due as overdue_days + FROM account_move m + WHERE m.partner_id = $1 AND m.state = 'posted' + AND m.move_type IN ('out_invoice', 'out_receipt') + AND m.payment_state NOT IN ('paid', 'in_payment', 'reversed') + AND m.invoice_date_due < CURRENT_DATE + ORDER BY m.invoice_date_due`, partnerID) + if err != nil { + return nil, fmt.Errorf("account: query overdue invoices for partner %d: %w", partnerID, err) + } + + var maxOverdueDays int + var overdueInvoiceIDs []int64 + for rows.Next() { + var invID int64 + var invName string + var dueDate interface{} + var residual float64 + var overdueDays int + if err := rows.Scan(&invID, &invName, &dueDate, &residual, &overdueDays); err != nil { + continue + } + overdueInvoiceIDs = append(overdueInvoiceIDs, invID) + if overdueDays > maxOverdueDays { + maxOverdueDays = overdueDays + } + } + rows.Close() + + if len(overdueInvoiceIDs) == 0 { + continue + } + + // Find the appropriate follow-up level based on overdue days + var companyID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(company_id, 1) FROM res_partner WHERE id = $1`, partnerID, + ).Scan(&companyID) + + var levelID *int64 + var levelName *string + env.Tx().QueryRow(env.Ctx(), + `SELECT id, name FROM account_followup_line + WHERE company_id = $1 AND delay <= $2 + ORDER BY delay DESC LIMIT 1`, companyID, maxOverdueDays, + ).Scan(&levelID, &levelName) + + if levelID != nil { + env.Tx().Exec(env.Ctx(), + `UPDATE res_partner SET followup_level_id = $1, + followup_next_action_date = CURRENT_DATE + INTERVAL '14 days' + WHERE id = $2`, *levelID, partnerID) + } + } + return true, nil + }) + + // get_followup_html: generate HTML report of overdue invoices for a partner. + // Mirrors: odoo/addons/account_followup/models/res_partner.py get_followup_html() + ext.RegisterMethod("get_followup_html", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + partnerID := rs.IDs()[0] + + var partnerName string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, 'Unknown') FROM res_partner WHERE id = $1`, partnerID, + ).Scan(&partnerName) + + rows, err := env.Tx().Query(env.Ctx(), + `SELECT m.name, m.invoice_date_due, m.amount_residual::float8, + m.amount_total::float8, CURRENT_DATE - m.invoice_date_due as overdue_days + FROM account_move m + WHERE m.partner_id = $1 AND m.state = 'posted' + AND m.move_type IN ('out_invoice', 'out_receipt') + AND m.payment_state NOT IN ('paid', 'in_payment', 'reversed') + ORDER BY m.invoice_date_due`, partnerID) + if err != nil { + return "", fmt.Errorf("account: query invoices for followup: %w", err) + } + defer rows.Close() + + var b strings.Builder + b.WriteString(``) + b.WriteString(fmt.Sprintf("

Payment Follow-up: %s

", partnerName)) + b.WriteString(``) + + var totalDue float64 + for rows.Next() { + var invName string + var dueDate interface{} + var residual, total float64 + var overdueDays int + if err := rows.Scan(&invName, &dueDate, &residual, &total, &overdueDays); err != nil { + continue + } + totalDue += residual + + overdueClass := "" + if overdueDays > 30 { + overdueClass = ` class="overdue"` + } + b.WriteString(fmt.Sprintf(`%d`, + invName, dueDate, total, residual, overdueClass, overdueDays)) + } + + b.WriteString(fmt.Sprintf(``, totalDue)) + b.WriteString("
InvoiceDue DateTotalAmount DueOverdue Days
%s%v%.2f%.2f
Total Due%.2f
") + + // Add follow-up message if a level is set + var description *string + env.Tx().QueryRow(env.Ctx(), + `SELECT fl.description FROM res_partner p + JOIN account_followup_line fl ON fl.id = p.followup_level_id + WHERE p.id = $1`, partnerID, + ).Scan(&description) + + if description != nil && *description != "" { + b.WriteString(fmt.Sprintf(`
%s
`, *description)) + } + + return b.String(), nil + }) +} diff --git a/addons/account/models/account_lock.go b/addons/account/models/account_lock.go new file mode 100644 index 0000000..64b0f6d --- /dev/null +++ b/addons/account/models/account_lock.go @@ -0,0 +1,285 @@ +package models + +import ( + "crypto/sha256" + "fmt" + "strings" + + "odoo-go/pkg/orm" +) + +// initAccountLock registers entry locking and hash chain integrity features. +// Mirrors: odoo/addons/account/models/account_move.py (hash chain features) +// +// France, Belgium and other countries require an immutable audit trail. +// Posted entries are hashed in sequence; any tampering breaks the chain. +func initAccountLock() { + ext := orm.ExtendModel("account.move") + + ext.AddFields( + orm.Char("inalterable_hash", orm.FieldOpts{ + String: "Inalterability Hash", + Readonly: true, + Help: "Secure hash for preventing tampering with posted entries", + }), + orm.Char("secure_sequence_number", orm.FieldOpts{ + String: "Inalterability No.", + Readonly: true, + }), + orm.Boolean("restrict_mode_hash_table", orm.FieldOpts{ + String: "Lock with Hash", + Related: "journal_id.restrict_mode_hash_table", + }), + orm.Char("string_to_hash", orm.FieldOpts{ + String: "Data to Hash", + Compute: "_compute_string_to_hash", + Help: "Concatenation of fields used for hashing", + }), + ) + + // _compute_string_to_hash: generates the string representation of the move + // used for hash computation. Includes date, journal, partner, amounts. + // Mirrors: odoo/addons/account/models/account_move.py _compute_string_to_hash() + ext.RegisterCompute("string_to_hash", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + moveID := rs.IDs()[0] + + var name, moveType, state string + var date interface{} + var companyID, journalID int64 + var partnerID *int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, '/'), COALESCE(move_type, 'entry'), COALESCE(state, 'draft'), + date, COALESCE(company_id, 0), COALESCE(journal_id, 0), partner_id + FROM account_move WHERE id = $1`, moveID, + ).Scan(&name, &moveType, &state, &date, &companyID, &journalID, &partnerID) + + // Include line amounts + rows, err := env.Tx().Query(env.Ctx(), + `SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0), + COALESCE(name, '') + FROM account_move_line WHERE move_id = $1 ORDER BY id`, moveID) + if err != nil { + return orm.Values{"string_to_hash": ""}, nil + } + defer rows.Close() + + var lineData []string + for rows.Next() { + var accID int64 + var debit, credit float64 + var label string + rows.Scan(&accID, &debit, &credit, &label) + lineData = append(lineData, fmt.Sprintf("%d|%.2f|%.2f|%s", accID, debit, credit, label)) + } + + pid := int64(0) + if partnerID != nil { + pid = *partnerID + } + + hashStr := fmt.Sprintf("%s|%s|%v|%d|%d|%d|%s", + name, moveType, date, companyID, journalID, pid, + strings.Join(lineData, ";")) + + return orm.Values{"string_to_hash": hashStr}, nil + }) + + // action_hash_entry: computes and stores the hash for a posted entry. + // Mirrors: odoo/addons/account/models/account_move.py _hash_move() + ext.RegisterMethod("action_hash_entry", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, moveID := range rs.IDs() { + var state string + var journalID int64 + var restrictHash bool + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(m.state, 'draft'), COALESCE(m.journal_id, 0), + COALESCE(j.restrict_mode_hash_table, false) + FROM account_move m + LEFT JOIN account_journal j ON j.id = m.journal_id + WHERE m.id = $1`, moveID, + ).Scan(&state, &journalID, &restrictHash) + + if state != "posted" || !restrictHash { + continue + } + + // Already hashed? + var existingHash *string + env.Tx().QueryRow(env.Ctx(), + `SELECT inalterable_hash FROM account_move WHERE id = $1`, moveID, + ).Scan(&existingHash) + if existingHash != nil && *existingHash != "" { + continue // already hashed + } + + // Get the string to hash + var stringToHash string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(name, '/') || '|' || COALESCE(move_type, 'entry') || '|' || + COALESCE(date::text, '') || '|' || COALESCE(company_id::text, '0') + FROM account_move WHERE id = $1`, moveID, + ).Scan(&stringToHash) + + // Include lines + lineRows, _ := env.Tx().Query(env.Ctx(), + `SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0) + FROM account_move_line WHERE move_id = $1 ORDER BY id`, moveID) + if lineRows != nil { + for lineRows.Next() { + var accID int64 + var debit, credit float64 + lineRows.Scan(&accID, &debit, &credit) + stringToHash += fmt.Sprintf("|%d:%.2f:%.2f", accID, debit, credit) + } + lineRows.Close() + } + + // Get previous hash in the chain + var prevHash string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(inalterable_hash, '') + FROM account_move + WHERE journal_id = $1 AND state = 'posted' AND inalterable_hash IS NOT NULL + AND inalterable_hash != '' AND id < $2 + ORDER BY secure_sequence_number DESC, id DESC LIMIT 1`, + journalID, moveID, + ).Scan(&prevHash) + + // Compute hash: SHA-256 of (previous_hash + string_to_hash) + hashInput := prevHash + stringToHash + hash := sha256.Sum256([]byte(hashInput)) + hashHex := fmt.Sprintf("%x", hash) + + // Get next secure sequence number + var nextSeq int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(MAX(CAST(secure_sequence_number AS INTEGER)), 0) + 1 + FROM account_move WHERE journal_id = $1 AND secure_sequence_number IS NOT NULL`, + journalID, + ).Scan(&nextSeq) + + env.Tx().Exec(env.Ctx(), + `UPDATE account_move SET inalterable_hash = $1, secure_sequence_number = $2 WHERE id = $3`, + hashHex, fmt.Sprintf("%d", nextSeq), moveID) + } + return true, nil + }) + + // action_check_hash_integrity: verify the hash chain for a journal. + // Mirrors: odoo/addons/account/models/account_move.py _check_hash_integrity() + ext.RegisterMethod("action_check_hash_integrity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + moveID := rs.IDs()[0] + + var journalID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(journal_id, 0) FROM account_move WHERE id = $1`, moveID, + ).Scan(&journalID) + + if journalID == 0 { + return nil, fmt.Errorf("account: no journal found for move %d", moveID) + } + + // Read all hashed entries for this journal in order + rows, err := env.Tx().Query(env.Ctx(), + `SELECT id, COALESCE(inalterable_hash, ''), + COALESCE(name, '/') || '|' || COALESCE(move_type, 'entry') || '|' || + COALESCE(date::text, '') || '|' || COALESCE(company_id::text, '0') + FROM account_move + WHERE journal_id = $1 AND state = 'posted' + AND inalterable_hash IS NOT NULL AND inalterable_hash != '' + ORDER BY CAST(secure_sequence_number AS INTEGER), id`, journalID) + if err != nil { + return nil, fmt.Errorf("account: query hashed entries: %w", err) + } + defer rows.Close() + + prevHash := "" + entryCount := 0 + for rows.Next() { + var id int64 + var storedHash, baseStr string + if err := rows.Scan(&id, &storedHash, &baseStr); err != nil { + return nil, fmt.Errorf("account: scan hash entry: %w", err) + } + + // Include lines for this entry + lineRows, _ := env.Tx().Query(env.Ctx(), + `SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0) + FROM account_move_line WHERE move_id = $1 ORDER BY id`, id) + stringToHash := baseStr + if lineRows != nil { + for lineRows.Next() { + var accID int64 + var debit, credit float64 + lineRows.Scan(&accID, &debit, &credit) + stringToHash += fmt.Sprintf("|%d:%.2f:%.2f", accID, debit, credit) + } + lineRows.Close() + } + + // Verify hash + hashInput := prevHash + stringToHash + expectedHash := sha256.Sum256([]byte(hashInput)) + expectedHex := fmt.Sprintf("%x", expectedHash) + + if storedHash != expectedHex { + return map[string]interface{}{ + "status": "corrupted", + "message": fmt.Sprintf("Hash chain broken at entry %d", id), + "entries_checked": entryCount, + }, nil + } + + prevHash = storedHash + entryCount++ + } + + return map[string]interface{}{ + "status": "valid", + "message": "All entries verified successfully", + "entries_checked": entryCount, + }, nil + }) +} + +// initAccountSequence registers sequence generation helpers for accounting. +// Mirrors: odoo/addons/account/models/sequence_mixin.py +func initAccountSequence() { + // account.sequence.mixin fields on account.move (already mostly handled via sequence_prefix/number) + // This extends with the date-range based sequences + ext := orm.ExtendModel("account.move") + ext.AddFields( + orm.Char("highest_name", orm.FieldOpts{ + String: "Highest Name", + Compute: "_compute_highest_name", + Help: "Technical: highest sequence name in the same journal for ordering", + }), + orm.Boolean("made_sequence_hole", orm.FieldOpts{ + String: "Sequence Hole", + Help: "Technical: whether this entry created a gap in the sequence", + Default: false, + }), + ) + + ext.RegisterCompute("highest_name", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + moveID := rs.IDs()[0] + + var journalID int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(journal_id, 0) FROM account_move WHERE id = $1`, moveID, + ).Scan(&journalID) + + var highest string + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(MAX(name), '/') FROM account_move + WHERE journal_id = $1 AND name != '/' AND state = 'posted'`, + journalID, + ).Scan(&highest) + + return orm.Values{"highest_name": highest}, nil + }) +} diff --git a/addons/account/models/account_move.go b/addons/account/models/account_move.go index 3df8cd4..953a6dd 100644 --- a/addons/account/models/account_move.go +++ b/addons/account/models/account_move.go @@ -154,6 +154,20 @@ func initAccountMove() { orm.Text("narration", orm.FieldOpts{String: "Terms and Conditions"}), ) + // -- Invoice Responsible & References -- + m.AddFields( + orm.Many2one("invoice_user_id", "res.users", orm.FieldOpts{ + String: "Salesperson", Help: "User responsible for this invoice", + }), + orm.Many2one("reversed_entry_id", "account.move", orm.FieldOpts{ + String: "Reversed Entry", Help: "The move that was reversed to create this", + }), + orm.Char("access_url", orm.FieldOpts{String: "Portal Access URL", Compute: "_compute_access_url"}), + orm.Boolean("invoice_has_outstanding", orm.FieldOpts{ + String: "Has Outstanding Payments", Compute: "_compute_invoice_has_outstanding", + }), + ) + // -- Technical -- m.AddFields( orm.Boolean("auto_post", orm.FieldOpts{String: "Auto-post"}), @@ -161,6 +175,45 @@ func initAccountMove() { orm.Integer("sequence_number", orm.FieldOpts{String: "Sequence Number"}), ) + // _compute_access_url: generates /my/invoices/ for portal access. + // Mirrors: odoo/addons/account/models/account_move.py _compute_access_url() + m.RegisterCompute("access_url", func(rs *orm.Recordset) (orm.Values, error) { + moveID := rs.IDs()[0] + return orm.Values{ + "access_url": fmt.Sprintf("/my/invoices/%d", moveID), + }, nil + }) + + // _compute_invoice_has_outstanding: checks for outstanding payments. + // Mirrors: odoo/addons/account/models/account_move.py _compute_has_outstanding() + m.RegisterCompute("invoice_has_outstanding", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + moveID := rs.IDs()[0] + + var partnerID *int64 + var moveType string + env.Tx().QueryRow(env.Ctx(), + `SELECT partner_id, COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID, + ).Scan(&partnerID, &moveType) + + hasOutstanding := false + if partnerID != nil && *partnerID > 0 && (moveType == "out_invoice" || moveType == "out_refund") { + var count int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM account_move_line l + JOIN account_account a ON a.id = l.account_id + JOIN account_move m ON m.id = l.move_id AND m.state = 'posted' + WHERE l.partner_id = $1 AND a.account_type = 'asset_receivable' + AND l.amount_residual < -0.005 AND l.reconciled = false`, + *partnerID).Scan(&count) + hasOutstanding = count > 0 + } + + return orm.Values{ + "invoice_has_outstanding": hasOutstanding, + }, nil + }) + // -- Computed Fields -- // _compute_amount: sums invoice lines to produce totals. // Mirrors: odoo/addons/account/models/account_move.py AccountMove._compute_amount() @@ -342,6 +395,116 @@ func initAccountMove() { return true, nil }) + // action_reverse: creates a credit note (reversal) for the current move. + // Mirrors: odoo/addons/account/models/account_move.py action_reverse() + m.RegisterMethod("action_reverse", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + env := rs.Env() + for _, moveID := range rs.IDs() { + // Read original move + var partnerID, journalID, companyID, currencyID int64 + var moveType string + err := env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(partner_id,0), COALESCE(journal_id,0), COALESCE(company_id,0), + COALESCE(currency_id,0), COALESCE(move_type,'entry') + FROM account_move WHERE id = $1`, moveID, + ).Scan(&partnerID, &journalID, &companyID, ¤cyID, &moveType) + if err != nil { + return nil, fmt.Errorf("account: read move %d for reversal: %w", moveID, err) + } + + // Determine reverse type + reverseType := moveType + switch moveType { + case "out_invoice": + reverseType = "out_refund" + case "in_invoice": + reverseType = "in_refund" + case "out_refund": + reverseType = "out_invoice" + case "in_refund": + reverseType = "in_invoice" + } + + // Create reverse move + reverseRS := env.Model("account.move") + reverseMoveVals := orm.Values{ + "move_type": reverseType, + "partner_id": partnerID, + "journal_id": journalID, + "company_id": companyID, + "currency_id": currencyID, + "reversed_entry_id": moveID, + "ref": fmt.Sprintf("Reversal of %d", moveID), + } + reverseMove, err := reverseRS.Create(reverseMoveVals) + if err != nil { + return nil, fmt.Errorf("account: create reverse move: %w", err) + } + + // Copy lines with reversed debit/credit + lineRows, err := env.Tx().Query(env.Ctx(), + `SELECT account_id, name, COALESCE(debit::float8, 0), COALESCE(credit::float8, 0), + COALESCE(balance::float8, 0), COALESCE(quantity, 1), COALESCE(price_unit::float8, 0), + COALESCE(display_type, 'product'), partner_id, currency_id + FROM account_move_line WHERE move_id = $1`, moveID) + if err != nil { + return nil, fmt.Errorf("account: read lines for reversal: %w", err) + } + + lineRS := env.Model("account.move.line") + var reverseLines []orm.Values + for lineRows.Next() { + var accID int64 + var name string + var debit, credit, balance, qty, price float64 + var displayType string + var lpID, lcurID *int64 + if err := lineRows.Scan(&accID, &name, &debit, &credit, &balance, &qty, &price, &displayType, &lpID, &lcurID); err != nil { + lineRows.Close() + return nil, fmt.Errorf("account: scan line for reversal: %w", err) + } + + lineVals := orm.Values{ + "move_id": reverseMove.ID(), + "account_id": accID, + "name": name, + "debit": credit, // REVERSED + "credit": debit, // REVERSED + "balance": -balance, // REVERSED + "quantity": qty, + "price_unit": price, + "display_type": displayType, + "company_id": companyID, + "journal_id": journalID, + } + if lpID != nil { + lineVals["partner_id"] = *lpID + } + if lcurID != nil { + lineVals["currency_id"] = *lcurID + } + reverseLines = append(reverseLines, lineVals) + } + lineRows.Close() + + for _, lv := range reverseLines { + if _, err := lineRS.Create(lv); err != nil { + return nil, fmt.Errorf("account: create reverse line: %w", err) + } + } + + return map[string]interface{}{ + "type": "ir.actions.act_window", + "res_model": "account.move", + "res_id": reverseMove.ID(), + "view_mode": "form", + "views": [][]interface{}{{nil, "form"}}, + "target": "current", + }, nil + } + return false, nil + }) + // action_register_payment: opens the payment register wizard. // Mirrors: odoo/addons/account/models/account_move.py action_register_payment() m.RegisterMethod("action_register_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { @@ -873,6 +1036,30 @@ func initAccountMoveLine() { }), ) + // -- Analytic & Tags -- + m.AddFields( + orm.Many2many("tax_tag_ids", "account.account.tag", orm.FieldOpts{ + String: "Tax Tags", + Relation: "account_move_line_account_tag_rel", + Column1: "line_id", + Column2: "tag_id", + }), + orm.Json("analytic_distribution", orm.FieldOpts{ + String: "Analytic Distribution", + Help: "JSON distribution across analytic accounts, e.g. {\"42\": 100}", + }), + ) + + // -- Maturity & Related -- + m.AddFields( + orm.Date("date_maturity", orm.FieldOpts{String: "Due Date"}), + orm.Selection("parent_state", []orm.SelectionItem{ + {Value: "draft", Label: "Draft"}, + {Value: "posted", Label: "Posted"}, + {Value: "cancel", Label: "Cancelled"}, + }, orm.FieldOpts{String: "Parent State", Related: "move_id.state", Store: true}), + ) + // -- Display -- m.AddFields( orm.Selection("display_type", []orm.SelectionItem{ @@ -892,8 +1079,45 @@ func initAccountMoveLine() { m.AddFields( orm.Boolean("reconciled", orm.FieldOpts{String: "Reconciled"}), orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Matching"}), + orm.Char("matching_number", orm.FieldOpts{ + String: "Matching #", Compute: "_compute_matching_number", + Help: "P for partial, full reconcile name otherwise", + }), ) + // _compute_matching_number: derives the matching display from reconcile state. + // Mirrors: odoo/addons/account/models/account_move_line.py _compute_matching_number() + m.RegisterCompute("matching_number", func(rs *orm.Recordset) (orm.Values, error) { + env := rs.Env() + lineID := rs.IDs()[0] + + var fullRecID *int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT full_reconcile_id FROM account_move_line WHERE id = $1`, lineID, + ).Scan(&fullRecID) + + if fullRecID != nil && *fullRecID > 0 { + var name string + env.Tx().QueryRow(env.Ctx(), + `SELECT name FROM account_full_reconcile WHERE id = $1`, *fullRecID, + ).Scan(&name) + return orm.Values{"matching_number": name}, nil + } + + // Check if partially reconciled + var partialCount int + env.Tx().QueryRow(env.Ctx(), + `SELECT COUNT(*) FROM account_partial_reconcile + WHERE debit_move_id = $1 OR credit_move_id = $1`, lineID, + ).Scan(&partialCount) + + if partialCount > 0 { + return orm.Values{"matching_number": "P"}, nil + } + + return orm.Values{"matching_number": ""}, nil + }) + // reconcile: matches debit lines against credit lines and creates // account.partial.reconcile (and optionally account.full.reconcile) records. // Mirrors: odoo/addons/account/models/account_move_line.py AccountMoveLine.reconcile() diff --git a/addons/account/models/account_payment_method.go b/addons/account/models/account_payment_method.go new file mode 100644 index 0000000..5692306 --- /dev/null +++ b/addons/account/models/account_payment_method.go @@ -0,0 +1,58 @@ +package models + +import "odoo-go/pkg/orm" + +// initAccountPaymentMethod registers account.payment.method and account.payment.method.line. +// Mirrors: odoo/addons/account/models/account_payment_method.py +// +// account.payment.method defines how payments are processed (e.g. manual, check, electronic). +// account.payment.method.line links a payment method to a journal with a specific sequence. +func initAccountPaymentMethod() { + m := orm.NewModel("account.payment.method", orm.ModelOpts{ + Description: "Payment Method", + }) + + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true, Translate: true}), + orm.Char("code", orm.FieldOpts{String: "Code", Required: true}), + orm.Selection("payment_type", []orm.SelectionItem{ + {Value: "inbound", Label: "Inbound"}, + {Value: "outbound", Label: "Outbound"}, + }, orm.FieldOpts{String: "Payment Type", Required: true}), + ) + + // -- Payment Method Line -- + // Links a payment method to a journal, controlling which methods are available per journal. + // Mirrors: odoo/addons/account/models/account_payment_method.py AccountPaymentMethodLine + pml := orm.NewModel("account.payment.method.line", orm.ModelOpts{ + Description: "Payment Method Line", + Order: "sequence, id", + }) + + pml.AddFields( + orm.Many2one("payment_method_id", "account.payment.method", orm.FieldOpts{ + String: "Payment Method", Required: true, OnDelete: orm.OnDeleteCascade, + }), + orm.Many2one("journal_id", "account.journal", orm.FieldOpts{ + String: "Journal", Required: true, OnDelete: orm.OnDeleteCascade, + }), + orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), + orm.Char("name", orm.FieldOpts{ + String: "Name", Related: "payment_method_id.name", + }), + orm.Char("code", orm.FieldOpts{ + String: "Code", Related: "payment_method_id.code", + }), + orm.Selection("payment_type", []orm.SelectionItem{ + {Value: "inbound", Label: "Inbound"}, + {Value: "outbound", Label: "Outbound"}, + }, orm.FieldOpts{String: "Payment Type", Related: "payment_method_id.payment_type"}), + orm.Many2one("payment_account_id", "account.account", orm.FieldOpts{ + String: "Outstanding Receipts/Payments Account", + Help: "Account used for outstanding payments/receipts with this method", + }), + orm.Many2one("company_id", "res.company", orm.FieldOpts{ + String: "Company", Related: "journal_id.company_id", + }), + ) +} diff --git a/addons/account/models/account_reconcile_model.go b/addons/account/models/account_reconcile_model.go new file mode 100644 index 0000000..0880df4 --- /dev/null +++ b/addons/account/models/account_reconcile_model.go @@ -0,0 +1,298 @@ +package models + +import ( + "fmt" + "strings" + + "odoo-go/pkg/orm" +) + +// initAccountReconcileModel registers account.reconcile.model — automatic reconciliation rules. +// Mirrors: odoo/addons/account/models/account_reconcile_model.py +// +// Reconcile models define rules that automatically match bank statement lines +// with open invoices or create write-off entries. Three rule types: +// - "writeoff_button": manual write-off via button +// - "writeoff_suggestion": auto-suggest write-off in bank reconciliation +// - "invoice_matching": auto-match with open invoices based on criteria +func initAccountReconcileModel() { + m := orm.NewModel("account.reconcile.model", orm.ModelOpts{ + Description: "Reconcile Model", + Order: "sequence, id", + }) + + m.AddFields( + orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), + orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), + orm.Selection("rule_type", []orm.SelectionItem{ + {Value: "writeoff_button", Label: "Button to generate counterpart entry"}, + {Value: "writeoff_suggestion", Label: "Rule to suggest counterpart entry"}, + {Value: "invoice_matching", Label: "Rule to match invoices/bills"}, + }, orm.FieldOpts{String: "Type", Default: "writeoff_button", Required: true}), + orm.Boolean("auto_reconcile", orm.FieldOpts{ + String: "Auto-validate", + Help: "Validate the statement line automatically if the matched amount is close enough", + }), + orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), + // Matching criteria + orm.Boolean("match_nature", orm.FieldOpts{String: "Amount Nature", Default: true}), + orm.Selection("match_amount", []orm.SelectionItem{ + {Value: "lower", Label: "Is Lower Than"}, + {Value: "greater", Label: "Is Greater Than"}, + {Value: "between", Label: "Is Between"}, + }, orm.FieldOpts{String: "Amount Condition"}), + orm.Float("match_amount_min", orm.FieldOpts{String: "Amount Min"}), + orm.Float("match_amount_max", orm.FieldOpts{String: "Amount Max"}), + orm.Char("match_label", orm.FieldOpts{ + String: "Label", Help: "Regex pattern to match the bank statement line label", + }), + orm.Selection("match_label_param", []orm.SelectionItem{ + {Value: "contains", Label: "Contains"}, + {Value: "not_contains", Label: "Not Contains"}, + {Value: "match_regex", Label: "Match Regex"}, + }, orm.FieldOpts{String: "Label Parameter", Default: "contains"}), + orm.Char("match_note", orm.FieldOpts{String: "Note", Help: "Match on the statement line notes"}), + orm.Selection("match_note_param", []orm.SelectionItem{ + {Value: "contains", Label: "Contains"}, + {Value: "not_contains", Label: "Not Contains"}, + {Value: "match_regex", Label: "Match Regex"}, + }, orm.FieldOpts{String: "Note Parameter", Default: "contains"}), + orm.Many2many("match_journal_ids", "account.journal", orm.FieldOpts{ + String: "Journals", + Relation: "reconcile_model_journal_rel", + Column1: "model_id", + Column2: "journal_id", + }), + orm.Many2many("match_partner_ids", "res.partner", orm.FieldOpts{ + String: "Partners", + Relation: "reconcile_model_partner_rel", + Column1: "model_id", + Column2: "partner_id", + }), + orm.Selection("match_partner_category_id", []orm.SelectionItem{ + {Value: "customer", Label: "Customer"}, + {Value: "supplier", Label: "Vendor"}, + }, orm.FieldOpts{String: "Partner Category"}), + orm.Float("match_total_amount_param", orm.FieldOpts{ + String: "Amount matching %", + Default: 100, + Help: "Percentage of the transaction amount to consider for matching", + }), + orm.Boolean("match_same_currency", orm.FieldOpts{ + String: "Same Currency", Default: true, + }), + orm.Integer("past_months_limit", orm.FieldOpts{ + String: "Search Months Limit", + Default: 18, + Help: "Number of months in the past to consider for matching", + }), + orm.Float("decimal_separator", orm.FieldOpts{String: "Decimal Separator"}), + // Write-off lines + orm.One2many("line_ids", "account.reconcile.model.line", "model_id", orm.FieldOpts{ + String: "Write-off Lines", + }), + ) + + // apply_rules: try to match a bank statement line against this reconcile model. + // Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_rules() + m.RegisterMethod("apply_rules", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { + if len(args) < 1 { + return nil, fmt.Errorf("account: apply_rules requires a statement line ID") + } + env := rs.Env() + modelID := rs.IDs()[0] + + stLineID, ok := toInt64Arg(args[0]) + if !ok { + return nil, fmt.Errorf("account: invalid statement line ID") + } + + // Read model config + var ruleType string + var autoReconcile bool + var matchLabel, matchLabelParam *string + var matchAmountMin, matchAmountMax float64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(rule_type, 'writeoff_button'), COALESCE(auto_reconcile, false), + match_label, match_label_param, + COALESCE(match_amount_min, 0), COALESCE(match_amount_max, 0) + FROM account_reconcile_model WHERE id = $1`, modelID, + ).Scan(&ruleType, &autoReconcile, &matchLabel, &matchLabelParam, &matchAmountMin, &matchAmountMax) + + // Read statement line + var stAmount float64 + var stLabel, stPaymentRef *string + var stPartnerID *int64 + env.Tx().QueryRow(env.Ctx(), + `SELECT COALESCE(amount::float8, 0), payment_ref, narration, partner_id + FROM account_bank_statement_line WHERE id = $1`, stLineID, + ).Scan(&stAmount, &stPaymentRef, &stLabel, &stPartnerID) + + // Apply label matching + if matchLabel != nil && *matchLabel != "" && matchLabelParam != nil { + labelToCheck := "" + if stPaymentRef != nil { + labelToCheck = *stPaymentRef + } + if stLabel != nil { + labelToCheck += " " + *stLabel + } + + switch *matchLabelParam { + case "contains": + if !strings.Contains(strings.ToLower(labelToCheck), strings.ToLower(*matchLabel)) { + return map[string]interface{}{"matched": false}, nil + } + case "not_contains": + if strings.Contains(strings.ToLower(labelToCheck), strings.ToLower(*matchLabel)) { + return map[string]interface{}{"matched": false}, nil + } + } + } + + switch ruleType { + case "invoice_matching": + return applyInvoiceMatching(env, stLineID, stAmount, stPartnerID, autoReconcile) + case "writeoff_suggestion": + return applyWriteoffSuggestion(env, modelID, stLineID, stAmount) + default: + return map[string]interface{}{"matched": false, "rule_type": ruleType}, nil + } + }) + + // -- Reconcile Model Line (write-off definition) -- + rml := orm.NewModel("account.reconcile.model.line", orm.ModelOpts{ + Description: "Reconcile Model Line", + Order: "sequence, id", + }) + + rml.AddFields( + orm.Many2one("model_id", "account.reconcile.model", orm.FieldOpts{ + String: "Model", Required: true, OnDelete: orm.OnDeleteCascade, + }), + orm.Many2one("account_id", "account.account", orm.FieldOpts{ + String: "Account", Required: true, + }), + orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), + orm.Char("label", orm.FieldOpts{String: "Journal Item Label"}), + orm.Selection("amount_type", []orm.SelectionItem{ + {Value: "percentage", Label: "Percentage of balance"}, + {Value: "fixed", Label: "Fixed"}, + {Value: "percentage_st_line", Label: "Percentage of statement line"}, + {Value: "regex", Label: "From label"}, + }, orm.FieldOpts{String: "Amount Type", Default: "percentage", Required: true}), + orm.Float("amount", orm.FieldOpts{String: "Write-off Amount", Default: 100}), + orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{ + String: "Taxes", + Relation: "reconcile_model_line_tax_rel", + Column1: "line_id", + Column2: "tax_id", + }), + orm.Boolean("force_tax_included", orm.FieldOpts{String: "Tax Included in Price"}), + orm.Many2one("company_id", "res.company", orm.FieldOpts{ + String: "Company", Related: "model_id.company_id", + }), + ) +} + +// applyInvoiceMatching tries to find an open invoice matching the statement line amount. +// Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_invoice_matching() +func applyInvoiceMatching(env *orm.Environment, stLineID int64, amount float64, partnerID *int64, autoReconcile bool) (interface{}, error) { + if partnerID == nil || *partnerID == 0 { + return map[string]interface{}{"matched": false, "reason": "no partner"}, nil + } + + absAmount := amount + if absAmount < 0 { + absAmount = -absAmount + } + + // Find open invoices for the partner with matching residual amount + var matchedMoveLineID int64 + var matchedResidual float64 + err := env.Tx().QueryRow(env.Ctx(), + `SELECT l.id, ABS(COALESCE(l.amount_residual::float8, 0)) + FROM account_move_line l + JOIN account_move m ON m.id = l.move_id AND m.state = 'posted' + JOIN account_account a ON a.id = l.account_id + WHERE l.partner_id = $1 + AND a.account_type IN ('asset_receivable', 'liability_payable') + AND ABS(COALESCE(l.amount_residual::float8, 0)) > 0.005 + AND ABS(ABS(COALESCE(l.amount_residual::float8, 0)) - $2) < $2 * 0.05 + ORDER BY ABS(ABS(COALESCE(l.amount_residual::float8, 0)) - $2) + LIMIT 1`, *partnerID, absAmount, + ).Scan(&matchedMoveLineID, &matchedResidual) + + if err != nil || matchedMoveLineID == 0 { + return map[string]interface{}{"matched": false, "reason": "no matching invoice"}, nil + } + + result := map[string]interface{}{ + "matched": true, + "move_line_id": matchedMoveLineID, + "residual": matchedResidual, + "auto_validate": autoReconcile, + } + + // If auto-reconcile, mark the statement line as reconciled + if autoReconcile { + env.Tx().Exec(env.Ctx(), + `UPDATE account_bank_statement_line SET move_line_id = $1, is_reconciled = true WHERE id = $2`, + matchedMoveLineID, stLineID) + } + + return result, nil +} + +// applyWriteoffSuggestion suggests a write-off entry based on the reconcile model's lines. +// Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_writeoff() +func applyWriteoffSuggestion(env *orm.Environment, modelID, stLineID int64, amount float64) (interface{}, error) { + // Read write-off lines for this model + rows, err := env.Tx().Query(env.Ctx(), + `SELECT account_id, COALESCE(label, ''), COALESCE(amount_type, 'percentage'), + COALESCE(amount, 100) + FROM account_reconcile_model_line + WHERE model_id = $1 + ORDER BY sequence, id`, modelID) + if err != nil { + return map[string]interface{}{"matched": false}, nil + } + defer rows.Close() + + var suggestions []map[string]interface{} + for rows.Next() { + var accountID int64 + var label, amountType string + var pct float64 + if err := rows.Scan(&accountID, &label, &amountType, &pct); err != nil { + continue + } + + writeoffAmount := 0.0 + switch amountType { + case "percentage": + writeoffAmount = amount * pct / 100 + case "fixed": + writeoffAmount = pct + case "percentage_st_line": + writeoffAmount = amount * pct / 100 + } + + suggestions = append(suggestions, map[string]interface{}{ + "account_id": accountID, + "label": label, + "amount": writeoffAmount, + }) + } + + if len(suggestions) == 0 { + return map[string]interface{}{"matched": false}, nil + } + + return map[string]interface{}{ + "matched": true, + "rule_type": "writeoff_suggestion", + "suggestions": suggestions, + }, nil +} diff --git a/addons/account/models/account_report_html.go b/addons/account/models/account_report_html.go new file mode 100644 index 0000000..73c2b05 --- /dev/null +++ b/addons/account/models/account_report_html.go @@ -0,0 +1,129 @@ +package models + +import ( + "fmt" + "strings" + + "odoo-go/pkg/orm" +) + +// RenderReportHTML generates an HTML table for a report type. +// Mirrors: odoo/addons/account_reports/models/account_report.py _get_html() +// +// Supports: trial_balance, balance_sheet, profit_loss, aged_receivable, +// aged_payable, general_ledger. +func RenderReportHTML(env *orm.Environment, reportType string) (string, error) { + var data interface{} + var err error + + switch reportType { + case "trial_balance": + data, err = generateTrialBalance(env) + case "balance_sheet": + data, err = generateBalanceSheet(env) + case "profit_loss": + data, err = generateProfitLoss(env) + case "aged_receivable": + data, err = generateAgedReport(env, "asset_receivable") + case "aged_payable": + data, err = generateAgedReport(env, "liability_payable") + case "general_ledger": + data, err = generateGeneralLedger(env) + default: + return "", fmt.Errorf("unknown report: %s", reportType) + } + if err != nil { + return "", err + } + + result, ok := data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("invalid report data") + } + lines, _ := result["lines"].([]map[string]interface{}) + + var b strings.Builder + b.WriteString(``) + + // Build table based on report type + switch reportType { + case "trial_balance": + b.WriteString("

Trial Balance

") + b.WriteString(``) + for _, l := range lines { + b.WriteString(fmt.Sprintf("", + l["code"], l["name"], toF(l["debit"]), toF(l["credit"]), toF(l["balance"]))) + } + b.WriteString("
CodeAccountDebitCreditBalance
%v%v%.2f%.2f%.2f
") + + case "balance_sheet": + b.WriteString("

Balance Sheet

") + b.WriteString(``) + for _, l := range lines { + b.WriteString(fmt.Sprintf("", + l["section"], l["code"], l["name"], toF(l["balance"]))) + } + b.WriteString("
SectionCodeAccountBalance
%v%v%v%.2f
") + + case "profit_loss": + b.WriteString("

Profit & Loss

") + b.WriteString(``) + for _, l := range lines { + b.WriteString(fmt.Sprintf("", + l["section"], l["code"], l["name"], toF(l["balance"]))) + } + b.WriteString("
SectionCodeAccountAmount
%v%v%v%.2f
") + + case "aged_receivable", "aged_payable": + title := "Aged Receivable" + if reportType == "aged_payable" { + title = "Aged Payable" + } + b.WriteString(fmt.Sprintf("

%s

", title)) + b.WriteString(``) + for _, l := range lines { + b.WriteString(fmt.Sprintf("", + l["partner"], toF(l["current"]), toF(l["1-30"]), toF(l["31-60"]), toF(l["61-90+"]), toF(l["total"]))) + } + b.WriteString("
PartnerCurrent1-3031-6061-90+Total
%v%.2f%.2f%.2f%.2f%.2f
") + + case "general_ledger": + b.WriteString("

General Ledger

") + b.WriteString(``) + for _, l := range lines { + b.WriteString(fmt.Sprintf("", + l["account_code"], l["account_name"], l["move"], l["date"], l["label"], + toF(l["debit"]), toF(l["credit"]))) + } + b.WriteString("
AccountMoveDateLabelDebitCredit
%v %v%v%v%v%.2f%.2f
") + } + + b.WriteString("") + + return b.String(), nil +} + +// toF converts various numeric types to float64 for formatting. +func toF(v interface{}) float64 { + switch n := v.(type) { + case float64: + return n + case int64: + return float64(n) + case int: + return float64(n) + case int32: + return float64(n) + } + return 0 +} diff --git a/addons/account/models/init.go b/addons/account/models/init.go index c8cdd49..8a4c7f9 100644 --- a/addons/account/models/init.go +++ b/addons/account/models/init.go @@ -17,4 +17,15 @@ func Init() { initAccountAnalytic() initAccountRecurring() initAccountCompanyExtension() + initAccountPaymentMethod() + initAccountAsset() + initAccountBudget() + initAccountCashRounding() + initAccountInvoiceSend() + initAccountCashRoundingOnMove() + initAccountFollowup() + initAccountLock() + initAccountSequence() + initAccountEdi() + initAccountReconcileModel() }