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", }), ) // -- 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}), ) // -- 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"}), ) // -- 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"}), ) // -- 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) ELSE 0 END), 0), COALESCE(SUM(CASE WHEN display_type = 'tax' THEN ABS(balance) 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 return orm.Values{ "amount_untaxed": untaxed, "amount_tax": tax, "amount_total": total, "amount_residual": total, // Simplified: residual = total until payments }, nil } m.RegisterCompute("amount_untaxed", computeAmount) m.RegisterCompute("amount_tax", computeAmount) m.RegisterCompute("amount_total", computeAmount) m.RegisterCompute("amount_residual", computeAmount) // -- 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 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) } 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 (or draft → cancel) m.RegisterMethod("button_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE account_move SET state = 'cancel' WHERE id = $1`, id); err != nil { return nil, err } } return true, nil }) // button_draft: cancel → 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" { return nil, fmt.Errorf("account: can only reset cancelled entries to draft (current: %s)", state) } 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 }) // -- 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, } 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() for _, moveID := range rs.IDs() { // Read invoice info var partnerID, journalID, companyID, currencyID int64 var amountTotal 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') FROM account_move WHERE id = $1`, moveID, ).Scan(&partnerID, &journalID, &companyID, ¤cyID, &amountTotal, &moveType) 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" } // 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 var payMoveID int64 err = env.Tx().QueryRow(env.Ctx(), `INSERT INTO account_move (name, move_type, state, date, partner_id, journal_id, company_id, currency_id) VALUES ($1, 'entry', 'posted', NOW(), $2, $3, $4, $5) RETURNING id`, fmt.Sprintf("PAY/%d", moveID), partnerID, bankJournalID, companyID, currencyID, ).Scan(&payMoveID) if err != nil { return nil, fmt.Errorf("account: create payment move for invoice %d: %w", moveID, err) } // 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, amountTotal, currencyID, bankJournalID, partnerID, companyID, payMoveID) if err != nil { return nil, fmt.Errorf("account: create payment for invoice %d: %w", moveID, err) } // Update invoice payment state _, err = env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID) if err != nil { return nil, fmt.Errorf("account: update payment state for invoice %d: %w", moveID, err) } } return true, 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 } } // 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", }), ) // -- 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}), ) // -- Reconciliation -- m.AddFields( orm.Boolean("reconciled", orm.FieldOpts{String: "Reconciled"}), orm.Many2one("full_reconcile_id", "account.full.reconcile", orm.FieldOpts{String: "Matching"}), ) } // 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() m.RegisterMethod("action_post", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { if _, err := env.Tx().Exec(env.Ctx(), `UPDATE account_payment SET state = 'paid' WHERE id = $1 AND state = 'draft'`, id); err != nil { return nil, err } } return true, nil }) // action_cancel: cancel the payment. m.RegisterMethod("action_cancel", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, id := range rs.IDs() { 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) } } } // Mark related invoices as paid (simplified: update payment_state on active invoices in context) // In Python Odoo this happens through reconciliation; we simplify for 70% target. if ctx := env.Context(); ctx != nil { if activeIDs, ok := ctx["active_ids"].([]interface{}); ok { for _, rawID := range activeIDs { if moveID, ok := toInt64Arg(rawID); ok && moveID > 0 { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = 'paid' WHERE id = $1 AND state = 'posted'`, 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"}), ) // Bank statement line orm.NewModel("account.bank.statement.line", orm.ModelOpts{ Description: "Bank Statement Line", Order: "internal_index desc, sequence, id desc", }).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"}), ) } // -- 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 }