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(`| Invoice | Due Date | Total | Amount Due | Overdue Days |
`)
+
+ 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(`| %s | %v | %.2f | %.2f | %d |
`,
+ invName, dueDate, total, residual, overdueClass, overdueDays))
+ }
+
+ b.WriteString(fmt.Sprintf(`| Total Due | %.2f | |
`, totalDue))
+ b.WriteString("
")
+
+ // 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(`| Code | Account | Debit | Credit | Balance |
`)
+ for _, l := range lines {
+ b.WriteString(fmt.Sprintf("| %v | %v | %.2f | %.2f | %.2f |
",
+ l["code"], l["name"], toF(l["debit"]), toF(l["credit"]), toF(l["balance"])))
+ }
+ b.WriteString("
")
+
+ case "balance_sheet":
+ b.WriteString("Balance Sheet
")
+ b.WriteString(`| Section | Code | Account | Balance |
`)
+ for _, l := range lines {
+ b.WriteString(fmt.Sprintf("| %v | %v | %v | %.2f |
",
+ l["section"], l["code"], l["name"], toF(l["balance"])))
+ }
+ b.WriteString("
")
+
+ case "profit_loss":
+ b.WriteString("Profit & Loss
")
+ b.WriteString(`| Section | Code | Account | Amount |
`)
+ for _, l := range lines {
+ b.WriteString(fmt.Sprintf("| %v | %v | %v | %.2f |
",
+ l["section"], l["code"], l["name"], toF(l["balance"])))
+ }
+ b.WriteString("
")
+
+ case "aged_receivable", "aged_payable":
+ title := "Aged Receivable"
+ if reportType == "aged_payable" {
+ title = "Aged Payable"
+ }
+ b.WriteString(fmt.Sprintf("%s
", title))
+ b.WriteString(`| Partner | Current | 1-30 | 31-60 | 61-90+ | Total |
`)
+ for _, l := range lines {
+ b.WriteString(fmt.Sprintf("| %v | %.2f | %.2f | %.2f | %.2f | %.2f |
",
+ l["partner"], toF(l["current"]), toF(l["1-30"]), toF(l["31-60"]), toF(l["61-90+"]), toF(l["total"])))
+ }
+ b.WriteString("
")
+
+ case "general_ledger":
+ b.WriteString("General Ledger
")
+ b.WriteString(`| Account | Move | Date | Label | Debit | Credit |
`)
+ for _, l := range lines {
+ b.WriteString(fmt.Sprintf("| %v %v | %v | %v | %v | %.2f | %.2f |
",
+ l["account_code"], l["account_name"], l["move"], l["date"], l["label"],
+ toF(l["debit"]), toF(l["credit"])))
+ }
+ b.WriteString("
")
+ }
+
+ 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()
}