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, "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() 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) } // 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 = amountTotal } else { bankCredit = amountTotal } _, 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 = amountTotal cpResidual = -amountTotal // Negative residual for credit line } else { cpDebit = amountTotal cpResidual = amountTotal } 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) } // 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) 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 _, err := reconcileMethod(lineRS); err != nil { // Non-fatal: fall back to direct update env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID) } } else { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID) } } else { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID) } } else { env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID) } } else { // Fallback: direct payment state update (no reconciliation possible) env.Tx().Exec(env.Ctx(), `UPDATE account_move SET payment_state = 'paid' WHERE id = $1`, moveID) } } 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"}), ) // 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 all lines records, err := rs.Read([]string{"id", "debit", "credit", "amount_residual", "account_id", "partner_id", "move_id"}) if err != nil { return nil, err } // Separate debit lines (receivable) from credit lines (payment) var debitLines, creditLines []orm.Values for _, rec := range records { debit, _ := toFloat(rec["debit"]) credit, _ := toFloat(rec["credit"]) if debit > credit { debitLines = append(debitLines, rec) } else { creditLines = append(creditLines, rec) } } // Match debit <-> credit lines, creating partial reconciles partialRS := env.Model("account.partial.reconcile") var partialIDs []int64 for _, debitLine := range debitLines { debitResidual, _ := toFloat(debitLine["amount_residual"]) if debitResidual <= 0 { continue } debitLineID, _ := toInt64Arg(debitLine["id"]) for _, creditLine := range creditLines { creditResidual, _ := toFloat(creditLine["amount_residual"]) if creditResidual >= 0 { continue // credit residual is negative } creditLineID, _ := toInt64Arg(creditLine["id"]) // Match amount = min of what's available matchAmount := debitResidual if -creditResidual < matchAmount { matchAmount = -creditResidual } if matchAmount <= 0 { continue } // Create partial reconcile partial, err := partialRS.Create(orm.Values{ "debit_move_id": debitLineID, "credit_move_id": creditLineID, "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, debitLineID) env.Tx().Exec(env.Ctx(), `UPDATE account_move_line SET amount_residual = amount_residual + $1 WHERE id = $2`, matchAmount, creditLineID) debitResidual -= matchAmount creditResidual += matchAmount creditLine["amount_residual"] = creditResidual if debitResidual <= 0.005 { break } } debitLine["amount_residual"] = debitResidual } // Check if fully reconciled (all residuals ~ 0) allResolved := true for _, rec := range records { recID, _ := toInt64Arg(rec["id"]) var residual float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(amount_residual, 0) FROM account_move_line WHERE id = $1`, recID).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 { // Link all lines to the full reconcile for _, rec := range records { lineID, _ := toInt64Arg(rec["id"]) env.Tx().Exec(env.Ctx(), `UPDATE account_move_line SET full_reconcile_id = $1, reconciled = true WHERE id = $2`, fullRec.ID(), lineID) } // Link partials to full reconcile 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 _, rec := range records { if mid, ok := toInt64Arg(rec["move_id"]); ok { moveIDs[mid] = true } } for moveID := range moveIDs { 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() 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) } } } // 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)`, invoiceLineID, 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"}), ) // 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 } // 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 } 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) }