package models import ( "crypto/sha256" "fmt" "strings" "odoo-go/pkg/orm" ) // initAccountLock registers entry locking and hash chain integrity features. // Mirrors: odoo/addons/account/models/account_move.py (hash chain features) // // France, Belgium and other countries require an immutable audit trail. // Posted entries are hashed in sequence; any tampering breaks the chain. func initAccountLock() { ext := orm.ExtendModel("account.move") ext.AddFields( orm.Char("inalterable_hash", orm.FieldOpts{ String: "Inalterability Hash", Readonly: true, Help: "Secure hash for preventing tampering with posted entries", }), orm.Char("secure_sequence_number", orm.FieldOpts{ String: "Inalterability No.", Readonly: true, }), orm.Boolean("restrict_mode_hash_table", orm.FieldOpts{ String: "Lock with Hash", Related: "journal_id.restrict_mode_hash_table", }), orm.Char("string_to_hash", orm.FieldOpts{ String: "Data to Hash", Compute: "_compute_string_to_hash", Help: "Concatenation of fields used for hashing", }), ) // _compute_string_to_hash: generates the string representation of the move // used for hash computation. Includes date, journal, partner, amounts, and company VAT. // Mirrors: odoo/addons/account/models/account_move.py _compute_string_to_hash() // // The company VAT is included to ensure entries from different legal entities // produce distinct hashes even when all other fields match. ext.RegisterCompute("string_to_hash", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] var name, moveType, state string var date interface{} var companyID, journalID int64 var partnerID *int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, '/'), COALESCE(move_type, 'entry'), COALESCE(state, 'draft'), date, COALESCE(company_id, 0), COALESCE(journal_id, 0), partner_id FROM account_move WHERE id = $1`, moveID, ).Scan(&name, &moveType, &state, &date, &companyID, &journalID, &partnerID) // Fetch company VAT for inclusion in the hash var companyVAT string if companyID > 0 { env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(p.vat, '') FROM res_company c LEFT JOIN res_partner p ON p.id = c.partner_id WHERE c.id = $1`, companyID, ).Scan(&companyVAT) } // Include line amounts rows, err := env.Tx().Query(env.Ctx(), `SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0), COALESCE(name, '') FROM account_move_line WHERE move_id = $1 ORDER BY id`, moveID) if err != nil { return orm.Values{"string_to_hash": ""}, nil } defer rows.Close() var lineData []string for rows.Next() { var accID int64 var debit, credit float64 var label string rows.Scan(&accID, &debit, &credit, &label) lineData = append(lineData, fmt.Sprintf("%d|%.2f|%.2f|%s", accID, debit, credit, label)) } pid := int64(0) if partnerID != nil { pid = *partnerID } hashStr := fmt.Sprintf("%s|%s|%v|%d|%d|%d|%s|%s", name, moveType, date, companyID, journalID, pid, strings.Join(lineData, ";"), companyVAT) return orm.Values{"string_to_hash": hashStr}, nil }) // action_hash_entry: computes and stores the hash for a posted entry. // Mirrors: odoo/addons/account/models/account_move.py _hash_move() ext.RegisterMethod("action_hash_entry", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() for _, moveID := range rs.IDs() { var state string var journalID int64 var restrictHash bool env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(m.state, 'draft'), COALESCE(m.journal_id, 0), COALESCE(j.restrict_mode_hash_table, false) FROM account_move m LEFT JOIN account_journal j ON j.id = m.journal_id WHERE m.id = $1`, moveID, ).Scan(&state, &journalID, &restrictHash) if state != "posted" || !restrictHash { continue } // Already hashed? var existingHash *string env.Tx().QueryRow(env.Ctx(), `SELECT inalterable_hash FROM account_move WHERE id = $1`, moveID, ).Scan(&existingHash) if existingHash != nil && *existingHash != "" { continue // already hashed } // Get the string to hash var stringToHash string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(name, '/') || '|' || COALESCE(move_type, 'entry') || '|' || COALESCE(date::text, '') || '|' || COALESCE(company_id::text, '0') FROM account_move WHERE id = $1`, moveID, ).Scan(&stringToHash) // Include lines lineRows, _ := env.Tx().Query(env.Ctx(), `SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0) FROM account_move_line WHERE move_id = $1 ORDER BY id`, moveID) if lineRows != nil { for lineRows.Next() { var accID int64 var debit, credit float64 lineRows.Scan(&accID, &debit, &credit) stringToHash += fmt.Sprintf("|%d:%.2f:%.2f", accID, debit, credit) } lineRows.Close() } // Get previous hash in the chain var prevHash string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(inalterable_hash, '') FROM account_move WHERE journal_id = $1 AND state = 'posted' AND inalterable_hash IS NOT NULL AND inalterable_hash != '' AND id < $2 ORDER BY secure_sequence_number DESC, id DESC LIMIT 1`, journalID, moveID, ).Scan(&prevHash) // Compute hash: SHA-256 of (previous_hash + string_to_hash) hashInput := prevHash + stringToHash hash := sha256.Sum256([]byte(hashInput)) hashHex := fmt.Sprintf("%x", hash) // Get next secure sequence number var nextSeq int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(MAX(CAST(secure_sequence_number AS INTEGER)), 0) + 1 FROM account_move WHERE journal_id = $1 AND secure_sequence_number IS NOT NULL`, journalID, ).Scan(&nextSeq) env.Tx().Exec(env.Ctx(), `UPDATE account_move SET inalterable_hash = $1, secure_sequence_number = $2 WHERE id = $3`, hashHex, fmt.Sprintf("%d", nextSeq), moveID) } return true, nil }) // action_check_hash_integrity: verify the hash chain for a journal. // Mirrors: odoo/addons/account/models/account_move.py _check_hash_integrity() ext.RegisterMethod("action_check_hash_integrity", func(rs *orm.Recordset, args ...interface{}) (interface{}, error) { env := rs.Env() moveID := rs.IDs()[0] var journalID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(journal_id, 0) FROM account_move WHERE id = $1`, moveID, ).Scan(&journalID) if journalID == 0 { return nil, fmt.Errorf("account: no journal found for move %d", moveID) } // Read all hashed entries for this journal in order rows, err := env.Tx().Query(env.Ctx(), `SELECT id, COALESCE(inalterable_hash, ''), COALESCE(name, '/') || '|' || COALESCE(move_type, 'entry') || '|' || COALESCE(date::text, '') || '|' || COALESCE(company_id::text, '0') FROM account_move WHERE journal_id = $1 AND state = 'posted' AND inalterable_hash IS NOT NULL AND inalterable_hash != '' ORDER BY CAST(secure_sequence_number AS INTEGER), id`, journalID) if err != nil { return nil, fmt.Errorf("account: query hashed entries: %w", err) } defer rows.Close() prevHash := "" entryCount := 0 for rows.Next() { var id int64 var storedHash, baseStr string if err := rows.Scan(&id, &storedHash, &baseStr); err != nil { return nil, fmt.Errorf("account: scan hash entry: %w", err) } // Include lines for this entry lineRows, _ := env.Tx().Query(env.Ctx(), `SELECT COALESCE(account_id, 0), COALESCE(debit::float8, 0), COALESCE(credit::float8, 0) FROM account_move_line WHERE move_id = $1 ORDER BY id`, id) stringToHash := baseStr if lineRows != nil { for lineRows.Next() { var accID int64 var debit, credit float64 lineRows.Scan(&accID, &debit, &credit) stringToHash += fmt.Sprintf("|%d:%.2f:%.2f", accID, debit, credit) } lineRows.Close() } // Verify hash hashInput := prevHash + stringToHash expectedHash := sha256.Sum256([]byte(hashInput)) expectedHex := fmt.Sprintf("%x", expectedHash) if storedHash != expectedHex { return map[string]interface{}{ "status": "corrupted", "message": fmt.Sprintf("Hash chain broken at entry %d", id), "entries_checked": entryCount, }, nil } prevHash = storedHash entryCount++ } return map[string]interface{}{ "status": "valid", "message": "All entries verified successfully", "entries_checked": entryCount, }, nil }) } // initAccountSequence registers sequence generation helpers for accounting. // Mirrors: odoo/addons/account/models/sequence_mixin.py func initAccountSequence() { // account.sequence.mixin fields on account.move (already mostly handled via sequence_prefix/number) // This extends with the date-range based sequences ext := orm.ExtendModel("account.move") ext.AddFields( orm.Char("highest_name", orm.FieldOpts{ String: "Highest Name", Compute: "_compute_highest_name", Help: "Technical: highest sequence name in the same journal for ordering", }), orm.Boolean("made_sequence_hole", orm.FieldOpts{ String: "Sequence Hole", Help: "Technical: whether this entry created a gap in the sequence", Default: false, }), ) ext.RegisterCompute("highest_name", func(rs *orm.Recordset) (orm.Values, error) { env := rs.Env() moveID := rs.IDs()[0] var journalID int64 env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(journal_id, 0) FROM account_move WHERE id = $1`, moveID, ).Scan(&journalID) var highest string env.Tx().QueryRow(env.Ctx(), `SELECT COALESCE(MAX(name), '/') FROM account_move WHERE journal_id = $1 AND name != '/' AND state = 'posted'`, journalID, ).Scan(&highest) return orm.Values{"highest_name": highest}, nil }) }