- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3253 lines
116 KiB
Go
3253 lines
116 KiB
Go
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/<id> for portal access.
|
|
// Mirrors: odoo/addons/account/models/account_move.py _compute_access_url()
|
|
m.RegisterCompute("access_url", func(rs *orm.Recordset) (orm.Values, error) {
|
|
moveID := rs.IDs()[0]
|
|
return orm.Values{
|
|
"access_url": fmt.Sprintf("/my/invoices/%d", moveID),
|
|
}, nil
|
|
})
|
|
|
|
// _compute_invoice_has_outstanding: checks for outstanding payments.
|
|
// Mirrors: odoo/addons/account/models/account_move.py _compute_has_outstanding()
|
|
m.RegisterCompute("invoice_has_outstanding", func(rs *orm.Recordset) (orm.Values, error) {
|
|
env := rs.Env()
|
|
moveID := rs.IDs()[0]
|
|
|
|
var partnerID *int64
|
|
var moveType string
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT partner_id, COALESCE(move_type, 'entry') FROM account_move WHERE id = $1`, moveID,
|
|
).Scan(&partnerID, &moveType)
|
|
|
|
hasOutstanding := false
|
|
if partnerID != nil && *partnerID > 0 && (moveType == "out_invoice" || moveType == "out_refund") {
|
|
var count int
|
|
env.Tx().QueryRow(env.Ctx(),
|
|
`SELECT COUNT(*) FROM account_move_line l
|
|
JOIN account_account a ON a.id = l.account_id
|
|
JOIN account_move m ON m.id = l.move_id AND m.state = 'posted'
|
|
WHERE l.partner_id = $1 AND a.account_type = 'asset_receivable'
|
|
AND l.amount_residual < -0.005 AND l.reconciled = false`,
|
|
*partnerID).Scan(&count)
|
|
hasOutstanding = count > 0
|
|
}
|
|
|
|
return orm.Values{
|
|
"invoice_has_outstanding": hasOutstanding,
|
|
}, nil
|
|
})
|
|
|
|
// -- Computed Fields --
|
|
// _compute_amount: sums invoice lines to produce totals.
|
|
// Mirrors: odoo/addons/account/models/account_move.py AccountMove._compute_amount()
|
|
//
|
|
// 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"}),
|
|
)
|
|
}
|