-
Notifications
You must be signed in to change notification settings - Fork 24.4k
/
account_move.py
4148 lines (3652 loc) · 212 KB
/
account_move.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import RedirectWarning, UserError, ValidationError
from odoo.tools import float_is_zero, float_compare, safe_eval, date_utils, email_split, email_escape_char, email_re
from odoo.tools.misc import formatLang, format_date
from datetime import date, timedelta
from itertools import groupby
from stdnum.iso7064 import mod_97_10
from itertools import zip_longest
from hashlib import sha256
from json import dumps
import json
import re
import logging
import psycopg2
_logger = logging.getLogger(__name__)
#forbidden fields
INTEGRITY_HASH_MOVE_FIELDS = ('date', 'journal_id', 'company_id')
INTEGRITY_HASH_LINE_FIELDS = ('debit', 'credit', 'account_id', 'partner_id')
class AccountMove(models.Model):
_name = "account.move"
_inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin']
_description = "Journal Entries"
_order = 'date desc, name desc, id desc'
@api.model
def _get_default_journal(self):
''' Get the default journal.
It could either be passed through the context using the 'default_journal_id' key containing its id,
either be determined by the default type.
'''
move_type = self._context.get('default_type', 'entry')
journal_type = 'general'
if move_type in self.get_sale_types(include_receipts=True):
journal_type = 'sale'
elif move_type in self.get_purchase_types(include_receipts=True):
journal_type = 'purchase'
if self._context.get('default_journal_id'):
journal = self.env['account.journal'].browse(self._context['default_journal_id'])
if move_type != 'entry' and journal.type != journal_type:
raise UserError(_("Cannot create an invoice of type %s with a journal having %s as type.") % (move_type, journal.type))
else:
company_id = self._context.get('default_company_id', self.env.company.id)
domain = [('company_id', '=', company_id), ('type', '=', journal_type)]
journal = None
if self._context.get('default_currency_id'):
currency_domain = domain + [('currency_id', '=', self._context['default_currency_id'])]
journal = self.env['account.journal'].search(currency_domain, limit=1)
if not journal:
journal = self.env['account.journal'].search(domain, limit=1)
if not journal:
error_msg = _('Please define an accounting miscellaneous journal in your company')
if journal_type == 'sale':
error_msg = _('Please define an accounting sale journal in your company')
elif journal_type == 'purchase':
error_msg = _('Please define an accounting purchase journal in your company')
raise UserError(error_msg)
return journal
@api.model
def _get_default_invoice_date(self):
return fields.Date.today() if self._context.get('default_type', 'entry') in ('in_invoice', 'in_refund', 'in_receipt') else False
@api.model
def _get_default_currency(self):
''' Get the default currency from either the journal, either the default journal's company. '''
journal = self._get_default_journal()
return journal.currency_id or journal.company_id.currency_id
@api.model
def _get_default_invoice_incoterm(self):
''' Get the default incoterm for invoice. '''
return self.env.company.incoterm_id
# ==== Business fields ====
name = fields.Char(string='Number', required=True, readonly=True, copy=False, default='/')
date = fields.Date(string='Date', required=True, index=True, readonly=True,
states={'draft': [('readonly', False)]},
default=fields.Date.context_today)
ref = fields.Char(string='Reference', copy=False)
narration = fields.Text(string='Internal Note')
state = fields.Selection(selection=[
('draft', 'Draft'),
('posted', 'Posted'),
('cancel', 'Cancelled')
], string='Status', required=True, readonly=True, copy=False, tracking=True,
default='draft')
type = fields.Selection(selection=[
('entry', 'Journal Entry'),
('out_invoice', 'Customer Invoice'),
('out_refund', 'Customer Credit Note'),
('in_invoice', 'Vendor Bill'),
('in_refund', 'Vendor Credit Note'),
('out_receipt', 'Sales Receipt'),
('in_receipt', 'Purchase Receipt'),
], string='Type', required=True, store=True, index=True, readonly=True, tracking=True,
default="entry", change_default=True)
to_check = fields.Boolean(string='To Check', default=False,
help='If this checkbox is ticked, it means that the user was not sure of all the related informations at the time of the creation of the move and that the move needs to be checked again.')
journal_id = fields.Many2one('account.journal', string='Journal', required=True, readonly=True,
states={'draft': [('readonly', False)]},
domain="[('company_id', '=', company_id)]",
default=_get_default_journal)
user_id = fields.Many2one(related='invoice_user_id', string='User')
company_id = fields.Many2one(string='Company', store=True, readonly=True,
related='journal_id.company_id', change_default=True)
company_currency_id = fields.Many2one(string='Company Currency', readonly=True,
related='journal_id.company_id.currency_id')
currency_id = fields.Many2one('res.currency', store=True, readonly=True, tracking=True, required=True,
states={'draft': [('readonly', False)]},
string='Currency',
default=_get_default_currency)
line_ids = fields.One2many('account.move.line', 'move_id', string='Journal Items', copy=True, readonly=True,
states={'draft': [('readonly', False)]})
partner_id = fields.Many2one('res.partner', readonly=True, tracking=True,
states={'draft': [('readonly', False)]},
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
string='Partner', change_default=True)
commercial_partner_id = fields.Many2one('res.partner', string='Commercial Entity', store=True, readonly=True,
compute='_compute_commercial_partner_id')
# === Amount fields ===
amount_untaxed = fields.Monetary(string='Untaxed Amount', store=True, readonly=True, tracking=True,
compute='_compute_amount')
amount_tax = fields.Monetary(string='Tax', store=True, readonly=True,
compute='_compute_amount')
amount_total = fields.Monetary(string='Total', store=True, readonly=True,
compute='_compute_amount',
inverse='_inverse_amount_total')
amount_residual = fields.Monetary(string='Amount Due', store=True,
compute='_compute_amount')
amount_untaxed_signed = fields.Monetary(string='Untaxed Amount Signed', store=True, readonly=True,
compute='_compute_amount', currency_field='company_currency_id')
amount_tax_signed = fields.Monetary(string='Tax Signed', store=True, readonly=True,
compute='_compute_amount', currency_field='company_currency_id')
amount_total_signed = fields.Monetary(string='Total Signed', store=True, readonly=True,
compute='_compute_amount', currency_field='company_currency_id')
amount_residual_signed = fields.Monetary(string='Amount Due Signed', store=True,
compute='_compute_amount', currency_field='company_currency_id')
amount_by_group = fields.Binary(string="Tax amount by group",
compute='_compute_invoice_taxes_by_group')
# ==== Cash basis feature fields ====
tax_cash_basis_rec_id = fields.Many2one(
'account.partial.reconcile',
string='Tax Cash Basis Entry of',
help="Technical field used to keep track of the tax cash basis reconciliation. "
"This is needed when cancelling the source: it will post the inverse journal entry to cancel that part too.")
# ==== Auto-post feature fields ====
auto_post = fields.Boolean(string='Post Automatically', default=False,
help='If this checkbox is ticked, this entry will be automatically posted at its date.')
# ==== Reverse feature fields ====
reversed_entry_id = fields.Many2one('account.move', string="Reversal of", readonly=True, copy=False)
# =========================================================
# Invoice related fields
# =========================================================
# ==== Business fields ====
fiscal_position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', readonly=True,
states={'draft': [('readonly', False)]},
domain="[('company_id', '=', company_id)]",
help="Fiscal positions are used to adapt taxes and accounts for particular customers or sales orders/invoices. "
"The default value comes from the customer.")
invoice_user_id = fields.Many2one('res.users', copy=False, tracking=True,
string='Salesperson',
default=lambda self: self.env.user)
user_id = fields.Many2one(string='User', related='invoice_user_id',
help='Technical field used to fit the generic behavior in mail templates.')
invoice_payment_state = fields.Selection(selection=[
('not_paid', 'Not Paid'),
('in_payment', 'In Payment'),
('paid', 'Paid')],
string='Payment', store=True, readonly=True, copy=False, tracking=True,
compute='_compute_amount')
invoice_date = fields.Date(string='Invoice/Bill Date', readonly=True, index=True, copy=False,
states={'draft': [('readonly', False)]},
default=_get_default_invoice_date)
invoice_date_due = fields.Date(string='Due Date', readonly=True, index=True, copy=False,
states={'draft': [('readonly', False)]})
invoice_payment_ref = fields.Char(string='Payment Reference', index=True, copy=False,
help="The payment reference to set on journal items.")
invoice_sent = fields.Boolean(readonly=True, default=False, copy=False,
help="It indicates that the invoice has been sent.")
invoice_origin = fields.Char(string='Origin', readonly=True, tracking=True,
help="The document(s) that generated the invoice.")
invoice_payment_term_id = fields.Many2one('account.payment.term', string='Payment Terms',
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
readonly=True, states={'draft': [('readonly', False)]})
# /!\ invoice_line_ids is just a subset of line_ids.
invoice_line_ids = fields.One2many('account.move.line', 'move_id', string='Invoice lines',
copy=False, readonly=True,
domain=[('exclude_from_invoice_tab', '=', False)],
states={'draft': [('readonly', False)]})
invoice_partner_bank_id = fields.Many2one('res.partner.bank', string='Bank Account',
help='Bank Account Number to which the invoice will be paid. A Company bank account if this is a Customer Invoice or Vendor Credit Note, otherwise a Partner bank account number.',
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
invoice_incoterm_id = fields.Many2one('account.incoterms', string='Incoterm',
default=_get_default_invoice_incoterm,
help='International Commercial Terms are a series of predefined commercial terms used in international transactions.')
# ==== Payment widget fields ====
invoice_outstanding_credits_debits_widget = fields.Text(groups="account.group_account_invoice",
compute='_compute_payments_widget_to_reconcile_info')
invoice_payments_widget = fields.Text(groups="account.group_account_invoice",
compute='_compute_payments_widget_reconciled_info')
invoice_has_outstanding = fields.Boolean(groups="account.group_account_invoice",
compute='_compute_payments_widget_to_reconcile_info')
# ==== Vendor bill fields ====
invoice_vendor_bill_id = fields.Many2one('account.move', store=False,
domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]",
string='Vendor Bill',
help="Auto-complete from a past bill.")
invoice_source_email = fields.Char(string='Source Email', tracking=True)
invoice_partner_display_name = fields.Char(compute='_compute_invoice_partner_display_info', store=True)
invoice_partner_icon = fields.Char(compute='_compute_invoice_partner_display_info', store=False, compute_sudo=True)
# ==== Cash rounding fields ====
invoice_cash_rounding_id = fields.Many2one('account.cash.rounding', string='Cash Rounding Method',
readonly=True, states={'draft': [('readonly', False)]},
help='Defines the smallest coinage of the currency that can be used to pay by cash.')
# ==== Fields to set the sequence, on the first invoice of the journal ====
invoice_sequence_number_next = fields.Char(string='Next Number',
compute='_compute_invoice_sequence_number_next',
inverse='_inverse_invoice_sequence_number_next')
invoice_sequence_number_next_prefix = fields.Char(string='Next Number Prefix',
compute="_compute_invoice_sequence_number_next")
# ==== Display purpose fields ====
invoice_filter_type_domain = fields.Char(compute='_compute_invoice_filter_type_domain',
help="Technical field used to have a dynamic domain on journal / taxes in the form view.")
bank_partner_id = fields.Many2one('res.partner', help='Technical field to get the domain on the bank', compute='_compute_bank_partner_id')
invoice_has_matching_suspense_amount = fields.Boolean(compute='_compute_has_matching_suspense_amount',
groups='account.group_account_invoice',
help="Technical field used to display an alert on invoices if there is at least a matching amount in any supsense account.")
tax_lock_date_message = fields.Char(
compute='_compute_tax_lock_date_message',
help="Technical field used to display a message when the invoice's accounting date is prior of the tax lock date.")
# Technical field to hide Reconciled Entries stat button
has_reconciled_entries = fields.Boolean(compute="_compute_has_reconciled_entries")
# ==== Hash Fields ====
restrict_mode_hash_table = fields.Boolean(related='journal_id.restrict_mode_hash_table')
secure_sequence_number = fields.Integer(string="Inalteralbility No Gap Sequence #", readonly=True, copy=False)
inalterable_hash = fields.Char(string="Inalterability Hash", readonly=True, copy=False)
string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True)
# -------------------------------------------------------------------------
# ONCHANGE METHODS
# -------------------------------------------------------------------------
@api.onchange('invoice_date')
def _onchange_invoice_date(self):
if self.invoice_date:
if not self.invoice_payment_term_id:
self.invoice_date_due = self.invoice_date
self.date = self.invoice_date
self._onchange_currency()
@api.onchange('journal_id')
def _onchange_journal(self):
if self.journal_id and self.journal_id.currency_id:
new_currency = self.journal_id.currency_id
if new_currency != self.currency_id:
self.currency_id = new_currency
self._onchange_currency()
@api.onchange('partner_id')
def _onchange_partner_id(self):
warning = {}
if self.partner_id:
rec_account = self.partner_id.property_account_receivable_id
pay_account = self.partner_id.property_account_payable_id
if not rec_account and not pay_account:
action = self.env.ref('account.action_account_config')
msg = _('Cannot find a chart of accounts for this company, You should configure it. \nPlease go to Account Configuration.')
raise RedirectWarning(msg, action.id, _('Go to the configuration panel'))
p = self.partner_id
if p.invoice_warn == 'no-message' and p.parent_id:
p = p.parent_id
if p.invoice_warn and p.invoice_warn != 'no-message':
# Block if partner only has warning but parent company is blocked
if p.invoice_warn != 'block' and p.parent_id and p.parent_id.invoice_warn == 'block':
p = p.parent_id
warning = {
'title': _("Warning for %s") % p.name,
'message': p.invoice_warn_msg
}
if p.invoice_warn == 'block':
self.partner_id = False
return {'warning': warning}
for line in self.line_ids:
line.partner_id = self.partner_id.commercial_partner_id
if self.is_sale_document(include_receipts=True):
self.invoice_payment_term_id = self.partner_id.property_payment_term_id
elif self.is_purchase_document(include_receipts=True):
self.invoice_payment_term_id = self.partner_id.property_supplier_payment_term_id
self._compute_bank_partner_id()
self.invoice_partner_bank_id = self.bank_partner_id.bank_ids and self.bank_partner_id.bank_ids[0]
# Find the new fiscal position.
delivery_partner_id = self._get_invoice_delivery_partner_id()
new_fiscal_position_id = self.env['account.fiscal.position'].get_fiscal_position(
self.partner_id.id, delivery_id=delivery_partner_id)
self.fiscal_position_id = self.env['account.fiscal.position'].browse(new_fiscal_position_id)
self._recompute_dynamic_lines()
if warning:
return {'warning': warning}
@api.onchange('date', 'currency_id')
def _onchange_currency(self):
company_currency = self.company_id.currency_id
has_foreign_currency = self.currency_id and self.currency_id != company_currency
for line in self.line_ids:
new_currency = has_foreign_currency and self.currency_id
line.currency_id = new_currency
line._onchange_currency()
self._recompute_dynamic_lines()
@api.onchange('invoice_payment_ref')
def _onchange_invoice_payment_ref(self):
for line in self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')):
line.name = self.invoice_payment_ref
@api.onchange('invoice_vendor_bill_id')
def _onchange_invoice_vendor_bill(self):
if self.invoice_vendor_bill_id:
# Copy invoice lines.
for line in self.invoice_vendor_bill_id.invoice_line_ids:
copied_vals = line.copy_data()[0]
copied_vals['move_id'] = self.id
new_line = self.env['account.move.line'].new(copied_vals)
new_line.recompute_tax_line = True
# Copy payment terms.
self.invoice_payment_term_id = self.invoice_vendor_bill_id.invoice_payment_term_id
# Copy currency.
if self.currency_id != self.invoice_vendor_bill_id.currency_id:
self.currency_id = self.invoice_vendor_bill_id.currency_id
# Reset
self.invoice_vendor_bill_id = False
self._recompute_dynamic_lines()
@api.onchange('type')
def _onchange_type(self):
''' Onchange made to filter the partners depending of the type. '''
if self.is_sale_document(include_receipts=True):
if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms'):
self.narration = self.company_id.invoice_terms or self.env.company.invoice_terms
@api.onchange('invoice_line_ids')
def _onchange_invoice_line_ids(self):
current_invoice_lines = self.line_ids.filtered(lambda line: not line.exclude_from_invoice_tab)
others_lines = self.line_ids - current_invoice_lines
if others_lines and current_invoice_lines - self.invoice_line_ids:
others_lines[0].recompute_tax_line = True
self.line_ids = others_lines + self.invoice_line_ids
self._onchange_recompute_dynamic_lines()
@api.onchange('line_ids', 'invoice_payment_term_id', 'invoice_date_due', 'invoice_cash_rounding_id', 'invoice_vendor_bill_id')
def _onchange_recompute_dynamic_lines(self):
self._recompute_dynamic_lines()
@api.model
def _get_tax_grouping_key_from_tax_line(self, tax_line):
''' Create the dictionary based on a tax line that will be used as key to group taxes together.
/!\ Must be consistent with '_get_tax_grouping_key_from_base_line'.
:param tax_line: An account.move.line being a tax line (with 'tax_repartition_line_id' set then).
:return: A dictionary containing all fields on which the tax will be grouped.
'''
return {
'tax_repartition_line_id': tax_line.tax_repartition_line_id.id,
'account_id': tax_line.account_id.id,
'currency_id': tax_line.currency_id.id,
'analytic_tag_ids': [(6, 0, tax_line.tax_line_id.analytic and tax_line.analytic_tag_ids.ids or [])],
'analytic_account_id': tax_line.tax_line_id.analytic and tax_line.analytic_account_id.id,
'tax_ids': [(6, 0, tax_line.tax_ids.ids)],
'tag_ids': [(6, 0, tax_line.tag_ids.ids)],
}
@api.model
def _get_tax_grouping_key_from_base_line(self, base_line, tax_vals):
''' Create the dictionary based on a base line that will be used as key to group taxes together.
/!\ Must be consistent with '_get_tax_grouping_key_from_tax_line'.
:param base_line: An account.move.line being a base line (that could contains something in 'tax_ids').
:param tax_vals: An element of compute_all(...)['taxes'].
:return: A dictionary containing all fields on which the tax will be grouped.
'''
tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_vals['tax_repartition_line_id'])
account = base_line._get_default_tax_account(tax_repartition_line) or base_line.account_id
return {
'tax_repartition_line_id': tax_vals['tax_repartition_line_id'],
'account_id': account.id,
'currency_id': base_line.currency_id.id,
'analytic_tag_ids': [(6, 0, tax_vals['analytic'] and base_line.analytic_tag_ids.ids or [])],
'analytic_account_id': tax_vals['analytic'] and base_line.analytic_account_id.id,
'tax_ids': [(6, 0, tax_vals['tax_ids'])],
'tag_ids': [(6, 0, tax_vals['tag_ids'])],
}
def _recompute_tax_lines(self):
''' Compute the dynamic tax lines of the journal entry.
:param lines_map: The line_ids dispatched by type containing:
* base_lines: The lines having a tax_ids set.
* tax_lines: The lines having a tax_line_id set.
* terms_lines: The lines generated by the payment terms of the invoice.
* rounding_lines: The cash rounding lines of the invoice.
'''
self.ensure_one()
in_draft_mode = self != self._origin
def _serialize_tax_grouping_key(grouping_dict):
''' Serialize the dictionary values to be used in the taxes_map.
:param grouping_dict: The values returned by '_get_tax_grouping_key_from_tax_line' or '_get_tax_grouping_key_from_base_line'.
:return: A string representing the values.
'''
return '-'.join(str(v) for v in grouping_dict.values())
def _compute_base_line_taxes(base_line):
''' Compute taxes amounts both in company currency / foreign currency as the ratio between
amount_currency & balance could not be the same as the expected currency rate.
The 'amount_currency' value will be set on compute_all(...)['taxes'] in multi-currency.
:param base_line: The account.move.line owning the taxes.
:return: The result of the compute_all method.
'''
move = base_line.move_id
if move.is_invoice():
sign = -1 if move.is_inbound() else 1
quantity = base_line.quantity
if base_line.currency_id:
price_unit_foreign_curr = sign * base_line.price_unit * (1 - (base_line.discount / 100.0))
price_unit_comp_curr = base_line.currency_id._convert(price_unit_foreign_curr, move.company_id.currency_id, move.company_id, move.date)
else:
price_unit_foreign_curr = 0.0
price_unit_comp_curr = sign * base_line.price_unit * (1 - (base_line.discount / 100.0))
else:
quantity = 1.0
price_unit_foreign_curr = base_line.amount_currency
price_unit_comp_curr = base_line.balance
balance_taxes_res = base_line.tax_ids._origin.compute_all(
price_unit_comp_curr,
currency=base_line.company_currency_id,
quantity=quantity,
product=base_line.product_id,
partner=base_line.partner_id,
is_refund=self.type in ('out_refund', 'in_refund'),
)
if base_line.currency_id:
# Multi-currencies mode: Taxes are computed both in company's currency / foreign currency.
amount_currency_taxes_res = base_line.tax_ids._origin.compute_all(
price_unit_foreign_curr,
currency=base_line.currency_id,
quantity=quantity,
product=base_line.product_id,
partner=base_line.partner_id,
is_refund=self.type in ('out_refund', 'in_refund'),
)
for b_tax_res, ac_tax_res in zip(balance_taxes_res['taxes'], amount_currency_taxes_res['taxes']):
tax = self.env['account.tax'].browse(b_tax_res['id'])
b_tax_res['amount_currency'] = ac_tax_res['amount']
# A tax having a fixed amount must be converted into the company currency when dealing with a
# foreign currency.
if tax.amount_type == 'fixed':
b_tax_res['amount'] = base_line.currency_id._convert(b_tax_res['amount'], move.company_id.currency_id, move.company_id, move.date)
return balance_taxes_res
taxes_map = {}
# ==== Add tax lines ====
for line in self.line_ids.filtered('tax_repartition_line_id'):
grouping_dict = self._get_tax_grouping_key_from_tax_line(line)
grouping_key = _serialize_tax_grouping_key(grouping_dict)
taxes_map[grouping_key] = {
'tax_line': line,
'balance': 0.0,
'amount_currency': 0.0,
'tax_base_amount': 0.0,
'grouping_dict': False,
}
# ==== Mount base lines ====
for line in self.line_ids.filtered(lambda line: not line.exclude_from_invoice_tab):
# Don't call compute_all if there is no tax.
if not line.tax_ids:
line.tag_ids = [(5, 0, 0)]
continue
compute_all_vals = _compute_base_line_taxes(line)
# Assign tags on base line
line.tag_ids = compute_all_vals['base_tags']
tax_exigible = True
for tax_vals in compute_all_vals['taxes']:
grouping_dict = self._get_tax_grouping_key_from_base_line(line, tax_vals)
grouping_key = _serialize_tax_grouping_key(grouping_dict)
tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_vals['tax_repartition_line_id'])
tax = tax_repartition_line.invoice_tax_id or tax_repartition_line.refund_tax_id
if tax.tax_exigibility == 'on_payment':
tax_exigible = False
taxes_map_entry = taxes_map.setdefault(grouping_key, {
'tax_line': None,
'balance': 0.0,
'amount_currency': 0.0,
'tax_base_amount': 0.0,
'grouping_dict': False,
})
taxes_map_entry['balance'] += tax_vals['amount']
taxes_map_entry['amount_currency'] += tax_vals.get('amount_currency', 0.0)
taxes_map_entry['tax_base_amount'] += tax_vals['base']
taxes_map_entry['grouping_dict'] = grouping_dict
line.tax_exigible = tax_exigible
# ==== Process taxes_map ====
for taxes_map_entry in taxes_map.values():
# Don't create tax lines with zero balance.
if self.currency_id.is_zero(taxes_map_entry['balance']) and self.currency_id.is_zero(taxes_map_entry['amount_currency']):
taxes_map_entry['grouping_dict'] = False
tax_line = taxes_map_entry['tax_line']
tax_base_amount = -taxes_map_entry['tax_base_amount'] if self.is_inbound() else taxes_map_entry['tax_base_amount']
if not tax_line and not taxes_map_entry['grouping_dict']:
continue
elif tax_line and not taxes_map_entry['grouping_dict']:
# The tax line is no longer used, drop it.
self.line_ids -= tax_line
elif tax_line:
tax_line.update({
'amount_currency': taxes_map_entry['amount_currency'],
'debit': taxes_map_entry['balance'] > 0.0 and taxes_map_entry['balance'] or 0.0,
'credit': taxes_map_entry['balance'] < 0.0 and -taxes_map_entry['balance'] or 0.0,
'tax_base_amount': tax_base_amount,
})
else:
create_method = in_draft_mode and self.env['account.move.line'].new or self.env['account.move.line'].create
tax_repartition_line_id = taxes_map_entry['grouping_dict']['tax_repartition_line_id']
tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_repartition_line_id)
tax = tax_repartition_line.invoice_tax_id or tax_repartition_line.refund_tax_id
tax_line = create_method({
'name': tax.name,
'move_id': self.id,
'partner_id': line.partner_id.id,
'company_id': line.company_id.id,
'company_currency_id': line.company_currency_id.id,
'quantity': 1.0,
'date_maturity': False,
'amount_currency': taxes_map_entry['amount_currency'],
'debit': taxes_map_entry['balance'] > 0.0 and taxes_map_entry['balance'] or 0.0,
'credit': taxes_map_entry['balance'] < 0.0 and -taxes_map_entry['balance'] or 0.0,
'tax_base_amount': tax_base_amount,
'exclude_from_invoice_tab': True,
'tax_exigible': tax.tax_exigibility == 'on_invoice',
**taxes_map_entry['grouping_dict'],
})
if in_draft_mode:
tax_line._onchange_amount_currency()
tax_line._onchange_balance()
def _recompute_cash_rounding_lines(self):
''' Handle the cash rounding feature on invoices.
In some countries, the smallest coins do not exist. For example, in Switzerland, there is no coin for 0.01 CHF.
For this reason, if invoices are paid in cash, you have to round their total amount to the smallest coin that
exists in the currency. For the CHF, the smallest coin is 0.05 CHF.
There are two strategies for the rounding:
1) Add a line on the invoice for the rounding: The cash rounding line is added as a new invoice line.
2) Add the rounding in the biggest tax amount: The cash rounding line is added as a new tax line on the tax
having the biggest balance.
'''
self.ensure_one()
in_draft_mode = self != self._origin
def _compute_cash_rounding(self, total_balance, total_amount_currency):
''' Compute the amount differences due to the cash rounding.
:param self: The current account.move record.
:param total_balance: The invoice's total in company's currency.
:param total_amount_currency: The invoice's total in invoice's currency.
:return: The amount differences both in company's currency & invoice's currency.
'''
if self.currency_id == self.company_id.currency_id:
diff_balance = self.invoice_cash_rounding_id.compute_difference(self.currency_id, total_balance)
diff_amount_currency = 0.0
else:
diff_amount_currency = self.invoice_cash_rounding_id.compute_difference(self.currency_id, total_amount_currency)
diff_balance = self.currency_id._convert(diff_amount_currency, self.company_id.currency_id, self.company_id, self.date)
return diff_balance, diff_amount_currency
def _apply_cash_rounding(self, diff_balance, diff_amount_currency, cash_rounding_line):
''' Apply the cash rounding.
:param self: The current account.move record.
:param diff_balance: The computed balance to set on the new rounding line.
:param diff_amount_currency: The computed amount in invoice's currency to set on the new rounding line.
:param cash_rounding_line: The existing cash rounding line.
:return: The newly created rounding line.
'''
rounding_line_vals = {
'debit': diff_balance > 0.0 and diff_balance or 0.0,
'credit': diff_balance < 0.0 and -diff_balance or 0.0,
'quantity': 1.0,
'amount_currency': diff_amount_currency,
'partner_id': self.partner_id.id,
'move_id': self.id,
'currency_id': self.currency_id if self.currency_id != self.company_id.currency_id else False,
'company_id': self.company_id.id,
'company_currency_id': self.company_id.currency_id.id,
'is_rounding_line': True,
'sequence': 9999,
}
if self.invoice_cash_rounding_id.strategy == 'biggest_tax':
biggest_tax_line = None
for tax_line in self.line_ids.filtered('tax_repartition_line_id'):
if not biggest_tax_line or tax_line.price_subtotal > biggest_tax_line.price_subtotal:
biggest_tax_line = tax_line
# No tax found.
if not biggest_tax_line:
return
rounding_line_vals.update({
'name': _('%s (rounding)') % biggest_tax_line.name,
'account_id': biggest_tax_line.account_id.id,
'tax_repartition_line_id': biggest_tax_line.tax_repartition_line_id.id,
'tax_exigible': biggest_tax_line.tax_exigible,
'exclude_from_invoice_tab': True,
})
elif self.invoice_cash_rounding_id.strategy == 'add_invoice_line':
rounding_line_vals.update({
'name': self.invoice_cash_rounding_id.name,
'account_id': self.invoice_cash_rounding_id.account_id.id,
})
# Create or update the cash rounding line.
if cash_rounding_line:
cash_rounding_line.update({
'amount_currency': rounding_line_vals['amount_currency'],
'debit': rounding_line_vals['debit'],
'credit': rounding_line_vals['credit'],
})
else:
create_method = in_draft_mode and self.env['account.move.line'].new or self.env['account.move.line'].create
cash_rounding_line = create_method(rounding_line_vals)
if in_draft_mode:
cash_rounding_line._onchange_amount_currency()
cash_rounding_line._onchange_balance()
existing_cash_rounding_line = self.line_ids.filtered(lambda line: line.is_rounding_line)
# The cash rounding has been removed.
if not self.invoice_cash_rounding_id:
self.line_ids -= existing_cash_rounding_line
return
# The cash rounding strategy has changed.
if self.invoice_cash_rounding_id and existing_cash_rounding_line:
strategy = self.invoice_cash_rounding_id.strategy
old_strategy = 'biggest_tax' if existing_cash_rounding_line.tax_line_id else 'add_invoice_line'
if strategy != old_strategy:
self.line_ids -= existing_cash_rounding_line
existing_cash_rounding_line = self.env['account.move.line']
others_lines = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type not in ('receivable', 'payable'))
others_lines -= existing_cash_rounding_line
total_balance = sum(others_lines.mapped('balance'))
total_amount_currency = sum(others_lines.mapped('amount_currency'))
diff_balance, diff_amount_currency = _compute_cash_rounding(self, total_balance, total_amount_currency)
# The invoice is already rounded.
if self.currency_id.is_zero(diff_balance) and self.currency_id.is_zero(diff_amount_currency):
self.line_ids -= existing_cash_rounding_line
return
_apply_cash_rounding(self, diff_balance, diff_amount_currency, existing_cash_rounding_line)
def _recompute_payment_terms_lines(self):
''' Compute the dynamic payment term lines of the journal entry.'''
self.ensure_one()
in_draft_mode = self != self._origin
today = fields.Date.context_today(self)
def _get_payment_terms_computation_date(self):
''' Get the date from invoice that will be used to compute the payment terms.
:param self: The current account.move record.
:return: A datetime.date object.
'''
if self.invoice_payment_term_id:
return self.invoice_date or today
else:
return self.invoice_date_due or self.invoice_date or today
def _get_payment_terms_account(self, payment_terms_lines):
''' Get the account from invoice that will be set as receivable / payable account.
:param self: The current account.move record.
:param payment_terms_lines: The current payment terms lines.
:return: An account.account record.
'''
if payment_terms_lines:
# Retrieve account from previous payment terms lines in order to allow the user to set a custom one.
return payment_terms_lines[0].account_id
elif self.partner_id:
# Retrieve account from partner.
if self.is_sale_document(include_receipts=True):
return self.partner_id.property_account_receivable_id
else:
return self.partner_id.property_account_payable_id
else:
# Search new account.
domain = [
('company_id', '=', self.company_id.id),
('internal_type', '=', 'receivable' if self.type in ('out_invoice', 'out_refund', 'out_receipt') else 'payable'),
]
return self.env['account.account'].search(domain, limit=1)
def _compute_payment_terms(self, date, total_balance, total_amount_currency):
''' Compute the payment terms.
:param self: The current account.move record.
:param date: The date computed by '_get_payment_terms_computation_date'.
:param total_balance: The invoice's total in company's currency.
:param total_amount_currency: The invoice's total in invoice's currency.
:return: A list <to_pay_company_currency, to_pay_invoice_currency, due_date>.
'''
if self.invoice_payment_term_id:
to_compute = self.invoice_payment_term_id.compute(total_balance, date_ref=date, currency=self.currency_id)
if self.currency_id != self.company_id.currency_id:
# Multi-currencies.
to_compute_currency = self.invoice_payment_term_id.compute(total_amount_currency, date_ref=date, currency=self.currency_id)
return [(b[0], b[1], ac[1]) for b, ac in zip(to_compute, to_compute_currency)]
else:
# Single-currency.
return [(b[0], b[1], 0.0) for b in to_compute]
else:
return [(fields.Date.to_string(date), total_balance, total_amount_currency)]
def _compute_diff_payment_terms_lines(self, existing_terms_lines, account, to_compute):
''' Process the result of the '_compute_payment_terms' method and creates/updates corresponding invoice lines.
:param self: The current account.move record.
:param existing_terms_lines: The current payment terms lines.
:param account: The account.account record returned by '_get_payment_terms_account'.
:param to_compute: The list returned by '_compute_payment_terms'.
'''
# As we try to update existing lines, sort them by due date.
existing_terms_lines = existing_terms_lines.sorted(lambda line: line.date_maturity or today)
existing_terms_lines_index = 0
# Recompute amls: update existing line or create new one for each payment term.
new_terms_lines = self.env['account.move.line']
for date_maturity, balance, amount_currency in to_compute:
if existing_terms_lines_index < len(existing_terms_lines):
# Update existing line.
candidate = existing_terms_lines[existing_terms_lines_index]
existing_terms_lines_index += 1
candidate.update({
'date_maturity': date_maturity,
'amount_currency': -amount_currency,
'debit': balance < 0.0 and -balance or 0.0,
'credit': balance > 0.0 and balance or 0.0,
})
else:
# Create new line.
create_method = in_draft_mode and self.env['account.move.line'].new or self.env['account.move.line'].create
candidate = create_method({
'name': self.invoice_payment_ref or '',
'debit': balance < 0.0 and -balance or 0.0,
'credit': balance > 0.0 and balance or 0.0,
'quantity': 1.0,
'amount_currency': -amount_currency,
'date_maturity': date_maturity,
'move_id': self.id,
'currency_id': self.currency_id.id if self.currency_id != self.company_id.currency_id else False,
'account_id': account.id,
'partner_id': self.commercial_partner_id.id,
'exclude_from_invoice_tab': True,
})
new_terms_lines += candidate
if in_draft_mode:
candidate._onchange_amount_currency()
candidate._onchange_balance()
return new_terms_lines
existing_terms_lines = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable'))
others_lines = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type not in ('receivable', 'payable'))
total_balance = sum(others_lines.mapped('balance'))
total_amount_currency = sum(others_lines.mapped('amount_currency'))
if not others_lines:
self.line_ids -= existing_terms_lines
return
computation_date = _get_payment_terms_computation_date(self)
account = _get_payment_terms_account(self, existing_terms_lines)
to_compute = _compute_payment_terms(self, computation_date, total_balance, total_amount_currency)
new_terms_lines = _compute_diff_payment_terms_lines(self, existing_terms_lines, account, to_compute)
# Remove old terms lines that are no longer needed.
self.line_ids -= existing_terms_lines - new_terms_lines
if new_terms_lines:
self.invoice_payment_ref = new_terms_lines[-1].name or ''
self.invoice_date_due = new_terms_lines[-1].date_maturity
def _recompute_dynamic_lines(self, recompute_all_taxes=False):
''' Recompute all lines that depend of others.
For example, tax lines depends of base lines (lines having tax_ids set). This is also the case of cash rounding
lines that depend of base lines or tax lines depending the cash rounding strategy. When a payment term is set,
this method will auto-balance the move with payment term lines.
:param recompute_all_taxes: Force the computation of taxes. If set to False, the computation will be done
or not depending of the field 'recompute_tax_line' in lines.
'''
for invoice in self:
# Dispatch lines and pre-compute some aggregated values like taxes.
for line in invoice.line_ids:
if line.recompute_tax_line:
recompute_all_taxes = True
line.recompute_tax_line = False
# Compute taxes.
if recompute_all_taxes:
invoice._recompute_tax_lines()
if invoice.is_invoice(include_receipts=True):
# Compute cash rounding.
invoice._recompute_cash_rounding_lines()
# Compute payment terms.
invoice._recompute_payment_terms_lines()
# Only synchronize one2many in onchange.
if invoice != invoice._origin:
invoice.invoice_line_ids = invoice.line_ids.filtered(lambda line: not line.exclude_from_invoice_tab)
def onchange(self, values, field_name, field_onchange):
# OVERRIDE
# As the dynamic lines in this model are quite complex, we need to ensure some computations are done exactly
# at the beginning / at the end of the onchange mechanism. So, the onchange recursivity is disabled.
return super(AccountMove, self.with_context(recursive_onchanges=False)).onchange(values, field_name, field_onchange)
# -------------------------------------------------------------------------
# COMPUTE METHODS
# -------------------------------------------------------------------------
@api.depends('type')
def _compute_invoice_filter_type_domain(self):
for move in self:
if move.is_sale_document(include_receipts=True):
move.invoice_filter_type_domain = 'sale'
elif move.is_purchase_document(include_receipts=True):
move.invoice_filter_type_domain = 'purchase'
else:
move.invoice_filter_type_domain = False
@api.depends('partner_id')
def _compute_commercial_partner_id(self):
for move in self:
move.commercial_partner_id = move.partner_id.commercial_partner_id
@api.depends('commercial_partner_id')
def _compute_bank_partner_id(self):
for move in self:
if move.is_outbound():
move.bank_partner_id = move.commercial_partner_id
else:
move.bank_partner_id = move.company_id.partner_id
@api.depends(
'line_ids.debit',
'line_ids.credit',
'line_ids.currency_id',
'line_ids.amount_currency',
'line_ids.amount_residual',
'line_ids.amount_residual_currency',
'line_ids.payment_id.state')
def _compute_amount(self):
invoice_ids = [move.id for move in self if move.id and move.is_invoice(include_receipts=True)]
self.env['account.payment'].flush(['state'])
if invoice_ids:
self._cr.execute(
'''
SELECT move.id
FROM account_move move
JOIN account_move_line line ON line.move_id = move.id
JOIN account_partial_reconcile part ON part.debit_move_id = line.id OR part.credit_move_id = line.id
JOIN account_move_line rec_line ON
(rec_line.id = part.credit_move_id AND line.id = part.debit_move_id)
OR
(rec_line.id = part.debit_move_id AND line.id = part.credit_move_id)
JOIN account_payment payment ON payment.id = rec_line.payment_id
JOIN account_journal journal ON journal.id = rec_line.journal_id
WHERE payment.state IN ('posted', 'sent')
AND journal.post_at = 'bank_rec'
AND move.id IN %s
''', [tuple(invoice_ids)]
)
in_payment_set = set(res[0] for res in self._cr.fetchall())
else:
in_payment_set = {}
for move in self:
total_untaxed = 0.0
total_untaxed_currency = 0.0
total_tax = 0.0
total_tax_currency = 0.0
total_residual = 0.0
total_residual_currency = 0.0
total = 0.0
total_currency = 0.0
currencies = set()
for line in move.line_ids:
if line.currency_id:
currencies.add(line.currency_id)
if move.is_invoice(include_receipts=True):
# === Invoices ===
if not line.exclude_from_invoice_tab:
# Untaxed amount.
total_untaxed += line.balance
total_untaxed_currency += line.amount_currency
total += line.balance
total_currency += line.amount_currency
elif line.tax_line_id:
# Tax amount.
total_tax += line.balance
total_tax_currency += line.amount_currency
total += line.balance
total_currency += line.amount_currency
elif line.account_id.user_type_id.type in ('receivable', 'payable'):
# Residual amount.
total_residual += line.amount_residual
total_residual_currency += line.amount_residual_currency
else:
# === Miscellaneous journal entry ===
if line.debit:
total += line.balance
total_currency += line.amount_currency
if move.type == 'entry' or move.is_outbound():
sign = 1
else:
sign = -1
move.amount_untaxed = sign * (total_untaxed_currency if len(currencies) == 1 else total_untaxed)
move.amount_tax = sign * (total_tax_currency if len(currencies) == 1 else total_tax)
move.amount_total = sign * (total_currency if len(currencies) == 1 else total)
move.amount_residual = -sign * (total_residual_currency if len(currencies) == 1 else total_residual)
move.amount_untaxed_signed = -total_untaxed
move.amount_tax_signed = -total_tax
move.amount_total_signed = -total
move.amount_residual_signed = total_residual
currency = len(currencies) == 1 and currencies.pop() or move.company_id.currency_id
is_paid = currency and currency.is_zero(move.amount_residual) or not move.amount_residual
# Compute 'invoice_payment_state'.
if move.state == 'posted' and is_paid:
if move.id in in_payment_set:
move.invoice_payment_state = 'in_payment'
else:
move.invoice_payment_state = 'paid'
else:
move.invoice_payment_state = 'not_paid'
def _inverse_amount_total(self):