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 } // Use prorata_date or acquisition_date as start, fallback to now startDate := time.Now() var prorataDate, acquisitionDate *time.Time env.Tx().QueryRow(env.Ctx(), `SELECT prorata_date, acquisition_date FROM account_asset WHERE id = $1`, assetID, ).Scan(&prorataDate, &acquisitionDate) if prorataDate != nil { startDate = *prorataDate } else if acquisitionDate != nil { startDate = *acquisitionDate } 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 }) // action_create_deferred_entries: generate recognition entries for deferred // revenue (sale) or deferred expense assets. // Mirrors: odoo/addons/account_asset/models/account_asset.py _generate_deferred_entries() // // Unlike depreciation (which expenses an asset), deferred entries recognise // income or expense over time. Monthly amount = original_value / method_number. // Debit: deferred account (asset/liability), Credit: income/expense account. m.RegisterMethod("action_create_deferred_entries", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() assetID := rs.IDs()[0] var name, assetType, state string var journalID, companyID, assetAccountID, expenseAccountID int64 var currencyID *int64 var originalValue float64 var methodNumber int err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, ''), COALESCE(asset_type, 'purchase'), COALESCE(journal_id, 0), COALESCE(company_id, 0), COALESCE(account_asset_id, 0), COALESCE(account_depreciation_expense_id, 0), currency_id, COALESCE(state, 'draft'), COALESCE(original_value::float8, 0), COALESCE(method_number, 1) FROM account_asset WHERE id = $1`, assetID, ).Scan(&name, &assetType, &journalID, &companyID, &assetAccountID, &expenseAccountID, ¤cyID, &state, &originalValue, &methodNumber) if err != nil { return nil, fmt.Errorf("account: read asset %d: %w", assetID, err) } if assetType != "sale" && assetType != "expense" { return nil, fmt.Errorf("account: deferred entries only apply to deferred revenue (sale) or deferred expense assets, got %q", assetType) } if state != "open" { return nil, fmt.Errorf("account: can only create deferred entries for running assets") } if journalID == 0 || assetAccountID == 0 || expenseAccountID == 0 { return nil, fmt.Errorf("account: asset %d is missing journal or account configuration", assetID) } if methodNumber <= 0 { methodNumber = 1 } monthlyAmount := math.Round(originalValue/float64(methodNumber)*100) / 100 // How many entries already exist? var existingCount int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM account_move WHERE asset_id = $1`, assetID, ).Scan(&existingCount) if existingCount >= methodNumber { return nil, fmt.Errorf("account: all deferred entries already created (%d/%d)", existingCount, methodNumber) } // Resolve currency 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) } // Determine start date startDate := time.Now() var acqDate *time.Time env.Tx().QueryRow(env.Ctx(), `SELECT acquisition_date FROM account_asset WHERE id = $1`, assetID, ).Scan(&acqDate) if acqDate != nil { startDate = *acqDate } entryDate := startDate.AddDate(0, existingCount+1, 0).Format("2006-01-02") period := existingCount + 1 // Last entry absorbs rounding remainder amount := monthlyAmount if period == methodNumber { var alreadyRecognised 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 l.account_id = $2`, assetID, expenseAccountID, ).Scan(&alreadyRecognised) amount = math.Round((originalValue-alreadyRecognised)*100) / 100 } // Create the recognition journal entry moveRS := env.Model("account.move") move, err := moveRS.Create(orm.Values{ "move_type": "entry", "ref": fmt.Sprintf("Deferred recognition: %s (%d/%d)", name, period, methodNumber), "date": entryDate, "journal_id": journalID, "company_id": companyID, "currency_id": curID, "asset_id": assetID, }) if err != nil { return nil, fmt.Errorf("account: create deferred entry: %w", err) } lineRS := env.Model("account.move.line") // Debit: deferred account (asset account — the balance sheet deferral) if _, err := lineRS.Create(orm.Values{ "move_id": move.ID(), "account_id": assetAccountID, "name": fmt.Sprintf("Deferred recognition: %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 deferred debit line: %w", err) } // Credit: income/expense account if _, err := lineRS.Create(orm.Values{ "move_id": move.ID(), "account_id": expenseAccountID, "name": fmt.Sprintf("Deferred recognition: %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 deferred credit 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", }), ) }