Account module massive expansion: 2499→5049 LOC (+2550)

New models (12):
- account.asset: depreciation (linear/degressive), journal entry generation
- account.edi.format + account.edi.document: UBL 2.1 XML e-invoicing
- account.followup.line: payment follow-up escalation levels
- account.reconcile.model + lines: automatic bank reconciliation rules
- crossovered.budget + lines + account.budget.post: budgeting system
- account.cash.rounding: invoice rounding (UP/DOWN/HALF-UP)
- account.payment.method + lines: payment method definitions
- account.invoice.send: invoice sending wizard

Enhanced existing:
- account.move: action_reverse (credit notes), access_url, invoice_has_outstanding
- account.move.line: tax_tag_ids, analytic_distribution, date_maturity, matching_number
- Entry hash chain integrity (SHA-256, secure_sequence_number)
- Report HTML rendering for all 6 report types
- res.partner extended with followup status + overdue tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc
2026-04-03 21:59:50 +02:00
parent b8fa4719ad
commit 0a76a2b9aa
11 changed files with 2572 additions and 0 deletions

View File

@@ -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, &currencyID, &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",
}),
)
}

View File

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

View File

@@ -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",
}),
)
}

View File

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

View File

@@ -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(`<style>
body{font-family:Arial;margin:20px}
table{width:100%;border-collapse:collapse;margin-top:12px}
th,td{border:1px solid #ddd;padding:6px 8px;text-align:right}
th{background:#875a7b;color:white}
td:first-child,th:first-child{text-align:left}
.overdue{color:#d9534f;font-weight:bold}
h2{color:#875a7b}
</style>`)
b.WriteString(fmt.Sprintf("<h2>Payment Follow-up: %s</h2>", partnerName))
b.WriteString(`<table><tr><th>Invoice</th><th>Due Date</th><th>Total</th><th>Amount Due</th><th>Overdue Days</th></tr>`)
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(`<tr><td>%s</td><td>%v</td><td>%.2f</td><td>%.2f</td><td%s>%d</td></tr>`,
invName, dueDate, total, residual, overdueClass, overdueDays))
}
b.WriteString(fmt.Sprintf(`<tr style="font-weight:bold;background:#f5f5f5"><td colspan="3">Total Due</td><td>%.2f</td><td></td></tr>`, totalDue))
b.WriteString("</table>")
// 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(`<div style="margin-top:20px;padding:10px;background:#fff3cd;border:1px solid #ffc107;border-radius:4px">%s</div>`, *description))
}
return b.String(), nil
})
}

View File

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

View File

@@ -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/<id> 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, &currencyID, &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()

View File

@@ -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",
}),
)
}

View File

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

View File

@@ -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(`<!DOCTYPE html><html><head><meta charset="utf-8"><style>
body{font-family:Arial,Helvetica,sans-serif;margin:20px;color:#333}
table{width:100%;border-collapse:collapse;margin-top:12px}
th,td{border:1px solid #ddd;padding:6px 8px;text-align:right}
th{background:#875a7b;color:white;font-weight:600}
td:first-child,th:first-child{text-align:left}
tr:last-child{font-weight:bold;background:#f5f5f5}
tr:hover{background:#faf5f8}
h2{color:#875a7b;margin-bottom:4px}
.report-date{color:#888;font-size:0.85em;margin-bottom:12px}
</style></head><body>`)
// Build table based on report type
switch reportType {
case "trial_balance":
b.WriteString("<h2>Trial Balance</h2>")
b.WriteString(`<table><tr><th>Code</th><th>Account</th><th>Debit</th><th>Credit</th><th>Balance</th></tr>`)
for _, l := range lines {
b.WriteString(fmt.Sprintf("<tr><td>%v</td><td>%v</td><td>%.2f</td><td>%.2f</td><td>%.2f</td></tr>",
l["code"], l["name"], toF(l["debit"]), toF(l["credit"]), toF(l["balance"])))
}
b.WriteString("</table>")
case "balance_sheet":
b.WriteString("<h2>Balance Sheet</h2>")
b.WriteString(`<table><tr><th>Section</th><th>Code</th><th>Account</th><th>Balance</th></tr>`)
for _, l := range lines {
b.WriteString(fmt.Sprintf("<tr><td>%v</td><td>%v</td><td>%v</td><td>%.2f</td></tr>",
l["section"], l["code"], l["name"], toF(l["balance"])))
}
b.WriteString("</table>")
case "profit_loss":
b.WriteString("<h2>Profit &amp; Loss</h2>")
b.WriteString(`<table><tr><th>Section</th><th>Code</th><th>Account</th><th>Amount</th></tr>`)
for _, l := range lines {
b.WriteString(fmt.Sprintf("<tr><td>%v</td><td>%v</td><td>%v</td><td>%.2f</td></tr>",
l["section"], l["code"], l["name"], toF(l["balance"])))
}
b.WriteString("</table>")
case "aged_receivable", "aged_payable":
title := "Aged Receivable"
if reportType == "aged_payable" {
title = "Aged Payable"
}
b.WriteString(fmt.Sprintf("<h2>%s</h2>", title))
b.WriteString(`<table><tr><th>Partner</th><th>Current</th><th>1-30</th><th>31-60</th><th>61-90+</th><th>Total</th></tr>`)
for _, l := range lines {
b.WriteString(fmt.Sprintf("<tr><td>%v</td><td>%.2f</td><td>%.2f</td><td>%.2f</td><td>%.2f</td><td>%.2f</td></tr>",
l["partner"], toF(l["current"]), toF(l["1-30"]), toF(l["31-60"]), toF(l["61-90+"]), toF(l["total"])))
}
b.WriteString("</table>")
case "general_ledger":
b.WriteString("<h2>General Ledger</h2>")
b.WriteString(`<table><tr><th>Account</th><th>Move</th><th>Date</th><th>Label</th><th>Debit</th><th>Credit</th></tr>`)
for _, l := range lines {
b.WriteString(fmt.Sprintf("<tr><td>%v %v</td><td>%v</td><td>%v</td><td>%v</td><td>%.2f</td><td>%.2f</td></tr>",
l["account_code"], l["account_name"], l["move"], l["date"], l["label"],
toF(l["debit"]), toF(l["credit"])))
}
b.WriteString("</table>")
}
b.WriteString("</body></html>")
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
}

View File

@@ -17,4 +17,15 @@ func Init() {
initAccountAnalytic()
initAccountRecurring()
initAccountCompanyExtension()
initAccountPaymentMethod()
initAccountAsset()
initAccountBudget()
initAccountCashRounding()
initAccountInvoiceSend()
initAccountCashRoundingOnMove()
initAccountFollowup()
initAccountLock()
initAccountSequence()
initAccountEdi()
initAccountReconcileModel()
}