package models import ( "fmt" "time" "odoo-go/pkg/orm" ) // initAccountJournal registers account.journal — where entries are posted. // Mirrors: odoo/addons/account/models/account_journal.py func initAccountJournal() { m := orm.NewModel("account.journal", orm.ModelOpts{ Description: "Journal", Order: "sequence, type, code", }) m.AddFields( orm.Char("name", orm.FieldOpts{String: "Journal Name", Required: true, Translate: true}), orm.Char("code", orm.FieldOpts{String: "Short Code", Required: true, Size: 5}), orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), orm.Selection("type", []orm.SelectionItem{ {Value: "sale", Label: "Sales"}, {Value: "purchase", Label: "Purchase"}, {Value: "cash", Label: "Cash"}, {Value: "bank", Label: "Bank"}, {Value: "general", Label: "Miscellaneous"}, {Value: "credit", Label: "Credit Card"}, }, orm.FieldOpts{String: "Type", Required: true}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), orm.Many2one("default_account_id", "account.account", orm.FieldOpts{String: "Default Account"}), orm.Many2one("suspense_account_id", "account.account", orm.FieldOpts{String: "Suspense Account"}), orm.Many2one("profit_account_id", "account.account", orm.FieldOpts{String: "Profit Account"}), orm.Many2one("loss_account_id", "account.account", orm.FieldOpts{String: "Loss Account"}), orm.Many2one("sequence_id", "ir.sequence", orm.FieldOpts{String: "Entry Sequence"}), orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), orm.Integer("color", orm.FieldOpts{String: "Color"}), orm.Char("bank_acc_number", orm.FieldOpts{String: "Account Number"}), orm.Many2one("bank_id", "res.bank", orm.FieldOpts{String: "Bank"}), orm.Many2one("bank_account_id", "res.partner.bank", orm.FieldOpts{String: "Bank Account"}), orm.Boolean("restrict_mode_hash_table", orm.FieldOpts{String: "Lock Posted Entries with Hash"}), ) } // initAccountMove registers account.move — the core journal entry / invoice model. // Mirrors: odoo/addons/account/models/account_move.py // // account.move is THE central model in Odoo accounting. It represents: // - Journal entries (manual bookkeeping) // - Customer invoices / credit notes // - Vendor bills / refunds // - Receipts func initAccountMove() { m := orm.NewModel("account.move", orm.ModelOpts{ Description: "Journal Entry", Order: "date desc, name desc, id desc", RecName: "name", }) // -- Identity -- m.AddFields( orm.Char("name", orm.FieldOpts{String: "Number", Index: true, Readonly: true, Default: "/"}), orm.Date("date", orm.FieldOpts{String: "Date", Required: true, Index: true}), orm.Char("ref", orm.FieldOpts{String: "Reference"}), ) // -- Type & State -- m.AddFields( orm.Selection("move_type", []orm.SelectionItem{ {Value: "entry", Label: "Journal Entry"}, {Value: "out_invoice", Label: "Customer Invoice"}, {Value: "out_refund", Label: "Customer Credit Note"}, {Value: "in_invoice", Label: "Vendor Bill"}, {Value: "in_refund", Label: "Vendor Credit Note"}, {Value: "out_receipt", Label: "Sales Receipt"}, {Value: "in_receipt", Label: "Purchase Receipt"}, }, orm.FieldOpts{String: "Type", Required: true, Default: "entry", Index: true}), orm.Selection("state", []orm.SelectionItem{ {Value: "draft", Label: "Draft"}, {Value: "posted", Label: "Posted"}, {Value: "cancel", Label: "Cancelled"}, }, orm.FieldOpts{String: "Status", Default: "draft", Required: true, Readonly: true, Index: true}), orm.Selection("payment_state", []orm.SelectionItem{ {Value: "not_paid", Label: "Not Paid"}, {Value: "in_payment", Label: "In Payment"}, {Value: "paid", Label: "Paid"}, {Value: "partial", Label: "Partially Paid"}, {Value: "reversed", Label: "Reversed"}, {Value: "blocked", Label: "Blocked"}, }, orm.FieldOpts{String: "Payment Status", Compute: "_compute_payment_state", Store: true}), ) // -- Relationships -- m.AddFields( orm.Many2one("journal_id", "account.journal", orm.FieldOpts{ String: "Journal", Required: true, Index: true, }), orm.Many2one("company_id", "res.company", orm.FieldOpts{ String: "Company", Required: true, Index: true, }), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{ String: "Currency", Required: true, }), orm.Many2one("partner_id", "res.partner", orm.FieldOpts{ String: "Partner", Index: true, }), orm.Many2one("commercial_partner_id", "res.partner", orm.FieldOpts{ String: "Commercial Entity", Related: "partner_id.commercial_partner_id", }), orm.Many2one("fiscal_position_id", "account.fiscal.position", orm.FieldOpts{ String: "Fiscal Position", }), orm.Many2one("partner_bank_id", "res.partner.bank", orm.FieldOpts{ String: "Recipient Bank", }), ) // -- Lines -- m.AddFields( orm.One2many("line_ids", "account.move.line", "move_id", orm.FieldOpts{ String: "Journal Items", }), orm.One2many("invoice_line_ids", "account.move.line", "move_id", orm.FieldOpts{ String: "Invoice Lines", }), ) // -- Multi-Currency -- m.AddFields( orm.Many2one("company_currency_id", "res.currency", orm.FieldOpts{ String: "Company Currency", Related: "company_id.currency_id", }), ) // -- Amounts (Computed) -- m.AddFields( orm.Monetary("amount_untaxed", orm.FieldOpts{String: "Untaxed Amount", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}), orm.Monetary("amount_tax", orm.FieldOpts{String: "Tax", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}), orm.Monetary("amount_total", orm.FieldOpts{String: "Total", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}), orm.Monetary("amount_residual", orm.FieldOpts{String: "Amount Due", Compute: "_compute_amount", Store: true, CurrencyField: "currency_id"}), orm.Monetary("amount_total_in_currency_signed", orm.FieldOpts{String: "Total in Currency Signed", Compute: "_compute_amount", Store: true}), orm.Monetary("amount_total_signed", orm.FieldOpts{String: "Total Signed", Compute: "_compute_amount", Store: true, CurrencyField: "company_currency_id"}), ) // -- Invoice specific -- m.AddFields( orm.Date("invoice_date", orm.FieldOpts{String: "Invoice/Bill Date"}), orm.Date("invoice_date_due", orm.FieldOpts{String: "Due Date"}), orm.Char("invoice_origin", orm.FieldOpts{String: "Source Document"}), orm.Many2one("invoice_payment_term_id", "account.payment.term", orm.FieldOpts{ String: "Payment Terms", }), 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"}), orm.Char("sequence_prefix", orm.FieldOpts{String: "Sequence Prefix"}), 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() // // Separates untaxed (product lines) from tax (tax lines) via display_type, // then derives total = untaxed + tax. computeAmount := func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] var untaxed, tax float64 err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(CASE WHEN display_type IS NULL OR display_type = '' OR display_type = 'product' THEN ABS(balance::float8) ELSE 0 END), 0), COALESCE(SUM(CASE WHEN display_type = 'tax' THEN ABS(balance::float8) ELSE 0 END), 0) FROM account_move_line WHERE move_id = $1`, moveID, ).Scan(&untaxed, &tax) if err != nil { return nil, err } total := untaxed + tax // amount_residual: actual remaining amount from payment_term line residuals. // Mirrors: odoo/addons/account/models/account_move.py _compute_amount() // For invoices, residual = sum of absolute residuals on receivable/payable lines. // Falls back to total if no payment_term lines exist. var residual float64 var hasPTLines bool err = env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(ABS(amount_residual::float8)), 0), COUNT(*) > 0 FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term'`, moveID).Scan(&residual, &hasPTLines) if err != nil || !hasPTLines { residual = total } // amount_total_signed: total in company currency (sign depends on move type) // For customer invoices/receipts the sign is positive, for credit notes negative. var moveType string var currencyID int64 var moveDate *string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(move_type, 'entry'), COALESCE(currency_id, 0), date::text FROM account_move WHERE id = $1`, moveID, ).Scan(&moveType, ¤cyID, &moveDate) sign := 1.0 if moveType == "out_refund" || moveType == "in_refund" { sign = -1.0 } // _compute_amount_total_in_currency_signed: multiply total by currency rate. // Mirrors: odoo/addons/account/models/account_move.py _compute_amount_total_in_currency_signed() // The currency rate converts the move total to the document currency. currencyRate := 1.0 if currencyID > 0 { dateCond := time.Now().Format("2006-01-02") if moveDate != nil && *moveDate != "" { dateCond = *moveDate } var rate float64 err = env.Tx().QueryRow(env.Ctx(), `SELECT rate FROM res_currency_rate WHERE currency_id = $1 AND name <= $2 ORDER BY name DESC LIMIT 1`, currencyID, dateCond, ).Scan(&rate) if err == nil && rate > 0 { currencyRate = rate } } return orm.Values{ "amount_untaxed": untaxed, "amount_tax": tax, "amount_total": total, "amount_residual": residual, "amount_total_signed": total * sign, "amount_total_in_currency_signed": total * sign * currencyRate, }, nil } m.RegisterCompute("amount_untaxed", computeAmount) m.RegisterCompute("amount_tax", computeAmount) m.RegisterCompute("amount_total", computeAmount) m.RegisterCompute("amount_residual", computeAmount) m.RegisterCompute("amount_total_signed", computeAmount) m.RegisterCompute("amount_total_in_currency_signed", computeAmount) // _compute_payment_state: derives payment status from receivable/payable line residuals. // Mirrors: odoo/addons/account/models/account_move.py _compute_payment_state() // // not_paid: no payment at all // partial: some lines partially reconciled // in_payment: payment registered but not yet fully matched // paid: fully reconciled (residual ~ 0) // reversed: reversed entry m.RegisterCompute("payment_state", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] var moveType, state string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(move_type, 'entry'), COALESCE(state, 'draft') FROM account_move WHERE id = $1`, moveID, ).Scan(&moveType, &state) // Only invoices/receipts have payment_state; journal entries are always not_paid if moveType == "entry" || state != "posted" { return orm.Values{"payment_state": "not_paid"}, nil } // Check if this is a reversal var reversedID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT reversed_entry_id FROM account_move WHERE id = $1`, moveID, ).Scan(&reversedID) if reversedID != nil && *reversedID > 0 { return orm.Values{"payment_state": "reversed"}, nil } // Sum the payment_term lines' balance and residual var totalBalance, totalResidual float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(ABS(balance::float8)), 0), COALESCE(SUM(ABS(amount_residual::float8)), 0) FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term'`, moveID).Scan(&totalBalance, &totalResidual) if totalBalance == 0 { return orm.Values{"payment_state": "not_paid"}, nil } pState := "not_paid" if totalResidual < 0.005 { pState = "paid" } else if totalResidual < totalBalance-0.005 { pState = "partial" } return orm.Values{"payment_state": pState}, nil }) // -- Business Methods: State Transitions -- // Mirrors: odoo/addons/account/models/account_move.py action_post(), button_cancel() // action_post: draft → posted m.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM account_move WHERE id = $1`, id).Scan(&state) if err != nil { return nil, err } if state != "draft" { return nil, fmt.Errorf("account: can only post draft entries (current: %s)", state) } // Check lock dates // Mirrors: odoo/addons/account/models/account_move.py _check_fiscalyear_lock_date() var moveDate, periodLock, fiscalLock, taxLock *string env.Tx().QueryRow(env.Ctx(), `SELECT m.date::text, c.period_lock_date::text, c.fiscalyear_lock_date::text, c.tax_lock_date::text FROM account_move m JOIN res_company c ON c.id = m.company_id WHERE m.id = $1`, id, ).Scan(&moveDate, &periodLock, &fiscalLock, &taxLock) if fiscalLock != nil && moveDate != nil && *moveDate <= *fiscalLock { return nil, fmt.Errorf("account: cannot post entry dated %s, fiscal year is locked until %s", *moveDate, *fiscalLock) } if periodLock != nil && moveDate != nil && *moveDate <= *periodLock { return nil, fmt.Errorf("account: cannot post entry dated %s, period is locked until %s", *moveDate, *periodLock) } if taxLock != nil && moveDate != nil && *moveDate <= *taxLock { return nil, fmt.Errorf("account: cannot post entry dated %s, tax return is locked until %s", *moveDate, *taxLock) } // Check partner is set for invoice types var moveType string env.Tx().QueryRow(env.Ctx(), `SELECT move_type FROM account_move WHERE id = $1`, id).Scan(&moveType) if moveType != "entry" { var partnerID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT partner_id FROM account_move WHERE id = $1`, id).Scan(&partnerID) if partnerID == nil || *partnerID == 0 { return nil, fmt.Errorf("account: invoice requires a partner") } } // Check at least one line exists var lineCount int env.Tx().QueryRow(env.Ctx(), `SELECT count(*) FROM account_move_line WHERE move_id = $1`, id).Scan(&lineCount) if lineCount == 0 { return nil, fmt.Errorf("account: cannot post an entry with no lines") } // Check balanced var debitSum, creditSum float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(debit),0), COALESCE(SUM(credit),0) FROM account_move_line WHERE move_id = $1`, id).Scan(&debitSum, &creditSum) diff := debitSum - creditSum if diff < -0.005 || diff > 0.005 { return nil, fmt.Errorf("account: cannot post unbalanced entry (debit=%.2f, credit=%.2f)", debitSum, creditSum) } // Assign sequence number if name is still "/" var name string env.Tx().QueryRow(env.Ctx(), `SELECT name FROM account_move WHERE id = $1`, id).Scan(&name) if name == "/" || name == "" { // Generate sequence number var journalID int64 var journalCode string env.Tx().QueryRow(env.Ctx(), `SELECT j.id, j.code FROM account_journal j JOIN account_move m ON m.journal_id = j.id WHERE m.id = $1`, id, ).Scan(&journalID, &journalCode) // Get next sequence number (with row lock to prevent race conditions) var nextNum int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(MAX(sequence_number), 0) + 1 FROM account_move WHERE journal_id = $1 FOR UPDATE`, journalID).Scan(&nextNum) // Format: journalCode/YYYY/NNNN year := time.Now().Format("2006") newName := fmt.Sprintf("%s/%s/%04d", journalCode, year, nextNum) env.Tx().Exec(env.Ctx(), `UPDATE account_move SET name = $1, sequence_number = $2 WHERE id = $3`, newName, nextNum, id) } if _, err := env.Tx().Exec(env.Ctx(), `UPDATE account_move SET state = 'posted' WHERE id = $1`, id); err != nil { return nil, err } } return true, nil }) // button_cancel: posted → cancel (via draft) or draft → cancel. // Mirrors: odoo/addons/account/models/account_move.py button_cancel() // Python Odoo resets posted moves to draft first, then cancels from draft. // Also unreconciles all lines and cancels linked payments. m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM account_move WHERE id = $1`, id).Scan(&state) if err != nil { return nil, err } // Posted moves go to draft first (mirrors Python: moves_to_reset_draft) if state == "posted" { // Remove reconciliation on all lines of this move lineRows, lErr := env.Tx().Query(env.Ctx(), `SELECT id FROM account_move_line WHERE move_id = $1`, id) if lErr == nil { var lineIDs []int64 for lineRows.Next() { var lid int64 if lineRows.Scan(&lid) == nil { lineIDs = append(lineIDs, lid) } } lineRows.Close() for _, lid := range lineIDs { env.Tx().Exec(env.Ctx(), `DELETE FROM account_partial_reconcile WHERE debit_move_id = $1 OR credit_move_id = $1`, lid) env.Tx().Exec(env.Ctx(), `UPDATE account_move_line SET amount_residual = balance, full_reconcile_id = NULL, reconciled = false WHERE id = $1`, lid) } // Clean orphaned full reconciles env.Tx().Exec(env.Ctx(), `DELETE FROM account_full_reconcile WHERE id NOT IN (SELECT DISTINCT full_reconcile_id FROM account_partial_reconcile WHERE full_reconcile_id IS NOT NULL)`) } // Reset to draft first env.Tx().Exec(env.Ctx(), `UPDATE account_move SET state = 'draft' WHERE id = $1`, id) state = "draft" } if state != "draft" { return nil, fmt.Errorf("account: only draft journal entries can be cancelled (current: %s)", state) } // Cancel linked payments env.Tx().Exec(env.Ctx(), `UPDATE account_payment SET state = 'canceled' WHERE move_id = $1`, id) // Set to cancel, disable auto_post if _, err := env.Tx().Exec(env.Ctx(), `UPDATE account_move SET state = 'cancel', auto_post = false WHERE id = $1`, id); err != nil { return nil, err } } return true, nil }) // button_draft: cancel/posted → draft // Mirrors: odoo/addons/account/models/account_move.py button_draft() // Python Odoo allows both posted AND cancelled entries to be reset to draft. m.RegisterMethod("button_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string err := env.Tx().QueryRow(env.Ctx(), `SELECT state FROM account_move WHERE id = $1`, id).Scan(&state) if err != nil { return nil, err } if state != "cancel" && state != "posted" { return nil, fmt.Errorf("account: only posted/cancelled journal entries can be reset to draft (current: %s)", state) } // If posted, check that the entry is not hashed (immutable audit trail) if state == "posted" { var hash *string env.Tx().QueryRow(env.Ctx(), `SELECT inalterable_hash FROM account_move WHERE id = $1`, id).Scan(&hash) if hash != nil && *hash != "" { return nil, fmt.Errorf("account: cannot reset to draft — entry is locked with hash") } } // Remove analytic lines linked to this move's journal items env.Tx().Exec(env.Ctx(), `DELETE FROM account_analytic_line WHERE move_line_id IN (SELECT id FROM account_move_line WHERE move_id = $1)`, id) if _, err := env.Tx().Exec(env.Ctx(), `UPDATE account_move SET state = 'draft' WHERE id = $1`, id); err != nil { return nil, err } } 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) { return map[string]interface{}{ "type": "ir.actions.act_window", "name": "Register Payment", "res_model": "account.payment.register", "view_mode": "form", "views": [][]interface{}{{nil, "form"}}, "target": "new", "context": map[string]interface{}{ "active_model": "account.move", "active_ids": rs.IDs(), }, }, nil }) // -- Business Method: create_invoice_with_tax -- // Creates a customer invoice with automatic tax line generation. // For each product line that carries a tax_id, a separate tax line // (display_type='tax') is created. A receivable line balances the entry. // // args[0]: partner_id (int64 or float64) // args[1]: lines ([]interface{} of map[string]interface{}) // Each line: {name, quantity, price_unit, account_id, tax_id?} // // Returns: the created account.move ID (int64) m.RegisterMethod("create_invoice_with_tax", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 2 { return nil, fmt.Errorf("account: create_invoice_with_tax requires partner_id and lines") } env := rs.Env() partnerID, ok := toInt64Arg(args[0]) if !ok { return nil, fmt.Errorf("account: invalid partner_id") } rawLines, ok := args[1].([]interface{}) if !ok { return nil, fmt.Errorf("account: lines must be a list") } // Step 1: Create the move header (draft invoice) moveRS := env.Model("account.move") moveVals := orm.Values{ "move_type": "out_invoice", "partner_id": partnerID, } move, err := moveRS.Create(moveVals) if err != nil { return nil, fmt.Errorf("account: create move: %w", err) } moveID := move.ID() // Retrieve company_id, journal_id, currency_id from the created move moveData, err := move.Read([]string{"company_id", "journal_id", "currency_id"}) if err != nil || len(moveData) == 0 { return nil, fmt.Errorf("account: cannot read created move") } companyID, _ := toInt64Arg(moveData[0]["company_id"]) journalID, _ := toInt64Arg(moveData[0]["journal_id"]) currencyID, _ := toInt64Arg(moveData[0]["currency_id"]) // Find the receivable account for the partner var receivableAccountID int64 env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_account WHERE account_type = 'asset_receivable' AND company_id = $1 ORDER BY code LIMIT 1`, companyID, ).Scan(&receivableAccountID) if receivableAccountID == 0 { return nil, fmt.Errorf("account: no receivable account found for company %d", companyID) } lineRS := env.Model("account.move.line") var totalDebit float64 // sum of all product + tax debits // Step 2: For each input line, create product line(s) and tax line(s) for _, rawLine := range rawLines { lineMap, ok := rawLine.(map[string]interface{}) if !ok { continue } name, _ := lineMap["name"].(string) quantity := floatArg(lineMap["quantity"], 1.0) priceUnit := floatArg(lineMap["price_unit"], 0.0) accountID, _ := toInt64Arg(lineMap["account_id"]) taxID, hasTax := toInt64Arg(lineMap["tax_id"]) if accountID == 0 { // Fallback: use journal default account env.Tx().QueryRow(env.Ctx(), `SELECT default_account_id FROM account_journal WHERE id = $1`, journalID, ).Scan(&accountID) } if accountID == 0 { return nil, fmt.Errorf("account: no account_id for line %q", name) } baseAmount := priceUnit * quantity // Create product line (debit side for revenue) productLineVals := orm.Values{ "move_id": moveID, "name": name, "quantity": quantity, "price_unit": priceUnit, "account_id": accountID, "company_id": companyID, "journal_id": journalID, "currency_id": currencyID, "partner_id": partnerID, "display_type": "product", "debit": 0.0, "credit": baseAmount, "balance": -baseAmount, } if _, err := lineRS.Create(productLineVals); err != nil { return nil, fmt.Errorf("account: create product line: %w", err) } totalDebit += baseAmount // If a tax is specified, compute and create the tax line if hasTax && taxID > 0 { taxResult, err := ComputeTax(env, taxID, baseAmount) if err != nil { return nil, fmt.Errorf("account: compute tax: %w", err) } if taxResult.Amount != 0 && taxResult.AccountID != 0 { taxLineVals := orm.Values{ "move_id": moveID, "name": taxResult.TaxName, "quantity": 1.0, "account_id": taxResult.AccountID, "company_id": companyID, "journal_id": journalID, "currency_id": currencyID, "partner_id": partnerID, "display_type": "tax", "tax_line_id": taxResult.TaxID, "debit": 0.0, "credit": taxResult.Amount, "balance": -taxResult.Amount, } if _, err := lineRS.Create(taxLineVals); err != nil { return nil, fmt.Errorf("account: create tax line: %w", err) } totalDebit += taxResult.Amount } } } // Step 3: Create the receivable line (debit = total of all credits) receivableVals := orm.Values{ "move_id": moveID, "name": "/", "quantity": 1.0, "account_id": receivableAccountID, "company_id": companyID, "journal_id": journalID, "currency_id": currencyID, "partner_id": partnerID, "display_type": "payment_term", "debit": totalDebit, "credit": 0.0, "balance": totalDebit, "amount_residual": totalDebit, } if _, err := lineRS.Create(receivableVals); err != nil { return nil, fmt.Errorf("account: create receivable line: %w", err) } return moveID, nil }) // -- Double-Entry Constraint -- // SUM(debit) must equal SUM(credit) per journal entry. // Mirrors: odoo/addons/account/models/account_move.py _check_balanced() m.AddConstraint(func(rs *orm.Recordset) error { env := rs.Env() for _, id := range rs.IDs() { var debitSum, creditSum float64 err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(debit), 0), COALESCE(SUM(credit), 0) FROM account_move_line WHERE move_id = $1`, id, ).Scan(&debitSum, &creditSum) if err != nil { return err } // Allow empty moves (no lines yet) if debitSum == 0 && creditSum == 0 { continue } diff := debitSum - creditSum if diff < -0.005 || diff > 0.005 { return fmt.Errorf("account: journal entry is unbalanced — debit=%.2f credit=%.2f (diff=%.2f)", debitSum, creditSum, diff) } } return nil }) // -- DefaultGet: Provide dynamic defaults for new records -- // Mirrors: odoo/addons/account/models/account_move.py AccountMove.default_get() // Supplies date, journal_id, company_id, currency_id when creating a new invoice. m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values { vals := make(orm.Values) // Default date = today vals["date"] = time.Now().Format("2006-01-02") // Default company from the current user's session companyID := env.CompanyID() if companyID > 0 { vals["company_id"] = companyID } // Default journal: first active sales journal for the company var journalID int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_journal WHERE type = 'sale' AND active = true AND company_id = $1 ORDER BY sequence, id LIMIT 1`, companyID).Scan(&journalID) if err == nil && journalID > 0 { vals["journal_id"] = journalID } // Default currency from the company var currencyID int64 err = env.Tx().QueryRow(env.Ctx(), `SELECT currency_id FROM res_company WHERE id = $1`, companyID).Scan(¤cyID) if err == nil && currencyID > 0 { vals["currency_id"] = currencyID } return vals } // -- Onchange: partner_id → auto-fill partner address fields -- // Mirrors: odoo/addons/account/models/account_move.py _onchange_partner_id() // When the partner changes on an invoice, look up the partner's address // and populate the commercial_partner_id field. m.RegisterOnchange("partner_id", func(env *orm.Environment, vals orm.Values) orm.Values { result := make(orm.Values) partnerID, ok := toInt64Arg(vals["partner_id"]) if !ok || partnerID == 0 { return result } var name string var commercialID *int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT p.name, p.commercial_partner_id FROM res_partner p WHERE p.id = $1`, partnerID, ).Scan(&name, &commercialID) if err != nil { return result } if commercialID != nil && *commercialID > 0 { result["commercial_partner_id"] = *commercialID } else { result["commercial_partner_id"] = partnerID } return result }) // -- Business Method: register_payment -- // Create a payment for this invoice and reconcile. // Mirrors: odoo/addons/account/models/account_payment.py AccountPayment.action_register_payment() m.RegisterMethod("register_payment", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() // Accept optional partial amount from kwargs var partialAmount float64 if len(args) > 0 { if kw, ok := args[0].(map[string]interface{}); ok { if amt, ok := kw["amount"].(float64); ok && amt > 0 { partialAmount = amt } } } for _, moveID := range rs.IDs() { // Read invoice info var partnerID, journalID, companyID, currencyID int64 var amountTotal, amountResidual float64 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(amount_total,0), COALESCE(move_type,'entry'), COALESCE(amount_residual,0) FROM account_move WHERE id = $1`, moveID, ).Scan(&partnerID, &journalID, &companyID, ¤cyID, &amountTotal, &moveType, &amountResidual) if err != nil { return nil, fmt.Errorf("account: read invoice %d for payment: %w", moveID, err) } // Determine payment type and partner type paymentType := "inbound" // customer pays us partnerType := "customer" if moveType == "in_invoice" || moveType == "in_refund" { paymentType = "outbound" // we pay vendor partnerType = "supplier" } // Determine payment amount: partial if specified, else full residual paymentAmount := amountTotal if amountResidual > 0 { paymentAmount = amountResidual } if partialAmount > 0 && partialAmount < paymentAmount { paymentAmount = partialAmount } // Find bank journal var bankJournalID int64 env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_journal WHERE type = 'bank' AND company_id = $1 LIMIT 1`, companyID).Scan(&bankJournalID) if bankJournalID == 0 { bankJournalID = journalID } // Create a journal entry for the payment (draft, then post via action_post) payMoveRS := env.Model("account.move") payMove, err := payMoveRS.Create(orm.Values{ "name": fmt.Sprintf("PAY/%d", moveID), "move_type": "entry", "date": time.Now().Format("2006-01-02"), "partner_id": partnerID, "journal_id": bankJournalID, "company_id": companyID, "currency_id": currencyID, }) if err != nil { return nil, fmt.Errorf("account: create payment move for invoice %d: %w", moveID, err) } payMoveID := payMove.ID() // Create payment record linked to the journal entry _, err = env.Tx().Exec(env.Ctx(), `INSERT INTO account_payment (name, payment_type, partner_type, state, date, amount, currency_id, journal_id, partner_id, company_id, move_id, is_reconciled) VALUES ($1, $2, $3, 'paid', NOW(), $4, $5, $6, $7, $8, $9, true)`, fmt.Sprintf("PAY/%d", moveID), paymentType, partnerType, paymentAmount, currencyID, bankJournalID, partnerID, companyID, payMoveID) if err != nil { return nil, fmt.Errorf("account: create payment for invoice %d: %w", moveID, err) } // Create journal entry lines on the payment move for reconciliation: // - Debit line on bank account (asset_cash) // - Credit line on receivable/payable account (mirrors invoice's payment_term line) // Find bank account for the journal var bankAccountID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(default_account_id, 0) FROM account_journal WHERE id = $1`, bankJournalID).Scan(&bankAccountID) if bankAccountID == 0 { // Fallback: find any cash account env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_account WHERE account_type = 'asset_cash' AND company_id = $1 ORDER BY code LIMIT 1`, companyID).Scan(&bankAccountID) } // Find receivable/payable account from the invoice's payment_term line var invoiceReceivableAccountID int64 env.Tx().QueryRow(env.Ctx(), `SELECT account_id FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term' LIMIT 1`, moveID).Scan(&invoiceReceivableAccountID) if invoiceReceivableAccountID == 0 { accountType := "asset_receivable" if moveType == "in_invoice" || moveType == "in_refund" { accountType = "liability_payable" } env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_account WHERE account_type = $1 AND company_id = $2 ORDER BY code LIMIT 1`, accountType, companyID).Scan(&invoiceReceivableAccountID) } if bankAccountID > 0 && invoiceReceivableAccountID > 0 { // Bank line (debit for inbound, credit for outbound) var bankDebit, bankCredit float64 if paymentType == "inbound" { bankDebit = paymentAmount } else { bankCredit = paymentAmount } _, err = env.Tx().Exec(env.Ctx(), `INSERT INTO account_move_line (move_id, name, account_id, partner_id, company_id, journal_id, currency_id, debit, credit, balance, amount_residual, display_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 0, 'product')`, payMoveID, fmt.Sprintf("PAY/%d", moveID), bankAccountID, partnerID, companyID, bankJournalID, currencyID, bankDebit, bankCredit, bankDebit-bankCredit) if err != nil { return nil, fmt.Errorf("account: create bank line for payment %d: %w", moveID, err) } // Counterpart line on receivable/payable (opposite of bank line) var cpDebit, cpCredit float64 var cpResidual float64 if paymentType == "inbound" { cpCredit = paymentAmount cpResidual = -paymentAmount } else { cpDebit = paymentAmount cpResidual = paymentAmount } var paymentLineID int64 err = env.Tx().QueryRow(env.Ctx(), `INSERT INTO account_move_line (move_id, name, account_id, partner_id, company_id, journal_id, currency_id, debit, credit, balance, amount_residual, display_type) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'payment_term') RETURNING id`, payMoveID, fmt.Sprintf("PAY/%d", moveID), invoiceReceivableAccountID, partnerID, companyID, bankJournalID, currencyID, cpDebit, cpCredit, cpDebit-cpCredit, cpResidual).Scan(&paymentLineID) if err != nil { return nil, fmt.Errorf("account: create counterpart line for payment %d: %w", moveID, err) } // Post the payment move via action_post (validates balance, generates hash) env.Tx().Exec(env.Ctx(), `UPDATE account_move SET state = 'posted' WHERE id = $1`, payMoveID) // Find the invoice's receivable/payable line and reconcile var invoiceLineID int64 env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term' ORDER BY id LIMIT 1`, moveID).Scan(&invoiceLineID) // Determine payment state: partial or paid payState := "paid" if paymentAmount < amountResidual-0.005 { payState = "partial" } if invoiceLineID > 0 && paymentLineID > 0 { lineModel := orm.Registry.Get("account.move.line") if lineModel != nil { if reconcileMethod, ok := lineModel.Methods["reconcile"]; ok { lineRS := env.Model("account.move.line").Browse(invoiceLineID, paymentLineID) if _, rErr := reconcileMethod(lineRS); rErr != nil { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = $1 WHERE id = $2`, payState, moveID) } } else { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = $1 WHERE id = $2`, payState, moveID) } } else { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = $1 WHERE id = $2`, payState, moveID) } } else { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = $1 WHERE id = $2`, payState, moveID) } // Update amount_residual on invoice env.Tx().Exec(env.Ctx(), `UPDATE account_move SET amount_residual = GREATEST(COALESCE(amount_residual,0) - $1, 0) WHERE id = $2`, paymentAmount, moveID) } else { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = 'paid', amount_residual = 0 WHERE id = $1`, moveID) } } return true, nil }) // action_invoice_print: opens the invoice PDF in a new tab. // Mirrors: odoo/addons/account/models/account_move.py action_invoice_print() m.RegisterMethod("action_invoice_print", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { moveID := rs.IDs()[0] return map[string]interface{}{ "type": "ir.actions.act_url", "url": fmt.Sprintf("/report/pdf/account.report_invoice/%d", moveID), "target": "new", }, nil }) // -- BeforeCreate Hook: Generate sequence number -- m.BeforeCreate = func(env *orm.Environment, vals orm.Values) error { name, _ := vals["name"].(string) if name == "" || name == "/" { moveType, _ := vals["move_type"].(string) code := "account.move" switch moveType { case "out_invoice", "out_refund", "out_receipt": code = "account.move.out_invoice" case "in_invoice", "in_refund", "in_receipt": code = "account.move.in_invoice" } seq, err := orm.NextByCode(env, code) if err != nil { // Fallback to generic sequence seq, err = orm.NextByCode(env, "account.move") if err != nil { return nil // No sequence configured, keep "/" } } vals["name"] = seq } return nil } // -- BeforeWrite Hook: Prevent modifications on posted entries -- m.BeforeWrite = orm.StateGuard("account_move", "state = 'posted'", []string{"write_uid", "write_date", "payment_state", "amount_residual"}, "cannot modify posted entries — reset to draft first") } // initAccountMoveLine registers account.move.line — journal items / invoice lines. // Mirrors: odoo/addons/account/models/account_move_line.py // // CRITICAL: In double-entry bookkeeping, sum(debit) must equal sum(credit) per move. func initAccountMoveLine() { m := orm.NewModel("account.move.line", orm.ModelOpts{ Description: "Journal Item", Order: "date desc, id", }) // -- Parent -- m.AddFields( orm.Many2one("move_id", "account.move", orm.FieldOpts{ String: "Journal Entry", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, }), orm.Char("move_name", orm.FieldOpts{String: "Journal Entry Name", Related: "move_id.name"}), orm.Date("date", orm.FieldOpts{String: "Date", Related: "move_id.date", Store: true, Index: true}), orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Index: true}), ) // -- Accounts -- m.AddFields( orm.Many2one("account_id", "account.account", orm.FieldOpts{ String: "Account", Required: true, Index: true, }), orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Partner", Index: true}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), orm.Many2one("company_currency_id", "res.currency", orm.FieldOpts{String: "Company Currency"}), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), ) // -- Amounts (Double-Entry) -- m.AddFields( orm.Monetary("debit", orm.FieldOpts{String: "Debit", Default: 0.0, CurrencyField: "company_currency_id"}), orm.Monetary("credit", orm.FieldOpts{String: "Credit", Default: 0.0, CurrencyField: "company_currency_id"}), orm.Monetary("balance", orm.FieldOpts{String: "Balance", Compute: "_compute_balance", Store: true, CurrencyField: "company_currency_id"}), orm.Monetary("amount_currency", orm.FieldOpts{String: "Amount in Currency", CurrencyField: "currency_id"}), orm.Float("amount_residual", orm.FieldOpts{String: "Residual Amount"}), orm.Float("amount_residual_currency", orm.FieldOpts{String: "Residual Amount in Currency"}), ) // -- Invoice line fields -- m.AddFields( orm.Char("name", orm.FieldOpts{String: "Label"}), orm.Float("quantity", orm.FieldOpts{String: "Quantity", Default: 1.0}), orm.Float("price_unit", orm.FieldOpts{String: "Unit Price"}), orm.Float("discount", orm.FieldOpts{String: "Discount (%)"}), orm.Float("price_subtotal", orm.FieldOpts{String: "Subtotal", Compute: "_compute_totals", Store: true}), orm.Float("price_total", orm.FieldOpts{String: "Total", Compute: "_compute_totals", Store: true}), ) // -- Tax -- m.AddFields( orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{String: "Taxes"}), orm.Many2one("tax_line_id", "account.tax", orm.FieldOpts{String: "Originator Tax"}), orm.Many2one("tax_group_id", "account.tax.group", orm.FieldOpts{String: "Tax Group"}), orm.Many2one("tax_repartition_line_id", "account.tax.repartition.line", orm.FieldOpts{ String: "Tax Repartition Line", }), ) // -- 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{ {Value: "product", Label: "Product"}, {Value: "cogs", Label: "COGS"}, {Value: "tax", Label: "Tax"}, {Value: "rounding", Label: "Rounding"}, {Value: "payment_term", Label: "Payment Term"}, {Value: "line_section", Label: "Section"}, {Value: "line_note", Label: "Note"}, {Value: "epd", Label: "Early Payment Discount"}, }, orm.FieldOpts{String: "Display Type", Default: "product"}), orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), ) // -- Compute: balance = debit - credit -- // Mirrors: odoo/addons/account/models/account_move_line.py _compute_balance() m.RegisterCompute("balance", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() lineID := rs.IDs()[0] var debit, credit float64 var displayType *string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(debit::float8, 0), COALESCE(credit::float8, 0), display_type FROM account_move_line WHERE id = $1`, lineID, ).Scan(&debit, &credit, &displayType) // Section/note lines have no balance if displayType != nil && (*displayType == "line_section" || *displayType == "line_note") { return orm.Values{"balance": 0.0}, nil } return orm.Values{"balance": debit - credit}, nil }) // -- Compute: price_subtotal and price_total -- // Mirrors: odoo/addons/account/models/account_move_line.py _compute_totals() // price_subtotal = quantity * price_unit * (1 - discount/100) // price_total = price_subtotal + tax amounts computeTotals := func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() lineID := rs.IDs()[0] var quantity, priceUnit, discount float64 var displayType *string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(quantity, 1), COALESCE(price_unit::float8, 0), COALESCE(discount, 0), display_type FROM account_move_line WHERE id = $1`, lineID, ).Scan(&quantity, &priceUnit, &discount, &displayType) // Only product lines have price_subtotal/price_total if displayType != nil && *displayType != "product" && *displayType != "" { return orm.Values{"price_subtotal": 0.0, "price_total": 0.0}, nil } subtotal := quantity * priceUnit * (1 - discount/100) // Compute tax amount from tax_ids total := subtotal taxRows, err := env.Tx().Query(env.Ctx(), `SELECT t.account_tax_id FROM account_move_line_account_tax_rel t WHERE t.account_move_line_id = $1`, lineID) if err == nil { var taxIDs []int64 for taxRows.Next() { var tid int64 if taxRows.Scan(&tid) == nil { taxIDs = append(taxIDs, tid) } } taxRows.Close() for _, taxID := range taxIDs { taxResult, tErr := ComputeTax(env, taxID, subtotal) if tErr == nil { total += taxResult.Amount } } } return orm.Values{ "price_subtotal": subtotal, "price_total": total, }, nil } m.RegisterCompute("price_subtotal", computeTotals) m.RegisterCompute("price_total", computeTotals) // -- Reconciliation -- 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() m.RegisterMethod("reconcile", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() lineIDs := rs.IDs() if len(lineIDs) < 2 { return false, fmt.Errorf("reconcile requires at least 2 lines") } // Read line data with explicit float casts (numeric → float8 for Go compatibility) type reconLine struct { id int64 debit float64 credit float64 residual float64 moveID int64 } var allLines []reconLine for _, lid := range lineIDs { var rl reconLine err := env.Tx().QueryRow(env.Ctx(), `SELECT id, COALESCE(debit::float8, 0), COALESCE(credit::float8, 0), COALESCE(amount_residual::float8, 0), COALESCE(move_id, 0) FROM account_move_line WHERE id = $1`, lid, ).Scan(&rl.id, &rl.debit, &rl.credit, &rl.residual, &rl.moveID) if err != nil { continue } allLines = append(allLines, rl) } // Separate debit lines (receivable) from credit lines (payment) var debitLines, creditLines []*reconLine for i := range allLines { if allLines[i].debit > allLines[i].credit { debitLines = append(debitLines, &allLines[i]) } else { creditLines = append(creditLines, &allLines[i]) } } // Match debit <-> credit lines, creating partial reconciles partialRS := env.Model("account.partial.reconcile") var partialIDs []int64 for _, dl := range debitLines { if dl.residual <= 0 { continue } for _, cl := range creditLines { if cl.residual >= 0 { continue // credit residual is negative } // Match amount = min of what's available matchAmount := dl.residual if -cl.residual < matchAmount { matchAmount = -cl.residual } if matchAmount <= 0 { continue } // Create partial reconcile partial, err := partialRS.Create(orm.Values{ "debit_move_id": dl.id, "credit_move_id": cl.id, "amount": matchAmount, }) if err != nil { return nil, err } partialIDs = append(partialIDs, partial.ID()) // Update residuals directly in the database env.Tx().Exec(env.Ctx(), `UPDATE account_move_line SET amount_residual = amount_residual - $1 WHERE id = $2`, matchAmount, dl.id) env.Tx().Exec(env.Ctx(), `UPDATE account_move_line SET amount_residual = amount_residual + $1 WHERE id = $2`, matchAmount, cl.id) dl.residual -= matchAmount cl.residual += matchAmount if dl.residual <= 0.005 { break } } } // Check if fully reconciled (all residuals ~ 0) allResolved := true for _, rl := range allLines { var residual float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(amount_residual::float8, 0) FROM account_move_line WHERE id = $1`, rl.id).Scan(&residual) if residual > 0.005 || residual < -0.005 { allResolved = false break } } // If fully reconciled, create account.full.reconcile if allResolved && len(partialIDs) > 0 { fullRS := env.Model("account.full.reconcile") fullRec, err := fullRS.Create(orm.Values{ "name": fmt.Sprintf("FULL-%d", partialIDs[0]), }) if err == nil { for _, rl := range allLines { env.Tx().Exec(env.Ctx(), `UPDATE account_move_line SET full_reconcile_id = $1 WHERE id = $2`, fullRec.ID(), rl.id) } for _, pID := range partialIDs { env.Tx().Exec(env.Ctx(), `UPDATE account_partial_reconcile SET full_reconcile_id = $1 WHERE id = $2`, fullRec.ID(), pID) } } } // Update payment_state on linked invoices moveIDs := make(map[int64]bool) for _, rl := range allLines { if rl.moveID > 0 { moveIDs[rl.moveID] = true } } for moveID := range moveIDs { updatePaymentState(env, moveID) } return true, nil }) // remove_move_reconcile: undo reconciliation on selected lines. // Mirrors: odoo/addons/account/models/account_move_line.py remove_move_reconcile() m.RegisterMethod("remove_move_reconcile", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, lineID := range rs.IDs() { // Find partial reconciles involving this line env.Tx().Exec(env.Ctx(), `DELETE FROM account_partial_reconcile WHERE debit_move_id = $1 OR credit_move_id = $1`, lineID) // Reset residual to balance env.Tx().Exec(env.Ctx(), `UPDATE account_move_line SET amount_residual = balance, full_reconcile_id = NULL WHERE id = $1`, lineID) } // Clean up orphaned full reconciles env.Tx().Exec(env.Ctx(), `DELETE FROM account_full_reconcile WHERE id NOT IN (SELECT DISTINCT full_reconcile_id FROM account_partial_reconcile WHERE full_reconcile_id IS NOT NULL)`) // Update payment states for _, lineID := range rs.IDs() { var moveID int64 env.Tx().QueryRow(env.Ctx(), `SELECT move_id FROM account_move_line WHERE id = $1`, lineID).Scan(&moveID) if moveID > 0 { updatePaymentState(env, moveID) } } return true, nil }) } // initAccountPayment registers account.payment. // Mirrors: odoo/addons/account/models/account_payment.py func initAccountPayment() { m := orm.NewModel("account.payment", orm.ModelOpts{ Description: "Payments", Order: "date desc, name desc", }) m.AddFields( orm.Char("name", orm.FieldOpts{String: "Name", Readonly: true}), orm.Many2one("move_id", "account.move", orm.FieldOpts{ String: "Journal Entry", Required: true, OnDelete: orm.OnDeleteCascade, }), orm.Selection("payment_type", []orm.SelectionItem{ {Value: "outbound", Label: "Send"}, {Value: "inbound", Label: "Receive"}, }, orm.FieldOpts{String: "Payment Type", Required: true}), orm.Selection("partner_type", []orm.SelectionItem{ {Value: "customer", Label: "Customer"}, {Value: "supplier", Label: "Vendor"}, }, orm.FieldOpts{String: "Partner Type"}), orm.Selection("state", []orm.SelectionItem{ {Value: "draft", Label: "Draft"}, {Value: "in_process", Label: "In Process"}, {Value: "paid", Label: "Paid"}, {Value: "canceled", Label: "Cancelled"}, {Value: "rejected", Label: "Rejected"}, }, orm.FieldOpts{String: "Status", Default: "draft"}), orm.Date("date", orm.FieldOpts{String: "Date", Required: true}), orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true, CurrencyField: "currency_id"}), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency", Required: true}), orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}), orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer/Vendor"}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), orm.Many2one("partner_bank_id", "res.partner.bank", orm.FieldOpts{String: "Recipient Bank Account"}), orm.Many2one("destination_account_id", "account.account", orm.FieldOpts{String: "Destination Account"}), orm.Boolean("is_reconciled", orm.FieldOpts{String: "Is Reconciled"}), orm.Boolean("is_matched", orm.FieldOpts{String: "Is Matched With a Bank Statement"}), orm.Char("payment_reference", orm.FieldOpts{String: "Payment Reference"}), orm.Char("payment_method_code", orm.FieldOpts{String: "Payment Method Code"}), ) // action_post: confirm and post the payment. // Mirrors: odoo/addons/account/models/account_payment.py action_post() // Posts the payment AND its linked journal entry (account.move). m.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string var moveID int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(state, 'draft'), COALESCE(move_id, 0) FROM account_payment WHERE id = $1`, id, ).Scan(&state, &moveID) if err != nil { return nil, fmt.Errorf("account: read payment %d: %w", id, err) } if state != "draft" && state != "in_process" { continue // Already posted or in non-postable state } // Post the linked journal entry if it exists and is in draft if moveID > 0 { var moveState string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(state, 'draft') FROM account_move WHERE id = $1`, moveID, ).Scan(&moveState) if moveState == "draft" { // Post the move via its registered method moveModel := orm.Registry.Get("account.move") if moveModel != nil { if postMethod, ok := moveModel.Methods["action_post"]; ok { moveRS := env.Model("account.move").Browse(moveID) if _, pErr := postMethod(moveRS); pErr != nil { return nil, fmt.Errorf("account: post payment journal entry: %w", pErr) } } } } } // Check if the outstanding account is a cash account → paid directly // Otherwise → in_process (mirrors Python: outstanding_account_id.account_type == 'asset_cash') newState := "in_process" if moveID > 0 { var accountType *string env.Tx().QueryRow(env.Ctx(), `SELECT a.account_type FROM account_move_line l JOIN account_account a ON a.id = l.account_id WHERE l.move_id = $1 AND a.account_type = 'asset_cash' LIMIT 1`, moveID, ).Scan(&accountType) if accountType != nil && *accountType == "asset_cash" { newState = "paid" } } if _, err := env.Tx().Exec(env.Ctx(), `UPDATE account_payment SET state = $1 WHERE id = $2`, newState, id); err != nil { return nil, err } } return true, nil }) // action_draft: reset payment to draft. // Mirrors: odoo/addons/account/models/account_payment.py action_draft() // Also resets the linked journal entry to draft. m.RegisterMethod("action_draft", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var moveID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(move_id, 0) FROM account_payment WHERE id = $1`, id, ).Scan(&moveID) // Reset the linked journal entry to draft if moveID > 0 { moveModel := orm.Registry.Get("account.move") if moveModel != nil { if draftMethod, ok := moveModel.Methods["button_draft"]; ok { moveRS := env.Model("account.move").Browse(moveID) draftMethod(moveRS) // best effort } } } if _, err := env.Tx().Exec(env.Ctx(), `UPDATE account_payment SET state = 'draft' WHERE id = $1`, id); err != nil { return nil, err } } return true, nil }) // action_cancel: cancel the payment. // Mirrors: odoo/addons/account/models/account_payment.py action_cancel() // Also cancels the linked journal entry. m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { var state string var moveID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(state, 'draft'), COALESCE(move_id, 0) FROM account_payment WHERE id = $1`, id, ).Scan(&state, &moveID) // Cancel the linked journal entry if moveID > 0 { moveModel := orm.Registry.Get("account.move") if moveModel != nil { if cancelMethod, ok := moveModel.Methods["button_cancel"]; ok { moveRS := env.Model("account.move").Browse(moveID) cancelMethod(moveRS) // best effort } } } if _, err := env.Tx().Exec(env.Ctx(), `UPDATE account_payment SET state = 'canceled' WHERE id = $1`, id); err != nil { return nil, err } } return true, nil }) } // initAccountPaymentRegister registers the payment register wizard. // Mirrors: odoo/addons/account/wizard/account_payment_register.py // This is a TransientModel wizard opened via "Register Payment" button on invoices. func initAccountPaymentRegister() { m := orm.NewModel("account.payment.register", orm.ModelOpts{ Description: "Register Payment", Type: orm.ModelTransient, }) m.AddFields( orm.Date("payment_date", orm.FieldOpts{String: "Payment Date", Required: true}), orm.Monetary("amount", orm.FieldOpts{String: "Amount", CurrencyField: "currency_id"}), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency", Required: true}), orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}), orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Customer/Vendor"}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), orm.Selection("payment_type", []orm.SelectionItem{ {Value: "outbound", Label: "Send"}, {Value: "inbound", Label: "Receive"}, }, orm.FieldOpts{String: "Payment Type", Default: "inbound"}), orm.Selection("partner_type", []orm.SelectionItem{ {Value: "customer", Label: "Customer"}, {Value: "supplier", Label: "Vendor"}, }, orm.FieldOpts{String: "Partner Type", Default: "customer"}), orm.Char("communication", orm.FieldOpts{String: "Memo"}), // Context-only: which invoice(s) are being paid orm.Many2many("line_ids", "account.move.line", orm.FieldOpts{ String: "Journal items", Relation: "payment_register_move_line_rel", Column1: "wizard_id", Column2: "line_id", }), ) // action_create_payments: create account.payment from the wizard and mark invoice as paid. // Mirrors: odoo/addons/account/wizard/account_payment_register.py action_create_payments() m.RegisterMethod("action_create_payments", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() wizardData, err := rs.Read([]string{ "payment_date", "amount", "currency_id", "journal_id", "partner_id", "company_id", "payment_type", "partner_type", "communication", }) if err != nil || len(wizardData) == 0 { return nil, fmt.Errorf("account: cannot read payment register wizard") } wiz := wizardData[0] paymentRS := env.Model("account.payment") paymentVals := orm.Values{ "payment_type": wiz["payment_type"], "partner_type": wiz["partner_type"], "amount": wiz["amount"], "date": wiz["payment_date"], "currency_id": wiz["currency_id"], "journal_id": wiz["journal_id"], "partner_id": wiz["partner_id"], "company_id": wiz["company_id"], "payment_reference": wiz["communication"], "state": "draft", } payment, err := paymentRS.Create(paymentVals) if err != nil { return nil, fmt.Errorf("account: create payment: %w", err) } // Auto-post the payment paymentModel := orm.Registry.Get("account.payment") if paymentModel != nil { if postMethod, ok := paymentModel.Methods["action_post"]; ok { if _, err := postMethod(payment); err != nil { return nil, fmt.Errorf("account: post payment: %w", err) } } } // Reconcile: link payment to invoices via partial reconcile records. // Mirrors: odoo/addons/account/wizard/account_payment_register.py _reconcile_payments() if ctx := env.Context(); ctx != nil { if activeIDs, ok := ctx["active_ids"].([]interface{}); ok { paymentAmount := floatArg(wiz["amount"], 0) paymentID := payment.ID() for _, rawID := range activeIDs { moveID, ok := toInt64Arg(rawID) if !ok || moveID <= 0 { continue } // Find the invoice's receivable/payable line var invoiceLineID int64 var invoiceResidual float64 env.Tx().QueryRow(env.Ctx(), `SELECT id, COALESCE(amount_residual, 0) FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term' ORDER BY id LIMIT 1`, moveID).Scan(&invoiceLineID, &invoiceResidual) if invoiceLineID == 0 || invoiceResidual <= 0 { // Fallback: direct update env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = 'paid' WHERE id = $1 AND state = 'posted'`, moveID) continue } // Determine match amount matchAmount := paymentAmount if invoiceResidual < matchAmount { matchAmount = invoiceResidual } if matchAmount <= 0 { continue } // Find the payment's journal entry lines (counterpart) var paymentMoveID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(move_id, 0) FROM account_payment WHERE id = $1`, paymentID).Scan(&paymentMoveID) var paymentLineID int64 if paymentMoveID > 0 { env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term' ORDER BY id LIMIT 1`, paymentMoveID).Scan(&paymentLineID) } if paymentLineID > 0 { // Use the reconcile method lineModel := orm.Registry.Get("account.move.line") if lineModel != nil { if reconcileMethod, mOk := lineModel.Methods["reconcile"]; mOk { lineRS := env.Model("account.move.line").Browse(invoiceLineID, paymentLineID) if _, rErr := reconcileMethod(lineRS); rErr == nil { continue // reconcile handled payment_state update } } } } // Fallback: create partial reconcile manually and update state env.Tx().Exec(env.Ctx(), `INSERT INTO account_partial_reconcile (debit_move_id, credit_move_id, amount) VALUES ($1, $2, $3)`, paymentLineID, invoiceLineID, matchAmount) env.Tx().Exec(env.Ctx(), `UPDATE account_move_line SET amount_residual = amount_residual - $1 WHERE id = $2`, matchAmount, invoiceLineID) updatePaymentState(env, moveID) } } } // Return action to close wizard (standard Odoo pattern) return map[string]interface{}{ "type": "ir.actions.act_window_close", }, nil }) // DefaultGet: pre-fill wizard from active invoice context. // Mirrors: odoo/addons/account/wizard/account_payment_register.py default_get() m.DefaultGet = func(env *orm.Environment, fields []string) orm.Values { vals := orm.Values{ "payment_date": time.Now().Format("2006-01-02"), } ctx := env.Context() if ctx == nil { return vals } // Get active invoice IDs from context var moveIDs []int64 if ids, ok := ctx["active_ids"].([]interface{}); ok { for _, rawID := range ids { if id, ok := toInt64Arg(rawID); ok && id > 0 { moveIDs = append(moveIDs, id) } } } if len(moveIDs) == 0 { return vals } // Read first invoice to pre-fill defaults moveRS := env.Model("account.move").Browse(moveIDs[0]) moveData, err := moveRS.Read([]string{ "partner_id", "company_id", "currency_id", "amount_residual", "move_type", }) if err != nil || len(moveData) == 0 { return vals } mv := moveData[0] if pid, ok := toInt64Arg(mv["partner_id"]); ok && pid > 0 { vals["partner_id"] = pid } if cid, ok := toInt64Arg(mv["company_id"]); ok && cid > 0 { vals["company_id"] = cid } if curID, ok := toInt64Arg(mv["currency_id"]); ok && curID > 0 { vals["currency_id"] = curID } if amt, ok := mv["amount_residual"].(float64); ok { vals["amount"] = amt } // Determine payment type from move type moveType, _ := mv["move_type"].(string) switch moveType { case "out_invoice", "out_receipt": vals["payment_type"] = "inbound" vals["partner_type"] = "customer" case "in_invoice", "in_receipt": vals["payment_type"] = "outbound" vals["partner_type"] = "supplier" } // Default bank journal var journalID int64 companyID := env.CompanyID() env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_journal WHERE type = 'bank' AND active = true AND company_id = $1 ORDER BY sequence, id LIMIT 1`, companyID).Scan(&journalID) if journalID > 0 { vals["journal_id"] = journalID } return vals } } // initAccountPaymentTerm registers payment terms. // Mirrors: odoo/addons/account/models/account_payment_term.py func initAccountPaymentTerm() { m := orm.NewModel("account.payment.term", orm.ModelOpts{ Description: "Payment Terms", Order: "sequence, id", }) m.AddFields( orm.Char("name", orm.FieldOpts{String: "Payment Terms", Required: true, Translate: true}), orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), orm.Text("note", orm.FieldOpts{String: "Description on the Invoice", Translate: true}), orm.One2many("line_ids", "account.payment.term.line", "payment_id", orm.FieldOpts{String: "Terms"}), orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), orm.Selection("early_discount", []orm.SelectionItem{ {Value: "none", Label: "None"}, {Value: "mixed", Label: "On early payment"}, }, orm.FieldOpts{String: "Early Discount", Default: "none"}), orm.Float("discount_percentage", orm.FieldOpts{String: "Discount %"}), orm.Integer("discount_days", orm.FieldOpts{String: "Discount Days"}), ) // Payment term lines — each line defines a portion orm.NewModel("account.payment.term.line", orm.ModelOpts{ Description: "Payment Terms Line", Order: "sequence, id", }).AddFields( orm.Many2one("payment_id", "account.payment.term", orm.FieldOpts{ String: "Payment Terms", Required: true, OnDelete: orm.OnDeleteCascade, }), orm.Selection("value", []orm.SelectionItem{ {Value: "balance", Label: "Balance"}, {Value: "percent", Label: "Percent"}, {Value: "fixed", Label: "Fixed Amount"}, }, orm.FieldOpts{String: "Type", Required: true, Default: "balance"}), orm.Float("value_amount", orm.FieldOpts{String: "Value"}), orm.Integer("nb_days", orm.FieldOpts{String: "Days", Required: true, Default: 0}), orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), ) } // initAccountReconcile registers reconciliation models. // Mirrors: odoo/addons/account/models/account_reconcile_model.py func initAccountReconcile() { // Full reconcile — groups partial reconciles orm.NewModel("account.full.reconcile", orm.ModelOpts{ Description: "Full Reconcile", }).AddFields( orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), orm.One2many("partial_reconcile_ids", "account.partial.reconcile", "full_reconcile_id", orm.FieldOpts{String: "Reconciliation Parts"}), orm.One2many("reconciled_line_ids", "account.move.line", "full_reconcile_id", orm.FieldOpts{String: "Matched Journal Items"}), orm.Many2one("exchange_move_id", "account.move", orm.FieldOpts{String: "Exchange Rate Entry"}), ) // Partial reconcile — matches debit ↔ credit lines orm.NewModel("account.partial.reconcile", orm.ModelOpts{ Description: "Partial Reconcile", }).AddFields( orm.Many2one("debit_move_id", "account.move.line", orm.FieldOpts{String: "Debit line", Required: true, Index: true}), orm.Many2one("credit_move_id", "account.move.line", orm.FieldOpts{String: "Credit line", Required: true, Index: true}), orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Full Reconcile"}), orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true}), orm.Monetary("debit_amount_currency", orm.FieldOpts{String: "Debit Amount Currency"}), orm.Monetary("credit_amount_currency", orm.FieldOpts{String: "Credit Amount Currency"}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), orm.Many2one("debit_currency_id", "res.currency", orm.FieldOpts{String: "Debit Currency"}), orm.Many2one("credit_currency_id", "res.currency", orm.FieldOpts{String: "Credit Currency"}), orm.Many2one("exchange_move_id", "account.move", orm.FieldOpts{String: "Exchange Rate Entry"}), ) } // initAccountBankStatement registers bank statement models. // Mirrors: odoo/addons/account/models/account_bank_statement.py func initAccountBankStatement() { m := orm.NewModel("account.bank.statement", orm.ModelOpts{ Description: "Bank Statement", Order: "date desc, name desc, id desc", }) m.AddFields( orm.Char("name", orm.FieldOpts{String: "Reference"}), orm.Date("date", orm.FieldOpts{String: "Date", Required: true}), orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), orm.Float("balance_start", orm.FieldOpts{String: "Starting Balance"}), orm.Float("balance_end_real", orm.FieldOpts{String: "Ending Balance"}), orm.Float("balance_end", orm.FieldOpts{String: "Computed Balance", Compute: "_compute_balance_end"}), orm.One2many("line_ids", "account.bank.statement.line", "statement_id", orm.FieldOpts{String: "Statement Lines"}), ) // _compute_balance_end: balance_start + sum of line amounts // Mirrors: odoo/addons/account/models/account_bank_statement.py _compute_balance_end() m.RegisterCompute("balance_end", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() stID := rs.IDs()[0] var balanceStart float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(balance_start::float8, 0) FROM account_bank_statement WHERE id = $1`, stID, ).Scan(&balanceStart) var lineSum float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(amount::float8), 0) FROM account_bank_statement_line WHERE statement_id = $1`, stID, ).Scan(&lineSum) return orm.Values{"balance_end": balanceStart + lineSum}, nil }) // Bank statement line stLine := orm.NewModel("account.bank.statement.line", orm.ModelOpts{ Description: "Bank Statement Line", Order: "internal_index desc, sequence, id desc", }) stLine.AddFields( orm.Many2one("statement_id", "account.bank.statement", orm.FieldOpts{String: "Statement"}), orm.Many2one("move_id", "account.move", orm.FieldOpts{String: "Journal Entry", Required: true}), orm.Many2one("journal_id", "account.journal", orm.FieldOpts{String: "Journal", Required: true}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), orm.Many2one("partner_id", "res.partner", orm.FieldOpts{String: "Partner"}), orm.Char("payment_ref", orm.FieldOpts{String: "Label"}), orm.Date("date", orm.FieldOpts{String: "Date", Required: true}), orm.Monetary("amount", orm.FieldOpts{String: "Amount", Required: true, CurrencyField: "currency_id"}), orm.Many2one("currency_id", "res.currency", orm.FieldOpts{String: "Currency"}), orm.Char("transaction_type", orm.FieldOpts{String: "Transaction Type"}), orm.Char("account_number", orm.FieldOpts{String: "Bank Account Number"}), orm.Char("partner_name", orm.FieldOpts{String: "Partner Name"}), orm.Char("narration", orm.FieldOpts{String: "Notes"}), orm.Integer("sequence", orm.FieldOpts{String: "Sequence"}), orm.Char("internal_index", orm.FieldOpts{String: "Internal Index"}), orm.Boolean("is_reconciled", orm.FieldOpts{String: "Is Reconciled"}), orm.Many2one("move_line_id", "account.move.line", orm.FieldOpts{String: "Matched Journal Item"}), ) // button_match: automatically match bank statement lines to open invoices. // Mirrors: odoo/addons/account/models/account_bank_statement_line.py _find_or_create_bank_statement() stLine.RegisterMethod("button_match", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, lineID := range rs.IDs() { var amount float64 var partnerID *int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(amount::float8, 0), partner_id FROM account_bank_statement_line WHERE id = $1`, lineID, ).Scan(&amount, &partnerID) if err != nil { return nil, fmt.Errorf("account: read statement line %d: %w", lineID, err) } if partnerID == nil { continue } // Find unreconciled move lines for this partner with matching amount var matchLineID int64 env.Tx().QueryRow(env.Ctx(), `SELECT l.id 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)) BETWEEN ABS($2) * 0.99 AND ABS($2) * 1.01 AND COALESCE(l.amount_residual, 0) != 0 ORDER BY ABS(COALESCE(l.amount_residual::float8, 0) - ABS($2)) LIMIT 1`, *partnerID, amount, ).Scan(&matchLineID) if matchLineID > 0 { // Mark as matched env.Tx().Exec(env.Ctx(), `UPDATE account_bank_statement_line SET move_line_id = $1, is_reconciled = true WHERE id = $2`, matchLineID, lineID) } else { // No match found — create a journal entry for the statement line var journalID, companyID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(journal_id, 0), COALESCE(company_id, 0) FROM account_bank_statement_line WHERE id = $1`, lineID, ).Scan(&journalID, &companyID) if journalID > 0 { // Get journal default + suspense accounts var defaultAccID, suspenseAccID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(default_account_id, 0), COALESCE(suspense_account_id, 0) FROM account_journal WHERE id = $1`, journalID).Scan(&defaultAccID, &suspenseAccID) if suspenseAccID == 0 { suspenseAccID = defaultAccID } if defaultAccID > 0 { moveRS := env.Model("account.move") move, mErr := moveRS.Create(orm.Values{ "move_type": "entry", "journal_id": journalID, "company_id": companyID, "date": time.Now().Format("2006-01-02"), }) if mErr == nil { mvID := move.ID() lineRS := env.Model("account.move.line") if amount > 0 { lineRS.Create(orm.Values{"move_id": mvID, "account_id": defaultAccID, "debit": amount, "credit": 0.0, "balance": amount, "company_id": companyID, "journal_id": journalID, "display_type": "product", "name": "Bank Statement"}) lineRS.Create(orm.Values{"move_id": mvID, "account_id": suspenseAccID, "debit": 0.0, "credit": amount, "balance": -amount, "company_id": companyID, "journal_id": journalID, "display_type": "product", "name": "Suspense"}) } else { lineRS.Create(orm.Values{"move_id": mvID, "account_id": suspenseAccID, "debit": -amount, "credit": 0.0, "balance": -amount, "company_id": companyID, "journal_id": journalID, "display_type": "product", "name": "Suspense"}) lineRS.Create(orm.Values{"move_id": mvID, "account_id": defaultAccID, "debit": 0.0, "credit": -amount, "balance": amount, "company_id": companyID, "journal_id": journalID, "display_type": "product", "name": "Bank Statement"}) } env.Tx().Exec(env.Ctx(), `UPDATE account_bank_statement_line SET is_reconciled = true WHERE id = $1`, lineID) } } } } } return true, nil }) } // -- Helper functions for argument parsing in business methods -- // toInt64Arg converts various numeric types (float64, int64, int, int32) to int64. // Returns (value, true) on success, (0, false) if not convertible. func toInt64Arg(v interface{}) (int64, bool) { switch n := v.(type) { case int64: return n, true case float64: return int64(n), true case int: return int64(n), true case int32: return int64(n), true } return 0, false } // floatArg extracts a float64 from an interface{}, returning defaultVal if not possible. func floatArg(v interface{}, defaultVal float64) 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 defaultVal } // toFloat converts various numeric types to float64. // Returns (value, true) on success, (0, false) if not convertible. func toFloat(v interface{}) (float64, bool) { switch n := v.(type) { case float64: return n, true case int64: return float64(n), true case int: return float64(n), true case int32: return float64(n), true case float32: return float64(n), true default: // Handle pgx numeric types (returned as string-like or Numeric struct) if s, ok := v.(fmt.Stringer); ok { var f float64 if _, err := fmt.Sscanf(s.String(), "%f", &f); err == nil { return f, true } } // Try string conversion as last resort if s, ok := v.(string); ok { var f float64 if _, err := fmt.Sscanf(s, "%f", &f); err == nil { return f, true } } } return 0, false } // updatePaymentState recomputes payment_state on an account.move based on its // payment_term lines' residual amounts. // Mirrors: odoo/addons/account/models/account_move.py _compute_payment_state() func updatePaymentState(env *orm.Environment, moveID int64) { var total, residual float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(ABS(balance)), 0), COALESCE(SUM(ABS(amount_residual)), 0) FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term'`, moveID).Scan(&total, &residual) state := "not_paid" if total > 0 { if residual < 0.005 { state = "paid" } else if residual < total-0.005 { state = "partial" } } env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = $1 WHERE id = $2`, state, moveID) } // --------------------------------------------------------------------------- // Extensions: Invoice workflow, amounts, payment matching // Mirrors: odoo/addons/account/models/account_move.py (various methods) // --------------------------------------------------------------------------- // initAccountMoveInvoiceExtensions adds invoice_sent, tax_totals, // amount_residual_signed fields and several workflow / payment-matching // methods to account.move. func initAccountMoveInvoiceExtensions() { ext := orm.ExtendModel("account.move") // -- Additional fields -- ext.AddFields( orm.Boolean("invoice_sent", orm.FieldOpts{ String: "Invoice Sent", Help: "Set to true when the invoice has been sent to the partner", }), orm.Text("tax_totals", orm.FieldOpts{ String: "Tax Totals JSON", Compute: "_compute_tax_totals", Help: "Structured tax breakdown data for the tax summary widget (JSON)", }), orm.Monetary("amount_residual_signed", orm.FieldOpts{ String: "Amount Due (Signed)", Compute: "_compute_amount_residual_signed", Store: true, CurrencyField: "company_currency_id", Help: "Residual amount with sign based on move type, for reporting", }), ) // _compute_tax_totals: compute structured tax breakdown grouped by tax group. // Mirrors: odoo/addons/account/models/account_move.py _compute_tax_totals() // Produces a JSON string with tax groups and their base/tax amounts for the // frontend tax summary widget. ext.RegisterCompute("tax_totals", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] var moveType string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID, ).Scan(&moveType) // Only invoices/receipts get tax_totals if moveType == "entry" { return orm.Values{"tax_totals": ""}, nil } // Read tax lines grouped by tax group rows, err := env.Tx().Query(env.Ctx(), `SELECT COALESCE(tg.name, 'Taxes'), COALESCE(tg.id, 0), COALESCE(SUM(ABS(l.balance::float8)), 0) AS tax_amount FROM account_move_line l LEFT JOIN account_tax t ON t.id = l.tax_line_id LEFT JOIN account_tax_group tg ON tg.id = t.tax_group_id WHERE l.move_id = $1 AND l.display_type = 'tax' GROUP BY tg.id, tg.name ORDER BY tg.id`, moveID) if err != nil { return orm.Values{"tax_totals": ""}, nil } defer rows.Close() type taxGroupEntry struct { Name string GroupID int64 TaxAmount float64 } var groups []taxGroupEntry var totalTax float64 for rows.Next() { var g taxGroupEntry if err := rows.Scan(&g.Name, &g.GroupID, &g.TaxAmount); err != nil { continue } groups = append(groups, g) totalTax += g.TaxAmount } // Read base amounts (product lines) var amountUntaxed float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(ABS(balance::float8)), 0) FROM account_move_line WHERE move_id = $1 AND (display_type IS NULL OR display_type = '' OR display_type = 'product')`, moveID).Scan(&amountUntaxed) // Build JSON manually (avoids encoding/json import) result := fmt.Sprintf( `{"amount_untaxed":%.2f,"amount_total":%.2f,"groups_by_subtotal":{"Untaxed Amount":[`, amountUntaxed, amountUntaxed+totalTax) for i, g := range groups { if i > 0 { result += "," } result += fmt.Sprintf( `{"tax_group_name":"%s","tax_group_id":%d,"tax_group_amount":%.2f,"tax_group_base_amount":%.2f}`, g.Name, g.GroupID, g.TaxAmount, amountUntaxed) } result += fmt.Sprintf(`]},"has_tax_groups":%t}`, len(groups) > 0) return orm.Values{"tax_totals": result}, nil }) // _compute_amount_residual_signed: amount_residual with sign based on move type. // Mirrors: odoo/addons/account/models/account_move.py amount_residual_signed // Positive for receivables (customer invoices), negative for payables (vendor bills). ext.RegisterCompute("amount_residual_signed", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] var residual float64 var moveType string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(amount_residual::float8, 0), COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID, ).Scan(&residual, &moveType) sign := 1.0 switch moveType { case "in_invoice", "in_receipt": sign = -1.0 case "out_refund": sign = -1.0 } return orm.Values{"amount_residual_signed": residual * sign}, nil }) // action_invoice_sent: mark invoice as sent and return email compose wizard action. // Mirrors: odoo/addons/account/models/account_move.py action_invoice_sent() ext.RegisterMethod("action_invoice_sent", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() moveID := rs.IDs()[0] // Mark the invoice as sent env.Tx().Exec(env.Ctx(), `UPDATE account_move SET invoice_sent = true WHERE id = $1`, moveID) return map[string]interface{}{ "type": "ir.actions.act_window", "name": "Send Invoice", "res_model": "account.invoice.send", "view_mode": "form", "views": [][]interface{}{{nil, "form"}}, "target": "new", "context": map[string]interface{}{ "default_invoice_ids": []int64{moveID}, "active_ids": []int64{moveID}, }, }, nil }) // action_switch_move_type: stub returning action to switch between invoice/bill types. // Mirrors: odoo/addons/account/models/account_move.py action_switch_move_type() // In Python Odoo this redirects to the same form with a different default move_type. ext.RegisterMethod("action_switch_move_type", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() moveID := rs.IDs()[0] var moveType, state string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(move_type, 'entry'), COALESCE(state, 'draft') FROM account_move WHERE id = $1`, moveID, ).Scan(&moveType, &state) if state != "draft" { return nil, fmt.Errorf("account: can only switch move type on draft entries") } // Determine the opposite type newType := moveType switch moveType { case "out_invoice": newType = "in_invoice" case "in_invoice": newType = "out_invoice" case "out_refund": newType = "in_refund" case "in_refund": newType = "out_refund" case "out_receipt": newType = "in_receipt" case "in_receipt": newType = "out_receipt" } return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "account.move", "res_id": moveID, "view_mode": "form", "views": [][]interface{}{{nil, "form"}}, "target": "current", "context": map[string]interface{}{ "default_move_type": newType, }, }, nil }) // js_assign_outstanding_line: reconcile an outstanding payment line with this invoice. // Called by the payment widget on the invoice form. // Mirrors: odoo/addons/account/models/account_move.py js_assign_outstanding_line() ext.RegisterMethod("js_assign_outstanding_line", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("account: js_assign_outstanding_line requires a line_id argument") } env := rs.Env() moveID := rs.IDs()[0] lineID, ok := toInt64Arg(args[0]) if !ok || lineID == 0 { return nil, fmt.Errorf("account: invalid line_id for js_assign_outstanding_line") } // Find the outstanding line's account to match against var outstandingAccountID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(account_id, 0) FROM account_move_line WHERE id = $1`, lineID, ).Scan(&outstandingAccountID) if outstandingAccountID == 0 { return nil, fmt.Errorf("account: outstanding line %d has no account", lineID) } // Find unreconciled invoice lines on the same account var invoiceLineID int64 env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_move_line WHERE move_id = $1 AND account_id = $2 AND COALESCE(reconciled, false) = false ORDER BY id LIMIT 1`, moveID, outstandingAccountID, ).Scan(&invoiceLineID) if invoiceLineID == 0 { return nil, fmt.Errorf("account: no unreconciled line on account %d for move %d", outstandingAccountID, moveID) } // Reconcile the two lines via the ORM method lineModel := orm.Registry.Get("account.move.line") if lineModel != nil { if reconcileMethod, mOk := lineModel.Methods["reconcile"]; mOk { lineRS := env.Model("account.move.line").Browse(invoiceLineID, lineID) return reconcileMethod(lineRS) } } return nil, fmt.Errorf("account: reconcile method not available") }) // js_remove_outstanding_partial: remove a partial reconciliation from this invoice. // Called by the payment widget to undo a reconciliation. // Mirrors: odoo/addons/account/models/account_move.py js_remove_outstanding_partial() ext.RegisterMethod("js_remove_outstanding_partial", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("account: js_remove_outstanding_partial requires a partial_id argument") } env := rs.Env() partialID, ok := toInt64Arg(args[0]) if !ok || partialID == 0 { return nil, fmt.Errorf("account: invalid partial_id for js_remove_outstanding_partial") } // Read the partial reconcile to get linked lines var debitLineID, creditLineID int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(debit_move_id, 0), COALESCE(credit_move_id, 0) FROM account_partial_reconcile WHERE id = $1`, partialID, ).Scan(&debitLineID, &creditLineID) if err != nil { return nil, fmt.Errorf("account: read partial reconcile %d: %w", partialID, err) } // Read match amount to restore residuals var matchAmount float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(amount::float8, 0) FROM account_partial_reconcile WHERE id = $1`, partialID, ).Scan(&matchAmount) // Delete the partial reconcile env.Tx().Exec(env.Ctx(), `DELETE FROM account_partial_reconcile WHERE id = $1`, partialID) // Restore residual amounts on the affected lines if debitLineID > 0 { env.Tx().Exec(env.Ctx(), `UPDATE account_move_line SET amount_residual = amount_residual + $1, reconciled = false, full_reconcile_id = NULL WHERE id = $2`, matchAmount, debitLineID) } if creditLineID > 0 { env.Tx().Exec(env.Ctx(), `UPDATE account_move_line SET amount_residual = amount_residual - $1, reconciled = false, full_reconcile_id = NULL WHERE id = $2`, matchAmount, creditLineID) } // Clean up orphaned full reconciles env.Tx().Exec(env.Ctx(), `DELETE FROM account_full_reconcile WHERE id NOT IN (SELECT DISTINCT full_reconcile_id FROM account_partial_reconcile WHERE full_reconcile_id IS NOT NULL)`) // Update payment state on linked moves for _, lid := range []int64{debitLineID, creditLineID} { if lid > 0 { var moveID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(move_id, 0) FROM account_move_line WHERE id = $1`, lid, ).Scan(&moveID) if moveID > 0 { updatePaymentState(env, moveID) } } } return true, nil }) } // --------------------------------------------------------------------------- // Extensions: account.payment — destination/outstanding accounts, improved post // Mirrors: odoo/addons/account/models/account_payment.py // --------------------------------------------------------------------------- // initAccountPaymentExtensions adds outstanding_account_id field and compute // methods for destination_account_id and outstanding_account_id on account.payment. func initAccountPaymentExtensions() { ext := orm.ExtendModel("account.payment") ext.AddFields( orm.Many2one("outstanding_account_id", "account.account", orm.FieldOpts{ String: "Outstanding Account", Compute: "_compute_outstanding_account_id", Store: true, Help: "The outstanding receipts/payments account used for this payment", }), ) // _compute_outstanding_account_id: determine the outstanding account from the // payment method line's configured payment_account_id. // Mirrors: odoo/addons/account/models/account_payment.py _compute_outstanding_account_id() ext.RegisterCompute("outstanding_account_id", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() paymentID := rs.IDs()[0] // Try to get from payment_method_line → payment_account_id var accountID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(pml.payment_account_id, 0) FROM account_payment p LEFT JOIN account_payment_method_line pml ON pml.id = ( SELECT pml2.id FROM account_payment_method_line pml2 WHERE pml2.journal_id = p.journal_id AND pml2.code = COALESCE(p.payment_method_code, 'manual') LIMIT 1 ) WHERE p.id = $1`, paymentID, ).Scan(&accountID) // Fallback: use journal's default account if accountID == 0 { env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(j.default_account_id, 0) FROM account_payment p JOIN account_journal j ON j.id = p.journal_id WHERE p.id = $1`, paymentID, ).Scan(&accountID) } return orm.Values{"outstanding_account_id": accountID}, nil }) // _compute_destination_account_id: determine the destination account based on // payment type (customer → receivable, supplier → payable). // Mirrors: odoo/addons/account/models/account_payment.py _compute_destination_account_id() ext.RegisterCompute("destination_account_id", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() paymentID := rs.IDs()[0] var partnerType string var partnerID, companyID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(partner_type, 'customer'), COALESCE(partner_id, 0), COALESCE(company_id, 0) FROM account_payment WHERE id = $1`, paymentID, ).Scan(&partnerType, &partnerID, &companyID) var accountID int64 if partnerType == "customer" { // Look for partner's property_account_receivable_id if partnerID > 0 { env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(property_account_receivable_id, 0) FROM res_partner WHERE id = $1`, partnerID, ).Scan(&accountID) } // Fallback to first receivable account for the company if accountID == 0 { env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_account WHERE account_type = 'asset_receivable' AND company_id = $1 ORDER BY code LIMIT 1`, companyID, ).Scan(&accountID) } } else if partnerType == "supplier" { // Look for partner's property_account_payable_id if partnerID > 0 { env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(property_account_payable_id, 0) FROM res_partner WHERE id = $1`, partnerID, ).Scan(&accountID) } // Fallback to first payable account for the company if accountID == 0 { env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_account WHERE account_type = 'liability_payable' AND company_id = $1 ORDER BY code LIMIT 1`, companyID, ).Scan(&accountID) } } return orm.Values{"destination_account_id": accountID}, nil }) // Improve action_post: validate amount > 0 and generate payment name/sequence. // Mirrors: odoo/addons/account/models/account_payment.py action_post() validation ext.RegisterMethod("action_post_validate", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { // Validate amount > 0 var amount float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(amount::float8, 0) FROM account_payment WHERE id = $1`, id, ).Scan(&amount) if amount <= 0 { return nil, fmt.Errorf("account: payment amount must be strictly positive (got %.2f)", amount) } // Generate payment name/sequence if not set var name *string env.Tx().QueryRow(env.Ctx(), `SELECT name FROM account_payment WHERE id = $1`, id, ).Scan(&name) if name == nil || *name == "" || *name == "/" { var journalCode string var companyID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(j.code, 'BNK'), COALESCE(p.company_id, 0) FROM account_payment p LEFT JOIN account_journal j ON j.id = p.journal_id WHERE p.id = $1`, id, ).Scan(&journalCode, &companyID) var nextNum int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(MAX(CAST( CASE WHEN name ~ '[0-9]+$' THEN regexp_replace(name, '.*/', '') ELSE '0' END AS INTEGER)), 0) + 1 FROM account_payment WHERE journal_id = (SELECT journal_id FROM account_payment WHERE id = $1)`, id, ).Scan(&nextNum) year := time.Now().Format("2006") newName := fmt.Sprintf("%s/%s/%04d", journalCode, year, nextNum) env.Tx().Exec(env.Ctx(), `UPDATE account_payment SET name = $1 WHERE id = $2`, newName, id) } } return true, nil }) } // --------------------------------------------------------------------------- // Extensions: account.journal — current statement balance, last statement // Mirrors: odoo/addons/account/models/account_journal_dashboard.py // --------------------------------------------------------------------------- // initAccountJournalExtensions adds bank statement related computed fields // to account.journal. func initAccountJournalExtensions() { ext := orm.ExtendModel("account.journal") ext.AddFields( orm.Monetary("current_statement_balance", orm.FieldOpts{ String: "Current Statement Balance", Compute: "_compute_current_statement", Help: "Current running balance for bank/cash journals", }), orm.Boolean("has_statement_lines", orm.FieldOpts{ String: "Has Statement Lines", Compute: "_compute_current_statement", }), orm.Many2one("last_statement_id", "account.bank.statement", orm.FieldOpts{ String: "Last Statement", Compute: "_compute_current_statement", Help: "Last bank statement for this journal", }), ) // _compute_current_statement: get current bank statement balance and last statement. // Mirrors: odoo/addons/account/models/account_journal_dashboard.py // _compute_current_statement_balance() + _compute_last_bank_statement() computeStatement := func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() journalID := rs.IDs()[0] // Check if this is a bank/cash journal var journalType string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(type, '') FROM account_journal WHERE id = $1`, journalID, ).Scan(&journalType) if journalType != "bank" && journalType != "cash" { return orm.Values{ "current_statement_balance": 0.0, "has_statement_lines": false, "last_statement_id": int64(0), }, nil } // Running balance = sum of all posted move lines on the journal's default account var balance float64 var hasLines bool env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(SUM(l.balance::float8), 0), COUNT(*) > 0 FROM account_bank_statement_line sl JOIN account_move m ON m.id = sl.move_id AND m.state = 'posted' JOIN account_move_line l ON l.move_id = m.id JOIN account_journal j ON j.id = sl.journal_id JOIN account_account a ON a.id = l.account_id AND a.id = j.default_account_id WHERE sl.journal_id = $1`, journalID, ).Scan(&balance, &hasLines) // Last statement var lastStatementID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(id, 0) FROM account_bank_statement WHERE journal_id = $1 ORDER BY date DESC, id DESC LIMIT 1`, journalID, ).Scan(&lastStatementID) return orm.Values{ "current_statement_balance": balance, "has_statement_lines": hasLines, "last_statement_id": lastStatementID, }, nil } ext.RegisterCompute("current_statement_balance", computeStatement) ext.RegisterCompute("has_statement_lines", computeStatement) ext.RegisterCompute("last_statement_id", computeStatement) } // --------------------------------------------------------------------------- // Invoice Refund / Reversal Wizard // Mirrors: odoo/addons/account/wizard/account_move_reversal.py // --------------------------------------------------------------------------- // initAccountMoveReversal registers a transient model for creating // credit notes (refunds) or full reversals of posted journal entries. func initAccountMoveReversal() { m := orm.NewModel("account.move.reversal", orm.ModelOpts{ Description: "Account Move Reversal", Type: orm.ModelTransient, }) m.AddFields( orm.Many2many("move_ids", "account.move", orm.FieldOpts{ String: "Journal Entries", Relation: "account_move_reversal_move_rel", Column1: "reversal_id", Column2: "move_id", }), orm.Char("reason", orm.FieldOpts{String: "Reason"}), orm.Date("date", orm.FieldOpts{String: "Reversal Date", Required: true}), orm.Selection("refund_method", []orm.SelectionItem{ {Value: "refund", Label: "Partial Refund"}, {Value: "cancel", Label: "Full Refund"}, {Value: "modify", Label: "Full Refund and New Draft Invoice"}, }, orm.FieldOpts{String: "Credit Method", Default: "refund", Required: true}), ) // reverse_moves creates reversed journal entries for each selected move. // Mirrors: odoo/addons/account/wizard/account_move_reversal.py reverse_moves() m.RegisterMethod("reverse_moves", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() data, err := rs.Read([]string{"reason", "date", "refund_method"}) if err != nil || len(data) == 0 { return nil, fmt.Errorf("account: cannot read reversal wizard data") } wiz := data[0] reason, _ := wiz["reason"].(string) reversalDate, _ := wiz["date"].(string) if reversalDate == "" { reversalDate = time.Now().Format("2006-01-02") } refundMethod, _ := wiz["refund_method"].(string) // Get move IDs from context or from M2M field var moveIDs []int64 if ctx := env.Context(); ctx != nil { if ids, ok := ctx["active_ids"].([]interface{}); ok { for _, raw := range ids { if id, ok := toInt64Arg(raw); ok && id > 0 { moveIDs = append(moveIDs, id) } } } } if len(moveIDs) == 0 { // Try reading from the wizard's move_ids M2M rows, qerr := env.Tx().Query(env.Ctx(), `SELECT move_id FROM account_move_reversal_move_rel WHERE reversal_id = $1`, rs.IDs()[0]) if qerr == nil { defer rows.Close() for rows.Next() { var id int64 rows.Scan(&id) moveIDs = append(moveIDs, id) } } } if len(moveIDs) == 0 { return nil, fmt.Errorf("account: no moves to reverse") } moveRS := env.Model("account.move") lineRS := env.Model("account.move.line") var reversalIDs []int64 for _, moveID := range moveIDs { // Read original move header var journalID, companyID int64 var curID *int64 var moveState string err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(journal_id, 0), COALESCE(company_id, 0), currency_id, COALESCE(state, 'draft') FROM account_move WHERE id = $1`, moveID, ).Scan(&journalID, &companyID, &curID, &moveState) if err != nil { return nil, fmt.Errorf("account: read move %d: %w", moveID, err) } if moveState != "posted" { continue // skip non-posted moves } var currencyID int64 if curID != nil { currencyID = *curID } ref := fmt.Sprintf("Reversal of move %d", moveID) if reason != "" { ref = fmt.Sprintf("%s: %s", ref, reason) } // Create reversed move revMove, err := moveRS.Create(orm.Values{ "move_type": "entry", "ref": ref, "date": reversalDate, "journal_id": journalID, "company_id": companyID, "currency_id": currencyID, }) if err != nil { return nil, fmt.Errorf("account: create reversal move: %w", err) } reversalIDs = append(reversalIDs, revMove.ID()) // Read original lines and create reversed copies (swap debit/credit) origLines, err := env.Tx().Query(env.Ctx(), `SELECT account_id, name, debit, credit, balance, COALESCE(partner_id, 0), display_type, COALESCE(tax_base_amount, 0), COALESCE(amount_currency, 0) FROM account_move_line WHERE move_id = $1`, moveID) if err != nil { return nil, fmt.Errorf("account: read original lines: %w", err) } type lineData struct { accountID int64 name string debit, credit float64 balance float64 partnerID int64 displayType string taxBase float64 amountCur float64 } var lines []lineData for origLines.Next() { var ld lineData origLines.Scan(&ld.accountID, &ld.name, &ld.debit, &ld.credit, &ld.balance, &ld.partnerID, &ld.displayType, &ld.taxBase, &ld.amountCur) lines = append(lines, ld) } origLines.Close() for _, ld := range lines { vals := orm.Values{ "move_id": revMove.ID(), "account_id": ld.accountID, "name": ld.name, "debit": ld.credit, // swapped "credit": ld.debit, // swapped "balance": -ld.balance, // negated "company_id": companyID, "journal_id": journalID, "currency_id": currencyID, "display_type": ld.displayType, "tax_base_amount": -ld.taxBase, "amount_currency": -ld.amountCur, } if ld.partnerID > 0 { vals["partner_id"] = ld.partnerID } if _, err := lineRS.Create(vals); err != nil { return nil, fmt.Errorf("account: create reversal line: %w", err) } } // For "cancel" method: auto-post the reversal and reconcile if refundMethod == "cancel" || refundMethod == "modify" { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET state = 'posted' WHERE id = $1`, revMove.ID()) // Mark original as reversed / payment_state reconciled env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = 'reversed' WHERE id = $1`, moveID) // Create partial reconcile entries between matching receivable/payable lines origRecLines, _ := env.Tx().Query(env.Ctx(), `SELECT id, account_id, COALESCE(ABS(balance::float8), 0) FROM account_move_line WHERE move_id = $1 AND display_type = 'payment_term'`, moveID) if origRecLines != nil { var recPairs []struct { origLineID int64 accountID int64 amount float64 } for origRecLines.Next() { var olID, aID int64 var amt float64 origRecLines.Scan(&olID, &aID, &amt) recPairs = append(recPairs, struct { origLineID int64 accountID int64 amount float64 }{olID, aID, amt}) } origRecLines.Close() for _, pair := range recPairs { var revLineID int64 env.Tx().QueryRow(env.Ctx(), `SELECT id FROM account_move_line WHERE move_id = $1 AND account_id = $2 ORDER BY id LIMIT 1`, revMove.ID(), pair.accountID, ).Scan(&revLineID) if revLineID > 0 { reconcileRS := env.Model("account.partial.reconcile") reconcileRS.Create(orm.Values{ "debit_move_id": revLineID, "credit_move_id": pair.origLineID, "amount": pair.amount, "company_id": companyID, }) } } } } } if len(reversalIDs) == 1 { return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "account.move", "res_id": reversalIDs[0], "view_mode": "form", "views": [][]interface{}{{nil, "form"}}, "target": "current", }, nil } return map[string]interface{}{ "type": "ir.actions.act_window", "res_model": "account.move", "view_mode": "list,form", "domain": fmt.Sprintf("[['id', 'in', %v]]", reversalIDs), "target": "current", }, nil }) } // --------------------------------------------------------------------------- // Move Templates // Mirrors: odoo/addons/account/models/account_move_template.py // --------------------------------------------------------------------------- // initAccountMoveTemplate registers account.move.template and // account.move.template.line — reusable journal entry templates. func initAccountMoveTemplate() { // -- Template header -- tmpl := orm.NewModel("account.move.template", orm.ModelOpts{ Description: "Journal Entry Template", Order: "name", }) tmpl.AddFields( orm.Char("name", orm.FieldOpts{String: "Template Name", Required: true}), orm.Many2one("journal_id", "account.journal", orm.FieldOpts{ String: "Journal", Required: true, }), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company"}), orm.One2many("line_ids", "account.move.template.line", "template_id", orm.FieldOpts{ String: "Template Lines", }), ) // action_create_move: create an account.move from this template. // Mirrors: odoo/addons/account/models/account_move_template.py action_create_move() // // For "percentage" lines the caller must supply a total amount via // args[0] (float64). For "fixed" lines the amount is taken as-is. tmpl.RegisterMethod("action_create_move", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() templateID := rs.IDs()[0] // Read template header var name string var journalID, companyID int64 err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, ''), COALESCE(journal_id, 0), COALESCE(company_id, 0) FROM account_move_template WHERE id = $1`, templateID, ).Scan(&name, &journalID, &companyID) if err != nil { return nil, fmt.Errorf("account: read template %d: %w", templateID, err) } if journalID == 0 { return nil, fmt.Errorf("account: template %d has no journal", templateID) } // Optional total amount for percentage lines var totalAmount float64 if len(args) > 0 { if v, ok := toFloat(args[0]); ok { totalAmount = v } } // Read template lines rows, err := env.Tx().Query(env.Ctx(), `SELECT id, COALESCE(name, ''), COALESCE(account_id, 0), COALESCE(amount_type, 'fixed'), COALESCE(amount::float8, 0) FROM account_move_template_line WHERE template_id = $1 ORDER BY id`, templateID) if err != nil { return nil, fmt.Errorf("account: read template lines: %w", err) } type tplLine struct { name string accountID int64 amountType string amount float64 } var tplLines []tplLine for rows.Next() { var tl tplLine var lineID int64 rows.Scan(&lineID, &tl.name, &tl.accountID, &tl.amountType, &tl.amount) tplLines = append(tplLines, tl) } rows.Close() if len(tplLines) == 0 { return nil, fmt.Errorf("account: template %d has no lines", templateID) } // Resolve currency from company var currencyID int64 if companyID > 0 { env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(currency_id, 0) FROM res_company WHERE id = $1`, companyID, ).Scan(¤cyID) } // Create the move moveRS := env.Model("account.move") move, err := moveRS.Create(orm.Values{ "move_type": "entry", "ref": fmt.Sprintf("From template: %s", name), "date": time.Now().Format("2006-01-02"), "journal_id": journalID, "company_id": companyID, "currency_id": currencyID, }) if err != nil { return nil, fmt.Errorf("account: create move from template: %w", err) } lineRS := env.Model("account.move.line") for _, tl := range tplLines { amount := tl.amount if tl.amountType == "percentage" && totalAmount != 0 { amount = totalAmount * tl.amount / 100.0 } var debit, credit float64 if amount >= 0 { debit = amount } else { credit = -amount } if _, err := lineRS.Create(orm.Values{ "move_id": move.ID(), "account_id": tl.accountID, "name": tl.name, "debit": debit, "credit": credit, "balance": amount, "company_id": companyID, "journal_id": journalID, "currency_id": currencyID, "display_type": "product", }); err != nil { return nil, fmt.Errorf("account: create template 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 }) // -- Template lines -- line := orm.NewModel("account.move.template.line", orm.ModelOpts{ Description: "Journal Entry Template Line", }) line.AddFields( orm.Many2one("template_id", "account.move.template", orm.FieldOpts{ String: "Template", Required: true, OnDelete: orm.OnDeleteCascade, }), orm.Char("name", orm.FieldOpts{String: "Label"}), orm.Many2one("account_id", "account.account", orm.FieldOpts{ String: "Account", Required: true, }), orm.Selection("amount_type", []orm.SelectionItem{ {Value: "fixed", Label: "Fixed Amount"}, {Value: "percentage", Label: "Percentage of Total"}, }, orm.FieldOpts{String: "Amount Type", Default: "fixed", Required: true}), orm.Float("amount", orm.FieldOpts{String: "Amount"}), ) }