diff --git a/fusion_accounting_documents/__init__.py b/fusion_accounting_documents/__init__.py
new file mode 100644
index 00000000..aee8895e
--- /dev/null
+++ b/fusion_accounting_documents/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import wizards
diff --git a/fusion_accounting_documents/__manifest__.py b/fusion_accounting_documents/__manifest__.py
new file mode 100644
index 00000000..945929dc
--- /dev/null
+++ b/fusion_accounting_documents/__manifest__.py
@@ -0,0 +1,48 @@
+{
+ 'name': 'Fusion Accounting — Documents Bridge',
+ 'version': '19.0.1.0.0',
+ 'category': 'Accounting/Accounting',
+ 'summary': 'Bridges the Documents app to Accounting: route scanned bills into vendor invoices.',
+ 'description': """
+Fusion Accounting — Documents Bridge
+====================================
+
+A Fusion-native replacement for Enterprise's ``documents_account`` module.
+
+Adds:
+
+- ``documents.document.move_id`` — Many2one to the linked accounting move.
+- ``documents.document.is_invoice_candidate`` — computed flag for PDFs/images
+ not yet linked to a move.
+- ``documents.document.action_create_invoice()`` — opens a wizard that
+ creates a draft vendor bill and copies the document's binary as an
+ attachment on the new ``account.move``.
+- ``account.move.source_document_ids`` — reverse linkage with a stat button
+ on the invoice form.
+- A ``fusion.create.invoice.from.document.wizard`` model + form view.
+- A server action bound to ``documents.document`` so the workflow is
+ reachable from the Documents Actions menu (the Documents app uses
+ kanban/list views without a regular form view to inherit from).
+
+Auto-installs when ``documents`` and ``fusion_accounting_core`` are both
+present.
+""",
+ 'author': 'Nexa Systems Inc.',
+ 'license': 'LGPL-3',
+ 'depends': [
+ 'fusion_accounting_core',
+ 'account',
+ 'documents',
+ ],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'wizards/create_invoice_from_document_views.xml',
+ 'views/documents_document_views.xml',
+ 'views/account_move_views.xml',
+ 'data/server_actions_data.xml',
+ ],
+ 'auto_install': ['documents', 'fusion_accounting_core'],
+ 'installable': True,
+ 'application': False,
+ 'icon': '/fusion_accounting_documents/static/description/icon.png',
+}
diff --git a/fusion_accounting_documents/data/server_actions_data.xml b/fusion_accounting_documents/data/server_actions_data.xml
new file mode 100644
index 00000000..da604f5c
--- /dev/null
+++ b/fusion_accounting_documents/data/server_actions_data.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ Create Vendor Invoice (Fusion)
+
+
+ list,kanban
+ code
+
+if records and len(records) == 1:
+ action = records.action_create_invoice()
+else:
+ raise UserError(_("Select exactly one document to convert into a vendor invoice."))
+
+
+
diff --git a/fusion_accounting_documents/models/__init__.py b/fusion_accounting_documents/models/__init__.py
new file mode 100644
index 00000000..c0ebbc10
--- /dev/null
+++ b/fusion_accounting_documents/models/__init__.py
@@ -0,0 +1,2 @@
+from . import documents_document
+from . import account_move
diff --git a/fusion_accounting_documents/models/account_move.py b/fusion_accounting_documents/models/account_move.py
new file mode 100644
index 00000000..f8924ca4
--- /dev/null
+++ b/fusion_accounting_documents/models/account_move.py
@@ -0,0 +1,33 @@
+"""Reverse linkage from account.move back to source documents."""
+
+from odoo import _, fields, models
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ source_document_ids = fields.One2many(
+ 'documents.document',
+ 'move_id',
+ string='Source Documents',
+ readonly=True,
+ help="Documents in the Documents app that were used to create this move.",
+ )
+ source_document_count = fields.Integer(
+ string='Source Document Count',
+ compute='_compute_source_document_count',
+ )
+
+ def _compute_source_document_count(self):
+ for m in self:
+ m.source_document_count = len(m.source_document_ids)
+
+ def action_open_source_documents(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Source Documents'),
+ 'res_model': 'documents.document',
+ 'view_mode': 'kanban,list',
+ 'domain': [('move_id', '=', self.id)],
+ }
diff --git a/fusion_accounting_documents/models/documents_document.py b/fusion_accounting_documents/models/documents_document.py
new file mode 100644
index 00000000..f6e27985
--- /dev/null
+++ b/fusion_accounting_documents/models/documents_document.py
@@ -0,0 +1,71 @@
+"""Bridge documents.document to accounting moves.
+
+Adds a Many2one link to the created invoice/move, a computed
+``is_invoice_candidate`` flag for PDFs/images that have not yet been
+turned into a vendor bill, and the ``action_create_invoice`` entry
+point used by both the form button and the server action.
+"""
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+
+INVOICE_CANDIDATE_MIMETYPES = (
+ 'application/pdf',
+ 'image/png',
+ 'image/jpeg',
+ 'image/jpg',
+)
+
+
+class DocumentsDocument(models.Model):
+ _inherit = 'documents.document'
+
+ move_id = fields.Many2one(
+ 'account.move',
+ string='Linked Invoice/Move',
+ copy=False,
+ ondelete='set null',
+ help="The accounting move this document was used to create.",
+ )
+ is_invoice_candidate = fields.Boolean(
+ string='Is Invoice Candidate',
+ compute='_compute_is_invoice_candidate',
+ store=True,
+ help="True when this document looks like a vendor bill "
+ "(PDF/image binary) and has not yet been linked to a move.",
+ )
+
+ @api.depends('mimetype', 'type', 'move_id')
+ def _compute_is_invoice_candidate(self):
+ for d in self:
+ d.is_invoice_candidate = (
+ d.type == 'binary'
+ and (d.mimetype or '') in INVOICE_CANDIDATE_MIMETYPES
+ and not d.move_id
+ )
+
+ def action_create_invoice(self):
+ """Open the wizard to create a vendor invoice from this document."""
+ self.ensure_one()
+ if self.move_id:
+ raise UserError(_(
+ "This document is already linked to invoice %s.",
+ self.move_id.display_name,
+ ))
+ if self.type == 'folder':
+ raise UserError(_(
+ "Folders cannot be turned into invoices."
+ ))
+ if (self.mimetype or '') not in INVOICE_CANDIDATE_MIMETYPES:
+ raise UserError(_(
+ "Only PDF or image documents can be turned into invoices."
+ ))
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': _('Create Invoice from Document'),
+ 'res_model': 'fusion.create.invoice.from.document.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {'default_document_id': self.id},
+ }
diff --git a/fusion_accounting_documents/security/ir.model.access.csv b/fusion_accounting_documents/security/ir.model.access.csv
new file mode 100644
index 00000000..6f7cce5d
--- /dev/null
+++ b/fusion_accounting_documents/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_fusion_create_invoice_wizard_user,fusion.create.invoice.wizard.user,model_fusion_create_invoice_from_document_wizard,base.group_user,1,1,1,1
diff --git a/fusion_accounting_documents/static/description/icon.png b/fusion_accounting_documents/static/description/icon.png
new file mode 100644
index 00000000..6773c627
Binary files /dev/null and b/fusion_accounting_documents/static/description/icon.png differ
diff --git a/fusion_accounting_documents/tests/__init__.py b/fusion_accounting_documents/tests/__init__.py
new file mode 100644
index 00000000..4e32d8e5
--- /dev/null
+++ b/fusion_accounting_documents/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_document_to_invoice
diff --git a/fusion_accounting_documents/tests/test_document_to_invoice.py b/fusion_accounting_documents/tests/test_document_to_invoice.py
new file mode 100644
index 00000000..42991c4c
--- /dev/null
+++ b/fusion_accounting_documents/tests/test_document_to_invoice.py
@@ -0,0 +1,140 @@
+"""Tests for the documents.document <-> account.move bridge."""
+
+import base64
+
+from odoo.exceptions import UserError
+from odoo.tests import tagged
+from odoo.tests.common import TransactionCase
+
+
+@tagged('post_install', '-at_install', 'fusion_accounting_documents')
+class TestDocumentToInvoice(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.vendor = cls.env['res.partner'].create({
+ 'name': 'Test Doc Vendor',
+ 'supplier_rank': 1,
+ })
+ cls.purchase_journal = cls.env['account.journal'].search(
+ [('type', '=', 'purchase'),
+ ('company_id', '=', cls.env.company.id)],
+ limit=1,
+ )
+
+ def _make_document(self, name='Test Bill PDF',
+ mimetype='application/pdf',
+ payload=b'%PDF-fake-bill-content'):
+ attachment = self.env['ir.attachment'].create({
+ 'name': name,
+ 'datas': base64.b64encode(payload),
+ 'mimetype': mimetype,
+ })
+ Document = self.env['documents.document']
+ doc_vals = {
+ 'name': name,
+ 'attachment_id': attachment.id,
+ 'mimetype': mimetype,
+ 'type': 'binary',
+ }
+ if 'folder_id' in Document._fields:
+ folder = Document.search(
+ [('type', '=', 'folder')], limit=1,
+ )
+ if folder:
+ doc_vals['folder_id'] = folder.id
+ return Document.create(doc_vals)
+
+ def test_invoice_candidate_flag_pdf(self):
+ doc = self._make_document()
+ self.assertTrue(doc.is_invoice_candidate)
+
+ def test_invoice_candidate_flag_image(self):
+ doc = self._make_document(
+ name='scan.png',
+ mimetype='image/png',
+ payload=b'\x89PNG\r\n\x1a\nfake',
+ )
+ self.assertTrue(doc.is_invoice_candidate)
+
+ def test_invoice_candidate_flag_text_excluded(self):
+ doc = self._make_document(
+ name='note.txt',
+ mimetype='text/plain',
+ payload=b'just a note',
+ )
+ self.assertFalse(doc.is_invoice_candidate)
+
+ def test_action_create_invoice_opens_wizard(self):
+ doc = self._make_document()
+ action = doc.action_create_invoice()
+ self.assertEqual(action['type'], 'ir.actions.act_window')
+ self.assertEqual(
+ action['res_model'],
+ 'fusion.create.invoice.from.document.wizard',
+ )
+ self.assertEqual(action['target'], 'new')
+ self.assertEqual(action['context']['default_document_id'], doc.id)
+
+ def test_wizard_creates_invoice_and_links(self):
+ doc = self._make_document()
+ wizard = self.env['fusion.create.invoice.from.document.wizard'].create({
+ 'document_id': doc.id,
+ 'partner_id': self.vendor.id,
+ 'move_type': 'in_invoice',
+ })
+ self.assertTrue(wizard.journal_id, "Default journal should resolve")
+ action = wizard.action_create_invoice()
+
+ self.assertEqual(action['res_model'], 'account.move')
+ move = self.env['account.move'].browse(action['res_id'])
+ self.assertEqual(move.move_type, 'in_invoice')
+ self.assertEqual(move.partner_id, self.vendor)
+
+ self.assertEqual(doc.move_id, move)
+ self.assertFalse(doc.is_invoice_candidate,
+ "Linked docs should no longer be candidates")
+
+ self.assertEqual(move.source_document_count, 1)
+ self.assertIn(doc, move.source_document_ids)
+
+ attachments = self.env['ir.attachment'].search([
+ ('res_model', '=', 'account.move'),
+ ('res_id', '=', move.id),
+ ])
+ self.assertTrue(
+ attachments,
+ "An attachment copy should land on the new move",
+ )
+
+ def test_action_create_invoice_already_linked_raises(self):
+ doc = self._make_document()
+ existing_move = self.env['account.move'].create({
+ 'move_type': 'in_invoice',
+ 'partner_id': self.vendor.id,
+ })
+ doc.move_id = existing_move.id
+ with self.assertRaises(UserError):
+ doc.action_create_invoice()
+
+ def test_action_create_invoice_non_candidate_raises(self):
+ doc = self._make_document(
+ name='note.txt',
+ mimetype='text/plain',
+ payload=b'hello',
+ )
+ with self.assertRaises(UserError):
+ doc.action_create_invoice()
+
+ def test_wizard_creates_credit_note(self):
+ doc = self._make_document(name='credit-note.pdf')
+ wizard = self.env['fusion.create.invoice.from.document.wizard'].create({
+ 'document_id': doc.id,
+ 'partner_id': self.vendor.id,
+ 'move_type': 'in_refund',
+ })
+ action = wizard.action_create_invoice()
+ move = self.env['account.move'].browse(action['res_id'])
+ self.assertEqual(move.move_type, 'in_refund')
+ self.assertEqual(doc.move_id, move)
diff --git a/fusion_accounting_documents/views/account_move_views.xml b/fusion_accounting_documents/views/account_move_views.xml
new file mode 100644
index 00000000..fb650931
--- /dev/null
+++ b/fusion_accounting_documents/views/account_move_views.xml
@@ -0,0 +1,21 @@
+
+
+
+ account.move.form.inherit.fusion.documents
+ account.move
+
+
+
+
+
+
+
+
diff --git a/fusion_accounting_documents/views/documents_document_views.xml b/fusion_accounting_documents/views/documents_document_views.xml
new file mode 100644
index 00000000..93b439a4
--- /dev/null
+++ b/fusion_accounting_documents/views/documents_document_views.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+ documents.document.kanban.inherit.fusion.acc
+ documents.document
+
+
+
+
+
+
+
+
+
+
+ documents.document.list.inherit.fusion.acc
+ documents.document
+
+
+
+
+
+
+
+
+
diff --git a/fusion_accounting_documents/wizards/__init__.py b/fusion_accounting_documents/wizards/__init__.py
new file mode 100644
index 00000000..12d3d897
--- /dev/null
+++ b/fusion_accounting_documents/wizards/__init__.py
@@ -0,0 +1 @@
+from . import create_invoice_from_document
diff --git a/fusion_accounting_documents/wizards/create_invoice_from_document.py b/fusion_accounting_documents/wizards/create_invoice_from_document.py
new file mode 100644
index 00000000..187b5edd
--- /dev/null
+++ b/fusion_accounting_documents/wizards/create_invoice_from_document.py
@@ -0,0 +1,132 @@
+"""Wizard to create a vendor invoice from a Documents document.
+
+The wizard creates an empty draft ``account.move`` of the chosen
+move type, copies the document's binary attachment onto the new
+move, posts a chatter note linking back to the source document,
+and finally stores the move on ``documents.document.move_id`` so
+the source no longer appears as an invoice candidate.
+"""
+
+from odoo import _, api, fields, models
+from odoo.exceptions import UserError
+
+
+MOVE_TYPE_LABELS = {
+ 'in_invoice': _('Vendor Bill'),
+ 'in_refund': _('Vendor Credit Note'),
+}
+
+
+class CreateInvoiceFromDocumentWizard(models.TransientModel):
+ _name = 'fusion.create.invoice.from.document.wizard'
+ _description = 'Create Vendor Invoice from Document'
+
+ document_id = fields.Many2one(
+ 'documents.document',
+ string='Source Document',
+ required=True,
+ readonly=True,
+ ondelete='cascade',
+ )
+ document_name = fields.Char(related='document_id.name', readonly=True)
+ document_mimetype = fields.Char(related='document_id.mimetype', readonly=True)
+
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Vendor',
+ domain="[('supplier_rank', '>', 0)]",
+ )
+ move_type = fields.Selection(
+ [
+ ('in_invoice', 'Vendor Bill'),
+ ('in_refund', 'Vendor Credit Note'),
+ ],
+ string='Type',
+ default='in_invoice',
+ required=True,
+ )
+ company_id = fields.Many2one(
+ 'res.company',
+ string='Company',
+ default=lambda self: self.env.company,
+ required=True,
+ )
+ journal_id = fields.Many2one(
+ 'account.journal',
+ string='Journal',
+ domain="[('type', '=', 'purchase'), ('company_id', '=', company_id)]",
+ default=lambda self: self._default_journal(),
+ )
+
+ @api.model
+ def _default_journal(self):
+ return self.env['account.journal'].search(
+ [('type', '=', 'purchase'),
+ ('company_id', '=', self.env.company.id)],
+ limit=1,
+ )
+
+ @api.onchange('company_id')
+ def _onchange_company_id(self):
+ if self.journal_id and self.journal_id.company_id != self.company_id:
+ self.journal_id = self.env['account.journal'].search(
+ [('type', '=', 'purchase'),
+ ('company_id', '=', self.company_id.id)],
+ limit=1,
+ )
+
+ def action_create_invoice(self):
+ self.ensure_one()
+ if not self.document_id:
+ raise UserError(_("No document selected."))
+ if self.document_id.move_id:
+ raise UserError(_(
+ "Document %(doc)s is already linked to invoice %(inv)s.",
+ doc=self.document_id.display_name,
+ inv=self.document_id.move_id.display_name,
+ ))
+ if not self.journal_id:
+ raise UserError(_(
+ "No purchase journal configured for company %s.",
+ self.company_id.display_name,
+ ))
+
+ move_vals = {
+ 'move_type': self.move_type,
+ 'journal_id': self.journal_id.id,
+ 'company_id': self.company_id.id,
+ }
+ if self.partner_id:
+ move_vals['partner_id'] = self.partner_id.id
+
+ move = self.env['account.move'].create(move_vals)
+
+ attachment = self.document_id.attachment_id
+ if attachment:
+ attachment_copy = attachment.copy({
+ 'res_model': 'account.move',
+ 'res_id': move.id,
+ })
+ move.message_post(
+ body=_(
+ "Created from Documents source: %s",
+ self.document_id.name,
+ ),
+ attachment_ids=[attachment_copy.id],
+ )
+ else:
+ move.message_post(body=_(
+ "Created from Documents source: %s "
+ "(no attachment to copy).",
+ self.document_id.name,
+ ))
+
+ self.document_id.move_id = move.id
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': MOVE_TYPE_LABELS.get(self.move_type, _('Invoice')),
+ 'res_model': 'account.move',
+ 'view_mode': 'form',
+ 'res_id': move.id,
+ }
diff --git a/fusion_accounting_documents/wizards/create_invoice_from_document_views.xml b/fusion_accounting_documents/wizards/create_invoice_from_document_views.xml
new file mode 100644
index 00000000..c9ffa2e4
--- /dev/null
+++ b/fusion_accounting_documents/wizards/create_invoice_from_document_views.xml
@@ -0,0 +1,37 @@
+
+
+
+ fusion.create.invoice.from.document.wizard.form
+ fusion.create.invoice.from.document.wizard
+
+
+
+
+