package models import ( "encoding/json" "fmt" "strings" "odoo-go/pkg/orm" ) // initAccountReconcileModel registers account.reconcile.model — automatic reconciliation rules. // Mirrors: odoo/addons/account/models/account_reconcile_model.py // // Reconcile models define rules that automatically match bank statement lines // with open invoices or create write-off entries. Three rule types: // - "writeoff_button": manual write-off via button // - "writeoff_suggestion": auto-suggest write-off in bank reconciliation // - "invoice_matching": auto-match with open invoices based on criteria func initAccountReconcileModel() { m := orm.NewModel("account.reconcile.model", orm.ModelOpts{ Description: "Reconcile Model", Order: "sequence, id", }) m.AddFields( orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), orm.Many2one("company_id", "res.company", orm.FieldOpts{String: "Company", Required: true}), orm.Selection("rule_type", []orm.SelectionItem{ {Value: "writeoff_button", Label: "Button to generate counterpart entry"}, {Value: "writeoff_suggestion", Label: "Rule to suggest counterpart entry"}, {Value: "invoice_matching", Label: "Rule to match invoices/bills"}, }, orm.FieldOpts{String: "Type", Default: "writeoff_button", Required: true}), orm.Boolean("auto_reconcile", orm.FieldOpts{ String: "Auto-validate", Help: "Validate the statement line automatically if the matched amount is close enough", }), orm.Boolean("active", orm.FieldOpts{String: "Active", Default: true}), // Matching criteria orm.Boolean("match_nature", orm.FieldOpts{String: "Amount Nature", Default: true}), orm.Selection("match_amount", []orm.SelectionItem{ {Value: "lower", Label: "Is Lower Than"}, {Value: "greater", Label: "Is Greater Than"}, {Value: "between", Label: "Is Between"}, }, orm.FieldOpts{String: "Amount Condition"}), orm.Float("match_amount_min", orm.FieldOpts{String: "Amount Min"}), orm.Float("match_amount_max", orm.FieldOpts{String: "Amount Max"}), orm.Char("match_label", orm.FieldOpts{ String: "Label", Help: "Regex pattern to match the bank statement line label", }), orm.Selection("match_label_param", []orm.SelectionItem{ {Value: "contains", Label: "Contains"}, {Value: "not_contains", Label: "Not Contains"}, {Value: "match_regex", Label: "Match Regex"}, }, orm.FieldOpts{String: "Label Parameter", Default: "contains"}), orm.Char("match_note", orm.FieldOpts{String: "Note", Help: "Match on the statement line notes"}), orm.Selection("match_note_param", []orm.SelectionItem{ {Value: "contains", Label: "Contains"}, {Value: "not_contains", Label: "Not Contains"}, {Value: "match_regex", Label: "Match Regex"}, }, orm.FieldOpts{String: "Note Parameter", Default: "contains"}), orm.Many2many("match_journal_ids", "account.journal", orm.FieldOpts{ String: "Journals", Relation: "reconcile_model_journal_rel", Column1: "model_id", Column2: "journal_id", }), orm.Many2many("match_partner_ids", "res.partner", orm.FieldOpts{ String: "Partners", Relation: "reconcile_model_partner_rel", Column1: "model_id", Column2: "partner_id", }), orm.Selection("match_partner_category_id", []orm.SelectionItem{ {Value: "customer", Label: "Customer"}, {Value: "supplier", Label: "Vendor"}, }, orm.FieldOpts{String: "Partner Category"}), orm.Float("match_total_amount_param", orm.FieldOpts{ String: "Amount matching %", Default: 100, Help: "Percentage of the transaction amount to consider for matching", }), orm.Boolean("match_same_currency", orm.FieldOpts{ String: "Same Currency", Default: true, }), orm.Integer("past_months_limit", orm.FieldOpts{ String: "Search Months Limit", Default: 18, Help: "Number of months in the past to consider for matching", }), orm.Float("decimal_separator", orm.FieldOpts{String: "Decimal Separator"}), // Write-off lines orm.One2many("line_ids", "account.reconcile.model.line", "model_id", orm.FieldOpts{ String: "Write-off Lines", }), ) // apply_rules: try to match a bank statement line against this reconcile model. // Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_rules() m.RegisterMethod("apply_rules", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { if len(args) < 1 { return nil, fmt.Errorf("account: apply_rules requires a statement line ID") } env := rs.Env() modelID := rs.IDs()[0] stLineID, ok := toInt64Arg(args[0]) if !ok { return nil, fmt.Errorf("account: invalid statement line ID") } // Read model config var ruleType string var autoReconcile bool var matchLabel, matchLabelParam *string var matchAmountMin, matchAmountMax float64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(rule_type, 'writeoff_button'), COALESCE(auto_reconcile, false), match_label, match_label_param, COALESCE(match_amount_min, 0), COALESCE(match_amount_max, 0) FROM account_reconcile_model WHERE id = $1`, modelID, ).Scan(&ruleType, &autoReconcile, &matchLabel, &matchLabelParam, &matchAmountMin, &matchAmountMax) // Read statement line var stAmount float64 var stLabel, stPaymentRef *string var stPartnerID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(amount::float8, 0), payment_ref, narration, partner_id FROM account_bank_statement_line WHERE id = $1`, stLineID, ).Scan(&stAmount, &stPaymentRef, &stLabel, &stPartnerID) // Apply label matching if matchLabel != nil && *matchLabel != "" && matchLabelParam != nil { labelToCheck := "" if stPaymentRef != nil { labelToCheck = *stPaymentRef } if stLabel != nil { labelToCheck += " " + *stLabel } switch *matchLabelParam { case "contains": if !strings.Contains(strings.ToLower(labelToCheck), strings.ToLower(*matchLabel)) { return map[string]interface{}{"matched": false}, nil } case "not_contains": if strings.Contains(strings.ToLower(labelToCheck), strings.ToLower(*matchLabel)) { return map[string]interface{}{"matched": false}, nil } } } switch ruleType { case "invoice_matching": return applyInvoiceMatching(env, stLineID, stAmount, stPartnerID, autoReconcile) case "writeoff_suggestion": return applyWriteoffSuggestion(env, modelID, stLineID, stAmount) default: return map[string]interface{}{"matched": false, "rule_type": ruleType}, nil } }) // -- Reconcile Model Line (write-off definition) -- rml := orm.NewModel("account.reconcile.model.line", orm.ModelOpts{ Description: "Reconcile Model Line", Order: "sequence, id", }) rml.AddFields( orm.Many2one("model_id", "account.reconcile.model", orm.FieldOpts{ String: "Model", Required: true, OnDelete: orm.OnDeleteCascade, }), orm.Many2one("account_id", "account.account", orm.FieldOpts{ String: "Account", Required: true, }), orm.Integer("sequence", orm.FieldOpts{String: "Sequence", Default: 10}), orm.Char("label", orm.FieldOpts{String: "Journal Item Label"}), orm.Selection("amount_type", []orm.SelectionItem{ {Value: "percentage", Label: "Percentage of balance"}, {Value: "fixed", Label: "Fixed"}, {Value: "percentage_st_line", Label: "Percentage of statement line"}, {Value: "regex", Label: "From label"}, }, orm.FieldOpts{String: "Amount Type", Default: "percentage", Required: true}), orm.Float("amount", orm.FieldOpts{String: "Write-off Amount", Default: 100}), orm.Many2many("tax_ids", "account.tax", orm.FieldOpts{ String: "Taxes", Relation: "reconcile_model_line_tax_rel", Column1: "line_id", Column2: "tax_id", }), orm.Boolean("force_tax_included", orm.FieldOpts{String: "Tax Included in Price"}), orm.Many2one("company_id", "res.company", orm.FieldOpts{ String: "Company", Related: "model_id.company_id", }), ) } // applyInvoiceMatching tries to find an open invoice matching the statement line amount. // Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_invoice_matching() func applyInvoiceMatching(env *orm.Environment, stLineID int64, amount float64, partnerID *int64, autoReconcile bool) (interface{}, error) { if partnerID == nil || *partnerID == 0 { return map[string]interface{}{"matched": false, "reason": "no partner"}, nil } absAmount := amount if absAmount < 0 { absAmount = -absAmount } // Find open invoices for the partner with matching residual amount var matchedMoveLineID int64 var matchedResidual float64 err := env.Tx().QueryRow(env.Ctx(), `SELECT l.id, ABS(COALESCE(l.amount_residual::float8, 0)) FROM account_move_line l JOIN account_move m ON m.id = l.move_id AND m.state = 'posted' JOIN account_account a ON a.id = l.account_id WHERE l.partner_id = $1 AND a.account_type IN ('asset_receivable', 'liability_payable') AND ABS(COALESCE(l.amount_residual::float8, 0)) > 0.005 AND ABS(ABS(COALESCE(l.amount_residual::float8, 0)) - $2) < $2 * 0.05 ORDER BY ABS(ABS(COALESCE(l.amount_residual::float8, 0)) - $2) LIMIT 1`, *partnerID, absAmount, ).Scan(&matchedMoveLineID, &matchedResidual) if err != nil || matchedMoveLineID == 0 { return map[string]interface{}{"matched": false, "reason": "no matching invoice"}, nil } result := map[string]interface{}{ "matched": true, "move_line_id": matchedMoveLineID, "residual": matchedResidual, "auto_validate": autoReconcile, } // If auto-reconcile, mark the statement line as reconciled if autoReconcile { env.Tx().Exec(env.Ctx(), `UPDATE account_bank_statement_line SET move_line_id = $1, is_reconciled = true WHERE id = $2`, matchedMoveLineID, stLineID) } return result, nil } // applyWriteoffSuggestion suggests a write-off entry based on the reconcile model's lines. // Mirrors: odoo/addons/account/models/account_reconcile_model.py _apply_writeoff() func applyWriteoffSuggestion(env *orm.Environment, modelID, stLineID int64, amount float64) (interface{}, error) { // Read write-off lines for this model rows, err := env.Tx().Query(env.Ctx(), `SELECT account_id, COALESCE(label, ''), COALESCE(amount_type, 'percentage'), COALESCE(amount, 100) FROM account_reconcile_model_line WHERE model_id = $1 ORDER BY sequence, id`, modelID) if err != nil { return map[string]interface{}{"matched": false}, nil } defer rows.Close() var suggestions []map[string]interface{} for rows.Next() { var accountID int64 var label, amountType string var pct float64 if err := rows.Scan(&accountID, &label, &amountType, &pct); err != nil { continue } writeoffAmount := 0.0 switch amountType { case "percentage": writeoffAmount = amount * pct / 100 case "fixed": writeoffAmount = pct case "percentage_st_line": writeoffAmount = amount * pct / 100 } suggestions = append(suggestions, map[string]interface{}{ "account_id": accountID, "label": label, "amount": writeoffAmount, }) } if len(suggestions) == 0 { return map[string]interface{}{"matched": false}, nil } return map[string]interface{}{ "matched": true, "rule_type": "writeoff_suggestion", "suggestions": suggestions, }, nil } // initAccountReconcilePreview registers account.reconcile.model.preview (Odoo 18+). // Transient model for previewing reconciliation results before applying. // Mirrors: odoo/addons/account/wizard/account_reconcile_model_preview.py func initAccountReconcilePreview() { m := orm.NewModel("account.reconcile.model.preview", orm.ModelOpts{ Description: "Reconcile Model Preview", Type: orm.ModelTransient, }) m.AddFields( orm.Many2one("model_id", "account.reconcile.model", orm.FieldOpts{ String: "Reconcile Model", Required: true, }), orm.Many2one("statement_line_id", "account.bank.statement.line", orm.FieldOpts{ String: "Statement Line", }), orm.Text("preview_data", orm.FieldOpts{ String: "Preview Data", Compute: "_compute_preview", }), ) m.RegisterCompute("preview_data", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() id := rs.IDs()[0] var modelID int64 var stLineID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(model_id, 0), statement_line_id FROM account_reconcile_model_preview WHERE id = $1`, id, ).Scan(&modelID, &stLineID) if modelID == 0 { return orm.Values{"preview_data": "[]"}, nil } // Read reconcile model lines to preview what would be created rows, err := env.Tx().Query(env.Ctx(), `SELECT rml.label, rml.amount_type, rml.amount, COALESCE(a.code, ''), COALESCE(a.name, '') FROM account_reconcile_model_line rml LEFT JOIN account_account a ON a.id = rml.account_id WHERE rml.model_id = $1 ORDER BY rml.sequence`, modelID) if err != nil { return orm.Values{"preview_data": "[]"}, nil } defer rows.Close() var preview []map[string]interface{} for rows.Next() { var label, amountType, accCode, accName string var amount float64 if err := rows.Scan(&label, &amountType, &amount, &accCode, &accName); err != nil { continue } preview = append(preview, map[string]interface{}{ "label": label, "amount_type": amountType, "amount": amount, "account_code": accCode, "account_name": accName, }) } data, _ := json.Marshal(preview) return orm.Values{"preview_data": string(data)}, nil }) }