package models import ( "encoding/xml" "fmt" "strings" "time" "odoo-go/pkg/orm" ) // initAccountEdi registers electronic invoicing (EDI) models. // Mirrors: odoo/addons/account_edi/models/account_edi_format.py // // EDI (Electronic Data Interchange) handles electronic invoice formats like // UBL, Factur-X, and XRechnung. This provides the base framework. func initAccountEdi() { // -- EDI Format -- // Defines a supported electronic invoice format. ef := orm.NewModel("account.edi.format", orm.ModelOpts{ Description: "Electronic Invoicing Format", Order: "name", }) ef.AddFields( orm.Char("name", orm.FieldOpts{String: "Name", Required: true}), orm.Char("code", orm.FieldOpts{String: "Code", Required: true, Help: "Technical code, e.g. facturx_1_0_05, ubl_2_1"}), ) // -- EDI Document -- // Links an invoice to an EDI format with state tracking. ed := orm.NewModel("account.edi.document", orm.ModelOpts{ Description: "Electronic Invoicing Document", Order: "id desc", }) ed.AddFields( orm.Many2one("move_id", "account.move", orm.FieldOpts{ String: "Invoice", Required: true, OnDelete: orm.OnDeleteCascade, Index: true, }), orm.Many2one("edi_format_id", "account.edi.format", orm.FieldOpts{ String: "Format", Required: true, }), orm.Selection("state", []orm.SelectionItem{ {Value: "to_send", Label: "To Send"}, {Value: "sent", Label: "Sent"}, {Value: "to_cancel", Label: "To Cancel"}, {Value: "cancelled", Label: "Cancelled"}, }, orm.FieldOpts{String: "Status", Default: "to_send", Required: true}), orm.Binary("attachment_id", orm.FieldOpts{String: "Attachment"}), orm.Char("error", orm.FieldOpts{String: "Error"}), orm.Boolean("blocking_level", orm.FieldOpts{String: "Blocking Level"}), ) // action_export_xml: generate UBL XML for the invoice. // Mirrors: odoo/addons/account_edi/models/account_edi_format.py _export_invoice_ubl() ed.RegisterMethod("action_export_xml", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() docID := rs.IDs()[0] var moveID int64 var formatCode string env.Tx().QueryRow(env.Ctx(), `SELECT d.move_id, f.code FROM account_edi_document d JOIN account_edi_format f ON f.id = d.edi_format_id WHERE d.id = $1`, docID, ).Scan(&moveID, &formatCode) if moveID == 0 { return nil, fmt.Errorf("account: EDI document has no linked invoice") } xmlContent, err := generateInvoiceXML(env, moveID, formatCode) if err != nil { env.Tx().Exec(env.Ctx(), `UPDATE account_edi_document SET error = $1 WHERE id = $2`, err.Error(), docID) return nil, err } // Mark as sent env.Tx().Exec(env.Ctx(), `UPDATE account_edi_document SET state = 'sent', error = NULL WHERE id = $1`, docID) return map[string]interface{}{ "xml": xmlContent, "move_id": moveID, "format": formatCode, }, nil }) // Extend account.move with EDI fields initAccountMoveEdiExtension() } // initAccountMoveEdiExtension adds EDI-related fields to account.move. func initAccountMoveEdiExtension() { ext := orm.ExtendModel("account.move") ext.AddFields( orm.One2many("edi_document_ids", "account.edi.document", "move_id", orm.FieldOpts{ String: "Electronic Documents", }), orm.Selection("edi_state", []orm.SelectionItem{ {Value: "to_send", Label: "To Send"}, {Value: "sent", Label: "Sent"}, {Value: "to_cancel", Label: "To Cancel"}, {Value: "cancelled", Label: "Cancelled"}, }, orm.FieldOpts{String: "Electronic Invoicing State"}), orm.Boolean("edi_show_cancel_button", orm.FieldOpts{ String: "Show Cancel EDI Button", Compute: "_compute_edi_show_cancel_button", }), ) // _compute_edi_show_cancel_button ext.RegisterCompute("edi_show_cancel_button", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] var sentCount int env.Tx().QueryRow(env.Ctx(), `SELECT COUNT(*) FROM account_edi_document WHERE move_id = $1 AND state = 'sent'`, moveID, ).Scan(&sentCount) return orm.Values{"edi_show_cancel_button": sentCount > 0}, nil }) // action_process_edi_web_services: send all pending EDI documents ext.RegisterMethod("action_process_edi_web_services", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, moveID := range rs.IDs() { // Find pending EDI documents rows, err := env.Tx().Query(env.Ctx(), `SELECT id FROM account_edi_document WHERE move_id = $1 AND state = 'to_send'`, moveID) if err != nil { continue } var docIDs []int64 for rows.Next() { var docID int64 rows.Scan(&docID) docIDs = append(docIDs, docID) } rows.Close() for _, docID := range docIDs { docRS := env.Model("account.edi.document").Browse(docID) ediModel := orm.Registry.Get("account.edi.document") if ediModel != nil { if exportMethod, ok := ediModel.Methods["action_export_xml"]; ok { exportMethod(docRS) } } } } return true, nil }) } // -- XML generation for UBL/Factur-X -- // UBLInvoice represents a simplified UBL 2.1 invoice structure. type UBLInvoice struct { XMLName xml.Name `xml:"Invoice"` XMLNS string `xml:"xmlns,attr"` XMLNSCAC string `xml:"xmlns:cac,attr"` XMLNSCBC string `xml:"xmlns:cbc,attr"` UBLVersionID string `xml:"cbc:UBLVersionID"` CustomizationID string `xml:"cbc:CustomizationID"` ID string `xml:"cbc:ID"` IssueDate string `xml:"cbc:IssueDate"` DueDate string `xml:"cbc:DueDate,omitempty"` InvoiceTypeCode string `xml:"cbc:InvoiceTypeCode"` DocumentCurrencyCode string `xml:"cbc:DocumentCurrencyCode"` Supplier UBLParty `xml:"cac:AccountingSupplierParty>cac:Party"` Customer UBLParty `xml:"cac:AccountingCustomerParty>cac:Party"` TaxTotal UBLTaxTotal `xml:"cac:TaxTotal"` LegalMonetaryTotal UBLMonetary `xml:"cac:LegalMonetaryTotal"` InvoiceLines []UBLLine `xml:"cac:InvoiceLine"` } // UBLParty represents a party in UBL. type UBLParty struct { Name string `xml:"cac:PartyName>cbc:Name"` Street string `xml:"cac:PostalAddress>cbc:StreetName,omitempty"` City string `xml:"cac:PostalAddress>cbc:CityName,omitempty"` Zip string `xml:"cac:PostalAddress>cbc:PostalZone,omitempty"` Country string `xml:"cac:PostalAddress>cac:Country>cbc:IdentificationCode,omitempty"` TaxID string `xml:"cac:PartyTaxScheme>cbc:CompanyID,omitempty"` } // UBLTaxTotal represents tax totals. type UBLTaxTotal struct { TaxAmount string `xml:"cbc:TaxAmount"` } // UBLMonetary represents monetary totals. type UBLMonetary struct { LineExtensionAmount string `xml:"cbc:LineExtensionAmount"` TaxExclusiveAmount string `xml:"cbc:TaxExclusiveAmount"` TaxInclusiveAmount string `xml:"cbc:TaxInclusiveAmount"` PayableAmount string `xml:"cbc:PayableAmount"` } // UBLLine represents an invoice line in UBL. type UBLLine struct { ID string `xml:"cbc:ID"` Quantity string `xml:"cbc:InvoicedQuantity"` LineAmount string `xml:"cbc:LineExtensionAmount"` ItemName string `xml:"cac:Item>cbc:Name"` PriceAmount string `xml:"cac:Price>cbc:PriceAmount"` } // generateInvoiceXML creates a UBL 2.1 XML representation of an invoice. // Mirrors: odoo/addons/account_edi_ubl_cii/models/account_edi_xml_ubl_20.py func generateInvoiceXML(env *orm.Environment, moveID int64, formatCode string) (string, error) { // Read move header var name, moveType string var invoiceDate, dueDate *string var partnerID, companyID int64 var amountUntaxed, amountTax, amountTotal float64 err := env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, '/'), COALESCE(move_type, 'out_invoice'), invoice_date::text, invoice_date_due::text, COALESCE(partner_id, 0), COALESCE(company_id, 0), COALESCE(amount_untaxed::float8, 0), COALESCE(amount_tax::float8, 0), COALESCE(amount_total::float8, 0) FROM account_move WHERE id = $1`, moveID, ).Scan(&name, &moveType, &invoiceDate, &dueDate, &partnerID, &companyID, &amountUntaxed, &amountTax, &amountTotal) if err != nil { return "", fmt.Errorf("account: read move for XML: %w", err) } // Determine invoice type code (UBL standard) typeCode := "380" // Commercial Invoice switch moveType { case "out_refund", "in_refund": typeCode = "381" // Credit Note case "out_receipt", "in_receipt": typeCode = "325" // Receipt } // Read supplier (company) var companyName string var companyStreet, companyCity, companyZip, companyCountry *string var companyVat *string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(c.name, ''), p.street, p.city, p.zip, co.code, c.vat FROM res_company c LEFT JOIN res_partner p ON p.id = c.partner_id LEFT JOIN res_country co ON co.id = p.country_id WHERE c.id = $1`, companyID, ).Scan(&companyName, &companyStreet, &companyCity, &companyZip, &companyCountry, &companyVat) // Read customer (partner) var customerName string var customerStreet, customerCity, customerZip, customerCountry *string var customerVat *string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(p.name, ''), p.street, p.city, p.zip, co.code, p.vat FROM res_partner p LEFT JOIN res_country co ON co.id = p.country_id WHERE p.id = $1`, partnerID, ).Scan(&customerName, &customerStreet, &customerCity, &customerZip, &customerCountry, &customerVat) // Read invoice lines lineRows, err := env.Tx().Query(env.Ctx(), `SELECT COALESCE(name, ''), COALESCE(quantity, 1), COALESCE(price_unit::float8, 0), COALESCE(price_subtotal::float8, 0) FROM account_move_line WHERE move_id = $1 AND (display_type IS NULL OR display_type = '' OR display_type = 'product') ORDER BY sequence, id`, moveID) if err != nil { return "", fmt.Errorf("account: read lines for XML: %w", err) } defer lineRows.Close() var ublLines []UBLLine lineNum := 1 for lineRows.Next() { var lineName string var qty, price, subtotal float64 if err := lineRows.Scan(&lineName, &qty, &price, &subtotal); err != nil { continue } ublLines = append(ublLines, UBLLine{ ID: fmt.Sprintf("%d", lineNum), Quantity: fmt.Sprintf("%.2f", qty), LineAmount: fmt.Sprintf("%.2f", subtotal), ItemName: lineName, PriceAmount: fmt.Sprintf("%.2f", price), }) lineNum++ } issueDateStr := time.Now().Format("2006-01-02") if invoiceDate != nil && *invoiceDate != "" { issueDateStr = *invoiceDate } dueDateStr := "" if dueDate != nil { dueDateStr = *dueDate } invoice := UBLInvoice{ XMLNS: "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2", XMLNSCAC: "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2", XMLNSCBC: "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2", UBLVersionID: "2.1", CustomizationID: "urn:cen.eu:en16931:2017", ID: name, IssueDate: issueDateStr, DueDate: dueDateStr, InvoiceTypeCode: typeCode, DocumentCurrencyCode: getCurrencyCode(env, moveID), Supplier: UBLParty{ Name: companyName, Street: ptrStr(companyStreet), City: ptrStr(companyCity), Zip: ptrStr(companyZip), Country: ptrStr(companyCountry), TaxID: ptrStr(companyVat), }, Customer: UBLParty{ Name: customerName, Street: ptrStr(customerStreet), City: ptrStr(customerCity), Zip: ptrStr(customerZip), Country: ptrStr(customerCountry), TaxID: ptrStr(customerVat), }, TaxTotal: UBLTaxTotal{ TaxAmount: fmt.Sprintf("%.2f", amountTax), }, LegalMonetaryTotal: UBLMonetary{ LineExtensionAmount: fmt.Sprintf("%.2f", amountUntaxed), TaxExclusiveAmount: fmt.Sprintf("%.2f", amountUntaxed), TaxInclusiveAmount: fmt.Sprintf("%.2f", amountTotal), PayableAmount: fmt.Sprintf("%.2f", amountTotal), }, InvoiceLines: ublLines, } output, err := xml.MarshalIndent(invoice, "", " ") if err != nil { return "", fmt.Errorf("account: marshal XML: %w", err) } var b strings.Builder b.WriteString(xml.Header) b.Write(output) return b.String(), nil } // getCurrencyCode returns the ISO currency code for an invoice, defaulting to "EUR". func getCurrencyCode(env *orm.Environment, moveID int64) string { var code string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(c.name, 'EUR') FROM account_move m JOIN res_currency c ON c.id = m.currency_id WHERE m.id = $1`, moveID).Scan(&code) if code == "" { return "EUR" } return code } // ptrStr safely dereferences a *string, returning "" if nil. func ptrStr(s *string) string { if s != nil { return *s } return "" }