account_partial_reconcile.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. # -*- coding: utf-8 -*-
  2. from odoo import api, fields, models, _, Command
  3. from odoo.exceptions import UserError, ValidationError
  4. from datetime import date
  5. class AccountPartialReconcile(models.Model):
  6. _name = "account.partial.reconcile"
  7. _description = "Partial Reconcile"
  8. _rec_name = "id"
  9. # ==== Reconciliation fields ====
  10. debit_move_id = fields.Many2one(
  11. comodel_name='account.move.line',
  12. index=True, required=True)
  13. credit_move_id = fields.Many2one(
  14. comodel_name='account.move.line',
  15. index=True, required=True)
  16. full_reconcile_id = fields.Many2one(
  17. comodel_name='account.full.reconcile',
  18. string="Full Reconcile", copy=False)
  19. exchange_move_id = fields.Many2one(comodel_name='account.move')
  20. # ==== Currency fields ====
  21. company_currency_id = fields.Many2one(
  22. comodel_name='res.currency',
  23. string="Company Currency",
  24. related='company_id.currency_id',
  25. help="Utility field to express amount currency")
  26. debit_currency_id = fields.Many2one(
  27. comodel_name='res.currency',
  28. store=True,
  29. related='debit_move_id.currency_id', precompute=True,
  30. string="Currency of the debit journal item.")
  31. credit_currency_id = fields.Many2one(
  32. comodel_name='res.currency',
  33. store=True,
  34. related='credit_move_id.currency_id', precompute=True,
  35. string="Currency of the credit journal item.")
  36. # ==== Amount fields ====
  37. amount = fields.Monetary(
  38. currency_field='company_currency_id',
  39. help="Always positive amount concerned by this matching expressed in the company currency.")
  40. debit_amount_currency = fields.Monetary(
  41. currency_field='debit_currency_id',
  42. help="Always positive amount concerned by this matching expressed in the debit line foreign currency.")
  43. credit_amount_currency = fields.Monetary(
  44. currency_field='credit_currency_id',
  45. help="Always positive amount concerned by this matching expressed in the credit line foreign currency.")
  46. # ==== Other fields ====
  47. company_id = fields.Many2one(
  48. comodel_name='res.company',
  49. string="Company", store=True, readonly=False,
  50. related='debit_move_id.company_id')
  51. max_date = fields.Date(
  52. string="Max Date of Matched Lines", store=True,
  53. compute='_compute_max_date')
  54. # used to determine at which date this reconciliation needs to be shown on the aged receivable/payable reports
  55. # -------------------------------------------------------------------------
  56. # CONSTRAINT METHODS
  57. # -------------------------------------------------------------------------
  58. @api.constrains('debit_currency_id', 'credit_currency_id')
  59. def _check_required_computed_currencies(self):
  60. bad_partials = self.filtered(lambda partial: not partial.debit_currency_id or not partial.credit_currency_id)
  61. if bad_partials:
  62. raise ValidationError(_("Missing foreign currencies on partials having ids: %s", bad_partials.ids))
  63. # -------------------------------------------------------------------------
  64. # COMPUTE METHODS
  65. # -------------------------------------------------------------------------
  66. @api.depends('debit_move_id.date', 'credit_move_id.date')
  67. def _compute_max_date(self):
  68. for partial in self:
  69. partial.max_date = max(
  70. partial.debit_move_id.date,
  71. partial.credit_move_id.date
  72. )
  73. # -------------------------------------------------------------------------
  74. # LOW-LEVEL METHODS
  75. # -------------------------------------------------------------------------
  76. def unlink(self):
  77. # OVERRIDE to unlink full reconcile linked to the current partials
  78. # and reverse the tax cash basis journal entries.
  79. # Avoid cyclic unlink calls when removing the partials that could remove some full reconcile
  80. # and then, loop again and again.
  81. if not self:
  82. return True
  83. # Retrieve the matching number to unlink.
  84. full_to_unlink = self.full_reconcile_id
  85. # Retrieve the CABA entries to reverse.
  86. moves_to_reverse = self.env['account.move'].search([('tax_cash_basis_rec_id', 'in', self.ids)])
  87. # Same for the exchange difference entries.
  88. moves_to_reverse += self.exchange_move_id
  89. # Unlink partials before doing anything else to avoid 'Record has already been deleted' due to the recursion.
  90. res = super().unlink()
  91. # Remove the matching numbers before reversing the moves to avoid trying to remove the full twice.
  92. full_to_unlink.unlink()
  93. # Reverse CABA entries.
  94. if moves_to_reverse:
  95. default_values_list = [{
  96. 'date': move._get_accounting_date(move.date, move._affect_tax_report()),
  97. 'ref': _('Reversal of: %s') % move.name,
  98. } for move in moves_to_reverse]
  99. moves_to_reverse._reverse_moves(default_values_list, cancel=True)
  100. return res
  101. # -------------------------------------------------------------------------
  102. # RECONCILIATION METHODS
  103. # -------------------------------------------------------------------------
  104. def _collect_tax_cash_basis_values(self):
  105. ''' Collect all information needed to create the tax cash basis journal entries on the current partials.
  106. :return: A dictionary mapping each move_id to the result of 'account_move._collect_tax_cash_basis_values'.
  107. Also, add the 'partials' keys being a list of dictionary, one for each partial to process:
  108. * partial: The account.partial.reconcile record.
  109. * percentage: The reconciled percentage represented by the partial.
  110. * payment_rate: The applied rate of this partial.
  111. '''
  112. tax_cash_basis_values_per_move = {}
  113. if not self:
  114. return {}
  115. for partial in self:
  116. for move in {partial.debit_move_id.move_id, partial.credit_move_id.move_id}:
  117. # Collect data about cash basis.
  118. if move.id in tax_cash_basis_values_per_move:
  119. move_values = tax_cash_basis_values_per_move[move.id]
  120. else:
  121. move_values = move._collect_tax_cash_basis_values()
  122. # Nothing to process on the move.
  123. if not move_values:
  124. continue
  125. # Check the cash basis configuration only when at least one cash basis tax entry need to be created.
  126. journal = partial.company_id.tax_cash_basis_journal_id
  127. if not journal:
  128. raise UserError(_("There is no tax cash basis journal defined for the '%s' company.\n"
  129. "Configure it in Accounting/Configuration/Settings") % partial.company_id.display_name)
  130. partial_amount = 0.0
  131. partial_amount_currency = 0.0
  132. rate_amount = 0.0
  133. rate_amount_currency = 0.0
  134. if partial.debit_move_id.move_id == move:
  135. partial_amount += partial.amount
  136. partial_amount_currency += partial.debit_amount_currency
  137. rate_amount -= partial.credit_move_id.balance
  138. rate_amount_currency -= partial.credit_move_id.amount_currency
  139. source_line = partial.debit_move_id
  140. counterpart_line = partial.credit_move_id
  141. if partial.credit_move_id.move_id == move:
  142. partial_amount += partial.amount
  143. partial_amount_currency += partial.credit_amount_currency
  144. rate_amount += partial.debit_move_id.balance
  145. rate_amount_currency += partial.debit_move_id.amount_currency
  146. source_line = partial.credit_move_id
  147. counterpart_line = partial.debit_move_id
  148. if partial.debit_move_id.move_id.is_invoice(include_receipts=True) and partial.credit_move_id.move_id.is_invoice(include_receipts=True):
  149. # Will match when reconciling a refund with an invoice.
  150. # In this case, we want to use the rate of each businness document to compute its cash basis entry,
  151. # not the rate of what it's reconciled with.
  152. rate_amount = source_line.balance
  153. rate_amount_currency = source_line.amount_currency
  154. payment_date = move.date
  155. else:
  156. payment_date = counterpart_line.date
  157. if move_values['currency'] == move.company_id.currency_id:
  158. # Ignore the exchange difference.
  159. if move.company_currency_id.is_zero(partial_amount):
  160. continue
  161. # Percentage made on company's currency.
  162. percentage = partial_amount / move_values['total_balance']
  163. else:
  164. # Ignore the exchange difference.
  165. if move.currency_id.is_zero(partial_amount_currency):
  166. continue
  167. # Percentage made on foreign currency.
  168. percentage = partial_amount_currency / move_values['total_amount_currency']
  169. if source_line.currency_id != counterpart_line.currency_id:
  170. # When the invoice and the payment are not sharing the same foreign currency, the rate is computed
  171. # on-the-fly using the payment date.
  172. payment_rate = self.env['res.currency']._get_conversion_rate(
  173. counterpart_line.company_currency_id,
  174. source_line.currency_id,
  175. counterpart_line.company_id,
  176. payment_date,
  177. )
  178. elif rate_amount:
  179. payment_rate = rate_amount_currency / rate_amount
  180. else:
  181. payment_rate = 0.0
  182. tax_cash_basis_values_per_move[move.id] = move_values
  183. partial_vals = {
  184. 'partial': partial,
  185. 'percentage': percentage,
  186. 'payment_rate': payment_rate,
  187. }
  188. # Add partials.
  189. move_values.setdefault('partials', [])
  190. move_values['partials'].append(partial_vals)
  191. # Clean-up moves having nothing to process.
  192. return {k: v for k, v in tax_cash_basis_values_per_move.items() if v}
  193. @api.model
  194. def _prepare_cash_basis_base_line_vals(self, base_line, balance, amount_currency):
  195. ''' Prepare the values to be used to create the cash basis journal items for the tax base line
  196. passed as parameter.
  197. :param base_line: An account.move.line being the base of some taxes.
  198. :param balance: The balance to consider for this line.
  199. :param amount_currency: The balance in foreign currency to consider for this line.
  200. :return: A python dictionary that could be passed to the create method of
  201. account.move.line.
  202. '''
  203. account = base_line.company_id.account_cash_basis_base_account_id or base_line.account_id
  204. tax_ids = base_line.tax_ids.flatten_taxes_hierarchy().filtered(lambda x: x.tax_exigibility == 'on_payment')
  205. is_refund = base_line.is_refund
  206. tax_tags = tax_ids.get_tax_tags(is_refund, 'base')
  207. product_tags = base_line.tax_tag_ids.filtered(lambda x: x.applicability == 'products')
  208. all_tags = tax_tags + product_tags
  209. return {
  210. 'name': base_line.move_id.name,
  211. 'debit': balance if balance > 0.0 else 0.0,
  212. 'credit': -balance if balance < 0.0 else 0.0,
  213. 'amount_currency': amount_currency,
  214. 'currency_id': base_line.currency_id.id,
  215. 'partner_id': base_line.partner_id.id,
  216. 'account_id': account.id,
  217. 'tax_ids': [Command.set(tax_ids.ids)],
  218. 'tax_tag_ids': [Command.set(all_tags.ids)],
  219. }
  220. @api.model
  221. def _prepare_cash_basis_counterpart_base_line_vals(self, cb_base_line_vals):
  222. ''' Prepare the move line used as a counterpart of the line created by
  223. _prepare_cash_basis_base_line_vals.
  224. :param cb_base_line_vals: The line returned by _prepare_cash_basis_base_line_vals.
  225. :return: A python dictionary that could be passed to the create method of
  226. account.move.line.
  227. '''
  228. return {
  229. 'name': cb_base_line_vals['name'],
  230. 'debit': cb_base_line_vals['credit'],
  231. 'credit': cb_base_line_vals['debit'],
  232. 'account_id': cb_base_line_vals['account_id'],
  233. 'amount_currency': -cb_base_line_vals['amount_currency'],
  234. 'currency_id': cb_base_line_vals['currency_id'],
  235. 'partner_id': cb_base_line_vals['partner_id'],
  236. }
  237. @api.model
  238. def _prepare_cash_basis_tax_line_vals(self, tax_line, balance, amount_currency):
  239. ''' Prepare the move line corresponding to a tax in the cash basis entry.
  240. :param tax_line: An account.move.line record being a tax line.
  241. :param balance: The balance to consider for this line.
  242. :param amount_currency: The balance in foreign currency to consider for this line.
  243. :return: A python dictionary that could be passed to the create method of
  244. account.move.line.
  245. '''
  246. tax_ids = tax_line.tax_ids.filtered(lambda x: x.tax_exigibility == 'on_payment')
  247. base_tags = tax_ids.get_tax_tags(tax_line.tax_repartition_line_id.refund_tax_id, 'base')
  248. product_tags = tax_line.tax_tag_ids.filtered(lambda x: x.applicability == 'products')
  249. all_tags = base_tags + tax_line.tax_repartition_line_id.tag_ids + product_tags
  250. return {
  251. 'name': tax_line.name,
  252. 'debit': balance if balance > 0.0 else 0.0,
  253. 'credit': -balance if balance < 0.0 else 0.0,
  254. 'tax_base_amount': tax_line.tax_base_amount,
  255. 'tax_repartition_line_id': tax_line.tax_repartition_line_id.id,
  256. 'tax_ids': [Command.set(tax_ids.ids)],
  257. 'tax_tag_ids': [Command.set(all_tags.ids)],
  258. 'account_id': tax_line.tax_repartition_line_id.account_id.id or tax_line.company_id.account_cash_basis_base_account_id.id or tax_line.account_id.id,
  259. 'amount_currency': amount_currency,
  260. 'currency_id': tax_line.currency_id.id,
  261. 'partner_id': tax_line.partner_id.id,
  262. # No need to set tax_tag_invert as on the base line; it will be computed from the repartition line
  263. }
  264. @api.model
  265. def _prepare_cash_basis_counterpart_tax_line_vals(self, tax_line, cb_tax_line_vals):
  266. ''' Prepare the move line used as a counterpart of the line created by
  267. _prepare_cash_basis_tax_line_vals.
  268. :param tax_line: An account.move.line record being a tax line.
  269. :param cb_tax_line_vals: The result of _prepare_cash_basis_counterpart_tax_line_vals.
  270. :return: A python dictionary that could be passed to the create method of
  271. account.move.line.
  272. '''
  273. return {
  274. 'name': cb_tax_line_vals['name'],
  275. 'debit': cb_tax_line_vals['credit'],
  276. 'credit': cb_tax_line_vals['debit'],
  277. 'account_id': tax_line.account_id.id,
  278. 'amount_currency': -cb_tax_line_vals['amount_currency'],
  279. 'currency_id': cb_tax_line_vals['currency_id'],
  280. 'partner_id': cb_tax_line_vals['partner_id'],
  281. }
  282. @api.model
  283. def _get_cash_basis_base_line_grouping_key_from_vals(self, base_line_vals):
  284. ''' Get the grouping key of a cash basis base line that hasn't yet been created.
  285. :param base_line_vals: The values to create a new account.move.line record.
  286. :return: The grouping key as a tuple.
  287. '''
  288. tax_ids = base_line_vals['tax_ids'][0][2] # Decode [(6, 0, [...])] command
  289. base_taxes = self.env['account.tax'].browse(tax_ids)
  290. return (
  291. base_line_vals['currency_id'],
  292. base_line_vals['partner_id'],
  293. base_line_vals['account_id'],
  294. tuple(base_taxes.filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
  295. )
  296. @api.model
  297. def _get_cash_basis_base_line_grouping_key_from_record(self, base_line, account=None):
  298. ''' Get the grouping key of a journal item being a base line.
  299. :param base_line: An account.move.line record.
  300. :param account: Optional account to shadow the current base_line one.
  301. :return: The grouping key as a tuple.
  302. '''
  303. return (
  304. base_line.currency_id.id,
  305. base_line.partner_id.id,
  306. (account or base_line.account_id).id,
  307. tuple(base_line.tax_ids.flatten_taxes_hierarchy().filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
  308. )
  309. @api.model
  310. def _get_cash_basis_tax_line_grouping_key_from_vals(self, tax_line_vals):
  311. ''' Get the grouping key of a cash basis tax line that hasn't yet been created.
  312. :param tax_line_vals: The values to create a new account.move.line record.
  313. :return: The grouping key as a tuple.
  314. '''
  315. tax_ids = tax_line_vals['tax_ids'][0][2] # Decode [(6, 0, [...])] command
  316. base_taxes = self.env['account.tax'].browse(tax_ids)
  317. return (
  318. tax_line_vals['currency_id'],
  319. tax_line_vals['partner_id'],
  320. tax_line_vals['account_id'],
  321. tuple(base_taxes.filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
  322. tax_line_vals['tax_repartition_line_id'],
  323. )
  324. @api.model
  325. def _get_cash_basis_tax_line_grouping_key_from_record(self, tax_line, account=None):
  326. ''' Get the grouping key of a journal item being a tax line.
  327. :param tax_line: An account.move.line record.
  328. :param account: Optional account to shadow the current tax_line one.
  329. :return: The grouping key as a tuple.
  330. '''
  331. return (
  332. tax_line.currency_id.id,
  333. tax_line.partner_id.id,
  334. (account or tax_line.account_id).id,
  335. tuple(tax_line.tax_ids.filtered(lambda x: x.tax_exigibility == 'on_payment').ids),
  336. tax_line.tax_repartition_line_id.id,
  337. )
  338. def _create_tax_cash_basis_moves(self):
  339. ''' Create the tax cash basis journal entries.
  340. :return: The newly created journal entries.
  341. '''
  342. tax_cash_basis_values_per_move = self._collect_tax_cash_basis_values()
  343. today = fields.Date.context_today(self)
  344. moves_to_create = []
  345. to_reconcile_after = []
  346. for move_values in tax_cash_basis_values_per_move.values():
  347. move = move_values['move']
  348. pending_cash_basis_lines = []
  349. for partial_values in move_values['partials']:
  350. partial = partial_values['partial']
  351. # Init the journal entry.
  352. lock_date = move.company_id._get_user_fiscal_lock_date()
  353. move_date = partial.max_date if partial.max_date > (lock_date or date.min) else today
  354. move_vals = {
  355. 'move_type': 'entry',
  356. 'date': move_date,
  357. 'ref': move.name,
  358. 'journal_id': partial.company_id.tax_cash_basis_journal_id.id,
  359. 'line_ids': [],
  360. 'tax_cash_basis_rec_id': partial.id,
  361. 'tax_cash_basis_origin_move_id': move.id,
  362. 'fiscal_position_id': move.fiscal_position_id.id,
  363. }
  364. # Tracking of lines grouped all together.
  365. # Used to reduce the number of generated lines and to avoid rounding issues.
  366. partial_lines_to_create = {}
  367. for caba_treatment, line in move_values['to_process_lines']:
  368. # ==========================================================================
  369. # Compute the balance of the current line on the cash basis entry.
  370. # This balance is a percentage representing the part of the journal entry
  371. # that is actually paid by the current partial.
  372. # ==========================================================================
  373. # Percentage expressed in the foreign currency.
  374. amount_currency = line.currency_id.round(line.amount_currency * partial_values['percentage'])
  375. balance = partial_values['payment_rate'] and amount_currency / partial_values['payment_rate'] or 0.0
  376. # ==========================================================================
  377. # Prepare the mirror cash basis journal item of the current line.
  378. # Group them all together as much as possible to reduce the number of
  379. # generated journal items.
  380. # Also track the computed balance in order to avoid rounding issues when
  381. # the journal entry will be fully paid. At that case, we expect the exact
  382. # amount of each line has been covered by the cash basis journal entries
  383. # and well reported in the Tax Report.
  384. # ==========================================================================
  385. if caba_treatment == 'tax':
  386. # Tax line.
  387. cb_line_vals = self._prepare_cash_basis_tax_line_vals(line, balance, amount_currency)
  388. grouping_key = self._get_cash_basis_tax_line_grouping_key_from_vals(cb_line_vals)
  389. elif caba_treatment == 'base':
  390. # Base line.
  391. cb_line_vals = self._prepare_cash_basis_base_line_vals(line, balance, amount_currency)
  392. grouping_key = self._get_cash_basis_base_line_grouping_key_from_vals(cb_line_vals)
  393. if grouping_key in partial_lines_to_create:
  394. aggregated_vals = partial_lines_to_create[grouping_key]['vals']
  395. debit = aggregated_vals['debit'] + cb_line_vals['debit']
  396. credit = aggregated_vals['credit'] + cb_line_vals['credit']
  397. balance = debit - credit
  398. aggregated_vals.update({
  399. 'debit': balance if balance > 0 else 0,
  400. 'credit': -balance if balance < 0 else 0,
  401. 'amount_currency': aggregated_vals['amount_currency'] + cb_line_vals['amount_currency'],
  402. })
  403. if caba_treatment == 'tax':
  404. aggregated_vals.update({
  405. 'tax_base_amount': aggregated_vals['tax_base_amount'] + cb_line_vals['tax_base_amount'],
  406. })
  407. partial_lines_to_create[grouping_key]['tax_line'] += line
  408. else:
  409. partial_lines_to_create[grouping_key] = {
  410. 'vals': cb_line_vals,
  411. }
  412. if caba_treatment == 'tax':
  413. partial_lines_to_create[grouping_key].update({
  414. 'tax_line': line,
  415. })
  416. # ==========================================================================
  417. # Create the counterpart journal items.
  418. # ==========================================================================
  419. # To be able to retrieve the correct matching between the tax lines to reconcile
  420. # later, the lines will be created using a specific sequence.
  421. sequence = 0
  422. for grouping_key, aggregated_vals in partial_lines_to_create.items():
  423. line_vals = aggregated_vals['vals']
  424. line_vals['sequence'] = sequence
  425. pending_cash_basis_lines.append((grouping_key, line_vals['amount_currency']))
  426. if 'tax_repartition_line_id' in line_vals:
  427. # Tax line.
  428. tax_line = aggregated_vals['tax_line']
  429. counterpart_line_vals = self._prepare_cash_basis_counterpart_tax_line_vals(tax_line, line_vals)
  430. counterpart_line_vals['sequence'] = sequence + 1
  431. if tax_line.account_id.reconcile:
  432. move_index = len(moves_to_create)
  433. to_reconcile_after.append((tax_line, move_index, counterpart_line_vals['sequence']))
  434. else:
  435. # Base line.
  436. counterpart_line_vals = self._prepare_cash_basis_counterpart_base_line_vals(line_vals)
  437. counterpart_line_vals['sequence'] = sequence + 1
  438. sequence += 2
  439. move_vals['line_ids'] += [(0, 0, counterpart_line_vals), (0, 0, line_vals)]
  440. moves_to_create.append(move_vals)
  441. moves = self.env['account.move'].create(moves_to_create)
  442. moves._post(soft=False)
  443. # Reconcile the tax lines being on a reconcile tax basis transfer account.
  444. for lines, move_index, sequence in to_reconcile_after:
  445. # In expenses, all move lines are created manually without any grouping on tax lines.
  446. # In that case, 'lines' could be already reconciled.
  447. lines = lines.filtered(lambda x: not x.reconciled)
  448. if not lines:
  449. continue
  450. counterpart_line = moves[move_index].line_ids.filtered(lambda line: line.sequence == sequence)
  451. # When dealing with tiny amounts, the line could have a zero amount and then, be already reconciled.
  452. if counterpart_line.reconciled:
  453. continue
  454. (lines + counterpart_line).reconcile()
  455. return moves