-
Notifications
You must be signed in to change notification settings - Fork 24.9k
/
account_move.py
5368 lines (4630 loc) · 271 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, AccessError
from odoo.tools import float_compare, date_utils, email_split, email_re, float_is_zero
from odoo.tools.misc import formatLang, format_date, get_lang
from odoo.osv import expression
from datetime import date, timedelta
from collections import defaultdict
from itertools import zip_longest
from hashlib import sha256
from json import dumps
import ast
import json
import re
import warnings
MAX_HASH_VERSION = 2
def calc_check_digits(number):
"""Calculate the extra digits that should be appended to the number to make it a valid number.
Source: python-stdnum iso7064.mod_97_10.calc_check_digits
"""
number_base10 = ''.join(str(int(x, 36)) for x in number)
checksum = int(number_base10) % 97
return '%02d' % ((98 - 100 * checksum) % 97)
class AccountMove(models.Model):
_name = "account.move"
_inherit = ['portal.mixin', 'mail.thread', 'mail.activity.mixin', 'sequence.mixin']
_description = "Journal Entry"
_order = 'date desc, name desc, id desc'
_mail_post_access = 'read'
_check_company_auto = True
_sequence_index = "journal_id"
def init(self):
super().init()
self.env.cr.execute("""
CREATE INDEX IF NOT EXISTS account_move_to_check_idx
ON account_move(journal_id) WHERE to_check = true;
CREATE INDEX IF NOT EXISTS account_move_payment_idx
ON account_move(journal_id, state, payment_state, move_type, date);
""")
@property
def _sequence_monthly_regex(self):
return self.journal_id.sequence_override_regex or super()._sequence_monthly_regex
@property
def _sequence_yearly_regex(self):
return self.journal_id.sequence_override_regex or super()._sequence_yearly_regex
@property
def _sequence_fixed_regex(self):
return self.journal_id.sequence_override_regex or super()._sequence_fixed_regex
@api.model
def _search_default_journal(self, journal_types):
company_id = self._context.get('default_company_id', self.env.company.id)
domain = [('company_id', '=', company_id), ('type', 'in', journal_types)]
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:
company = self.env['res.company'].browse(company_id)
error_msg = _(
"No journal could be found in company %(company_name)s for any of those types: %(journal_types)s",
company_name=company.display_name,
journal_types=', '.join(journal_types),
)
raise UserError(error_msg)
return journal
@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_move_type', 'entry')
if move_type in self.get_sale_types(include_receipts=True):
journal_types = ['sale']
elif move_type in self.get_purchase_types(include_receipts=True):
journal_types = ['purchase']
else:
journal_types = self._context.get('default_move_journal_types', ['general'])
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 not in journal_types:
raise UserError(_(
"Cannot create an invoice of type %(move_type)s with a journal having %(journal_type)s as type.",
move_type=move_type,
journal_type=journal.type,
))
else:
journal = self._search_default_journal(journal_types)
return journal
# TODO remove in master
@api.model
def _get_default_invoice_date(self):
warnings.warn("Method '_get_default_invoice_date()' is deprecated and has been removed.", DeprecationWarning)
return fields.Date.context_today(self) if self._context.get('default_move_type', 'entry') in self.get_purchase_types(include_receipts=True) 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', copy=False, compute='_compute_name', readonly=False, store=True, index=True, tracking=True)
highest_name = fields.Char(compute='_compute_highest_name')
show_name_warning = fields.Boolean(store=False)
date = fields.Date(
string='Date',
required=True,
index=True,
readonly=True,
states={'draft': [('readonly', False)]},
copy=False,
default=fields.Date.context_today
)
ref = fields.Char(string='Reference', copy=False, tracking=True)
narration = fields.Text(string='Terms and Conditions')
state = fields.Selection(selection=[
('draft', 'Draft'),
('posted', 'Posted'),
('cancel', 'Cancelled'),
], string='Status', required=True, readonly=True, copy=False, tracking=True,
default='draft')
posted_before = fields.Boolean(help="Technical field for knowing if the move has been posted before", copy=False)
move_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)
type_name = fields.Char('Type Name', compute='_compute_type_name')
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 information 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)]},
check_company=True, domain="[('id', 'in', suitable_journal_ids)]",
default=_get_default_journal)
suitable_journal_ids = fields.Many2many('account.journal', compute='_compute_suitable_journal_ids')
company_id = fields.Many2one(comodel_name='res.company', string='Company',
store=True, readonly=True,
compute='_compute_company_id')
company_currency_id = fields.Many2one(string='Company Currency', readonly=True,
related='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)]},
check_company=True,
string='Partner', change_default=True, ondelete='restrict')
commercial_partner_id = fields.Many2one('res.partner', string='Commercial Entity', store=True, readonly=True,
compute='_compute_commercial_partner_id', ondelete='restrict')
country_code = fields.Char(related='company_id.country_id.code', readonly=True)
user_id = fields.Many2one(string='User', related='invoice_user_id',
help='Technical field used to fit the generic behavior in mail templates.')
is_move_sent = fields.Boolean(
readonly=True,
default=False,
copy=False,
tracking=True,
help="It indicates that the invoice/payment has been sent.",
)
partner_bank_id = fields.Many2one('res.partner.bank', string='Recipient Bank',
compute="_compute_partner_bank_id", store=True, readonly=False,
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.',
check_company=True)
payment_reference = fields.Char(string='Payment Reference', index=True, copy=False,
compute='_compute_payment_reference', store=True, readonly=False,
help="The payment reference to set on journal items.")
payment_id = fields.Many2one(
index=True,
comodel_name='account.payment',
string="Payment", copy=False, check_company=True)
statement_line_id = fields.Many2one(
comodel_name='account.bank.statement.line',
string="Statement Line", copy=False, check_company=True)
# === 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',
help='Edit Tax amounts if you encounter rounding issues.')
payment_state = fields.Selection(selection=[
('not_paid', 'Not Paid'),
('in_payment', 'In Payment'),
('paid', 'Paid'),
('partial', 'Partially Paid'),
('reversed', 'Reversed'),
('invoicing_legacy', 'Invoicing App Legacy')],
string="Payment Status", store=True, readonly=True, copy=False, tracking=True,
compute='_compute_amount')
# ==== 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.")
tax_cash_basis_move_id = fields.Many2one(
comodel_name='account.move',
index=True,
string="Origin Tax Cash Basis Entry",
help="The journal entry from which this tax cash basis journal entry has been created.")
# ==== Auto-post feature fields ====
auto_post = fields.Boolean(string='Post Automatically', default=False, copy=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,
check_company=True, index=True)
reversal_move_id = fields.One2many('account.move', 'reversed_entry_id')
# =========================================================
# Invoice related fields
# =========================================================
# ==== Business fields ====
fiscal_position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', readonly=True,
states={'draft': [('readonly', False)]},
check_company=True,
domain="[('company_id', '=', company_id)]", ondelete="restrict",
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)
invoice_date = fields.Date(string='Invoice/Bill Date', readonly=True, index=True, copy=False,
states={'draft': [('readonly', False)]})
invoice_date_due = fields.Date(string='Due Date', readonly=True, index=True, copy=False,
states={'draft': [('readonly', False)]})
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',
check_company=True,
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_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.')
display_qr_code = fields.Boolean(string="Display QR-code", compute='_compute_display_qr_code')
qr_code_method = fields.Selection(string="Payment QR-code", copy=False,
selection=lambda self: self.env['res.partner.bank'].get_available_qr_methods_in_sequence(),
help="Type of QR-code to be generated for the payment of this invoice, when printing it. If left blank, the first available and usable method will be used.")
# ==== Payment widget fields ====
invoice_outstanding_credits_debits_widget = fields.Text(groups="account.group_account_invoice,account.group_account_readonly",
compute='_compute_payments_widget_to_reconcile_info')
invoice_has_outstanding = fields.Boolean(groups="account.group_account_invoice,account.group_account_readonly",
compute='_compute_payments_widget_to_reconcile_info')
invoice_payments_widget = fields.Text(groups="account.group_account_invoice,account.group_account_readonly",
compute='_compute_payments_widget_reconciled_info')
# ==== Vendor bill fields ====
invoice_vendor_bill_id = fields.Many2one('account.move', store=False,
check_company=True,
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)
# ==== 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.')
# ==== 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,account.group_account_readonly',
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")
show_reset_to_draft_button = fields.Boolean(compute='_compute_show_reset_to_draft_button')
# ==== 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, index=True)
inalterable_hash = fields.Char(string="Inalterability Hash", readonly=True, copy=False)
string_to_hash = fields.Char(compute='_compute_string_to_hash', readonly=True)
@api.model
def _field_will_change(self, record, vals, field_name):
if field_name not in vals:
return False
field = record._fields[field_name]
if field.type == 'many2one':
return record[field_name].id != vals[field_name]
if field.type == 'many2many':
current_ids = set(record[field_name].ids)
after_write_ids = set(record.new({field_name: vals[field_name]})[field_name].ids)
return current_ids != after_write_ids
if field.type == 'one2many':
return True
if field.type == 'monetary' and record[field.currency_field]:
return not record[field.currency_field].is_zero(record[field_name] - vals[field_name])
if field.type == 'float':
record_value = field.convert_to_cache(record[field_name], record)
to_write_value = field.convert_to_cache(vals[field_name], record)
return record_value != to_write_value
return record[field_name] != vals[field_name]
@api.model
def _cleanup_write_orm_values(self, record, vals):
cleaned_vals = dict(vals)
for field_name, value in vals.items():
if not self._field_will_change(record, vals, field_name):
del cleaned_vals[field_name]
return cleaned_vals
# -------------------------------------------------------------------------
# ONCHANGE METHODS
# -------------------------------------------------------------------------
def _get_accounting_date(self, invoice_date, has_tax):
"""Get correct accounting date for previous periods, taking tax lock date into account.
When registering an invoice in the past, we still want the sequence to be increasing.
We then take the last day of the period, depending on the sequence format.
If there is a tax lock date and there are taxes involved, we register the invoice at the
last date of the first open period.
:param invoice_date (datetime.date): The invoice date
:param has_tax (bool): Iff any taxes are involved in the lines of the invoice
:return (datetime.date):
"""
tax_lock_date = self.company_id.tax_lock_date
today = fields.Date.today()
if invoice_date and tax_lock_date and has_tax and invoice_date <= tax_lock_date:
invoice_date = tax_lock_date + timedelta(days=1)
if self.is_sale_document(include_receipts=True):
return invoice_date
else:
highest_name = self.highest_name or self._get_last_sequence(relaxed=True, lock=False)
number_reset = self._deduce_sequence_number_reset(highest_name)
if not highest_name or number_reset == 'month':
if (today.year, today.month) > (invoice_date.year, invoice_date.month):
return date_utils.get_month(invoice_date)[1]
else:
return max(invoice_date, today)
elif number_reset == 'year':
if today.year > invoice_date.year:
return date(invoice_date.year, 12, 31)
else:
return max(invoice_date, today)
return invoice_date
@api.onchange('invoice_date', 'highest_name', 'company_id')
def _onchange_invoice_date(self):
if self.invoice_date:
if not self.invoice_payment_term_id and (not self.invoice_date_due or self.invoice_date_due < self.invoice_date):
self.invoice_date_due = self.invoice_date
has_tax = bool(self.line_ids.tax_ids or self.line_ids.tax_tag_ids)
accounting_date = self._get_accounting_date(self.invoice_date, has_tax)
if accounting_date != self.date:
self.date = accounting_date
self._onchange_currency()
else:
self._onchange_recompute_dynamic_lines()
@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()
if self.state == 'draft' and self._get_last_sequence(lock=False) and self.name and self.name != '/':
self.name = '/'
@api.onchange('partner_id')
def _onchange_partner_id(self):
self = self.with_company(self.journal_id.company_id)
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}
if self.is_sale_document(include_receipts=True) and self.partner_id:
self.invoice_payment_term_id = self.partner_id.property_payment_term_id or self.invoice_payment_term_id
new_term_account = self.partner_id.commercial_partner_id.property_account_receivable_id
self.narration = self.company_id.with_context(lang=self.partner_id.lang or self.env.lang).invoice_terms
elif self.is_purchase_document(include_receipts=True) and self.partner_id:
self.invoice_payment_term_id = self.partner_id.property_supplier_payment_term_id or self.invoice_payment_term_id
new_term_account = self.partner_id.commercial_partner_id.property_account_payable_id
else:
new_term_account = None
for line in self.line_ids:
line.partner_id = self.partner_id.commercial_partner_id
if new_term_account and line.account_id.user_type_id.type in ('receivable', 'payable'):
line.account_id = new_term_account
self._compute_bank_partner_id()
# Find the new fiscal position.
delivery_partner_id = self._get_invoice_delivery_partner_id()
self.fiscal_position_id = self.env['account.fiscal.position'].get_fiscal_position(
self.partner_id.id, delivery_id=delivery_partner_id)
self._recompute_dynamic_lines()
if warning:
return {'warning': warning}
@api.onchange('date', 'currency_id')
def _onchange_currency(self):
currency = self.currency_id or self.company_id.currency_id
if self.is_invoice(include_receipts=True):
for line in self._get_lines_onchange_currency():
line.currency_id = currency
line._onchange_currency()
else:
for line in self.line_ids:
line._onchange_currency()
self._recompute_dynamic_lines(recompute_tax_base_amount=True)
@api.onchange('payment_reference')
def _onchange_payment_reference(self):
for line in self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')):
line.name = self.payment_reference or ''
@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
self._onchange_currency()
# Reset
self.invoice_vendor_bill_id = False
self._recompute_dynamic_lines()
@api.onchange('move_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)],
'tax_tag_ids': [(6, 0, tax_line.tax_tag_ids.ids)],
'partner_id': tax_line.partner_id.id,
}
@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'])],
'tax_tag_ids': [(6, 0, tax_vals['tag_ids'])],
'partner_id': base_line.partner_id.id,
}
def _get_tax_force_sign(self):
""" The sign must be forced to a negative sign in case the balance is on credit
to avoid negatif taxes amount.
Example - Customer Invoice :
Fixed Tax | unit price | discount | amount_tax | amount_total |
-------------------------------------------------------------------------
0.67 | 115 | 100% | - 0.67 | 0
-------------------------------------------------------------------------"""
self.ensure_one()
return -1 if self.move_type in ('out_invoice', 'in_refund', 'out_receipt') else 1
def _preprocess_taxes_map(self, taxes_map):
""" Useful in case we want to pre-process taxes_map """
return taxes_map
def _recompute_tax_lines(self, recompute_tax_base_amount=False, tax_rep_lines_to_recompute=None):
""" Compute the dynamic tax lines of the journal entry.
:param recompute_tax_base_amount: Flag forcing only the recomputation of the `tax_base_amount` field.
"""
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(include_receipts=True):
handle_price_include = True
sign = -1 if move.is_inbound() else 1
quantity = base_line.quantity
is_refund = move.move_type in ('out_refund', 'in_refund')
price_unit_wo_discount = sign * base_line.price_unit * (1 - (base_line.discount / 100.0))
else:
handle_price_include = False
quantity = 1.0
tax_type = base_line.tax_ids[0].type_tax_use if base_line.tax_ids else None
is_refund = (tax_type == 'sale' and base_line.debit) or (tax_type == 'purchase' and base_line.credit)
price_unit_wo_discount = base_line.amount_currency
balance_taxes_res = base_line.tax_ids._origin.with_context(force_sign=move._get_tax_force_sign()).compute_all(
price_unit_wo_discount,
currency=base_line.currency_id,
quantity=quantity,
product=base_line.product_id,
partner=base_line.partner_id,
is_refund=is_refund,
handle_price_include=handle_price_include,
)
if move.move_type == 'entry':
repartition_field = is_refund and 'refund_repartition_line_ids' or 'invoice_repartition_line_ids'
repartition_tags = base_line.tax_ids.flatten_taxes_hierarchy().mapped(repartition_field).filtered(lambda x: x.repartition_type == 'base').tag_ids
tags_need_inversion = self._tax_tags_need_inversion(move, is_refund, tax_type)
if tags_need_inversion:
balance_taxes_res['base_tags'] = base_line._revert_signed_tags(repartition_tags).ids
for tax_res in balance_taxes_res['taxes']:
tax_res['tag_ids'] = base_line._revert_signed_tags(self.env['account.account.tag'].browse(tax_res['tag_ids'])).ids
return balance_taxes_res
taxes_map = {}
# ==== Add tax lines ====
to_remove = self.env['account.move.line']
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)
if grouping_key in taxes_map:
# A line with the same key does already exist, we only need one
# to modify it; we have to drop this one.
to_remove += line
else:
taxes_map[grouping_key] = {
'tax_line': line,
'amount': 0.0,
'tax_base_amount': 0.0,
'grouping_dict': False,
}
if not recompute_tax_base_amount:
self.line_ids -= to_remove
# ==== Mount base lines ====
for line in self.line_ids.filtered(lambda line: not line.tax_repartition_line_id):
# Don't call compute_all if there is no tax.
if not line.tax_ids:
if not recompute_tax_base_amount:
line.tax_tag_ids = [(5, 0, 0)]
continue
compute_all_vals = _compute_base_line_taxes(line)
# Assign tags on base line
if not recompute_tax_base_amount:
line.tax_tag_ids = compute_all_vals['base_tags'] or [(5, 0, 0)]
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,
'amount': 0.0,
'tax_base_amount': 0.0,
'grouping_dict': False,
})
taxes_map_entry['amount'] += tax_vals['amount']
taxes_map_entry['tax_base_amount'] += self._get_base_amount_to_display(tax_vals['base'], tax_repartition_line, tax_vals['group'])
taxes_map_entry['grouping_dict'] = grouping_dict
if not recompute_tax_base_amount:
line.tax_exigible = tax_exigible
# ==== Pre-process taxes_map ====
taxes_map = self._preprocess_taxes_map(taxes_map)
# ==== Process taxes_map ====
for taxes_map_entry in taxes_map.values():
# The tax line is no longer used in any base lines, drop it.
if taxes_map_entry['tax_line'] and not taxes_map_entry['grouping_dict']:
if not recompute_tax_base_amount:
self.line_ids -= taxes_map_entry['tax_line']
continue
currency = self.env['res.currency'].browse(taxes_map_entry['grouping_dict']['currency_id'])
# Don't create tax lines with zero balance.
if currency.is_zero(taxes_map_entry['amount']):
if taxes_map_entry['tax_line'] and not recompute_tax_base_amount:
self.line_ids -= taxes_map_entry['tax_line']
continue
# tax_base_amount field is expressed using the company currency.
tax_base_amount = currency._convert(taxes_map_entry['tax_base_amount'], self.company_currency_id, self.company_id, self.date or fields.Date.context_today(self))
# Recompute only the tax_base_amount.
if recompute_tax_base_amount:
if taxes_map_entry['tax_line']:
taxes_map_entry['tax_line'].tax_base_amount = tax_base_amount
continue
balance = currency._convert(
taxes_map_entry['amount'],
self.company_currency_id,
self.company_id,
self.date or fields.Date.context_today(self),
)
amount_currency = currency.round(taxes_map_entry['amount'])
to_write_on_line = {
'amount_currency': amount_currency,
'currency_id': taxes_map_entry['grouping_dict']['currency_id'],
'debit': balance > 0.0 and balance or 0.0,
'credit': balance < 0.0 and -balance or 0.0,
'tax_base_amount': tax_base_amount,
}
if taxes_map_entry['tax_line']:
# Update an existing tax line.
if tax_rep_lines_to_recompute and taxes_map_entry['tax_line'].tax_repartition_line_id not in tax_rep_lines_to_recompute:
continue
taxes_map_entry['tax_line'].update(to_write_on_line)
else:
# Create a new tax line.
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)
if tax_rep_lines_to_recompute and tax_repartition_line not in tax_rep_lines_to_recompute:
continue
tax = tax_repartition_line.invoice_tax_id or tax_repartition_line.refund_tax_id
taxes_map_entry['tax_line'] = create_method({
**to_write_on_line,
'name': tax.name,
'move_id': self.id,
'company_id': self.company_id.id,
'company_currency_id': self.company_currency_id.id,
'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:
taxes_map_entry['tax_line'].update(taxes_map_entry['tax_line']._get_fields_onchange_balance(force_computation=True))
def _tax_tags_need_inversion(self, move, is_refund, tax_type):
""" Tells whether the tax tags need to be inverted for a given move.
:param move: the move for which we want to check inversion
:param is_refund: whether or not the operation we want the inversion value for is a refund
:param tax_type: the tax type of the operation we want the inversion value for
:return: True if the tags need to be inverted
"""
if move.move_type == 'entry':
return (tax_type == 'sale' and not is_refund) or (tax_type == 'purchase' and is_refund)
return False
@api.model
def _get_base_amount_to_display(self, base_amount, tax_rep_ln, parent_tax_group=None):
""" The base amount returned for taxes by compute_all has is the balance
of the base line. For inbound operations, positive sign is on credit, so
we need to invert the sign of this amount before displaying it.
"""
source_tax = parent_tax_group or tax_rep_ln.invoice_tax_id or tax_rep_ln.refund_tax_id
if (tax_rep_ln.invoice_tax_id and source_tax.type_tax_use == 'sale') \
or (tax_rep_ln.refund_tax_id and source_tax.type_tax_use == 'purchase'):
return -base_amount
return base_amount
def update_lines_tax_exigibility(self):
if all(account.user_type_id.type not in {'payable', 'receivable'} for account in self.mapped('line_ids.account_id')):
self.line_ids.write({'tax_exigible': True})
else:
tax_lines_caba = self.line_ids.filtered(lambda x: x.tax_line_id.tax_exigibility == 'on_payment')
base_lines_caba = self.line_ids.filtered(lambda x: any(tax.tax_exigibility == 'on_payment'
or (tax.amount_type == 'group'
and 'on_payment' in tax.mapped('children_tax_ids.tax_exigibility'))
for tax in x.tax_ids))
caba_lines = tax_lines_caba + base_lines_caba
caba_lines.write({'tax_exigible': False})
(self.line_ids - caba_lines).write({'tax_exigible': True})
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_amount_currency):
''' Compute the amount differences due to the cash rounding.
:param self: The current account.move record.
:param total_amount_currency: The invoice's total in invoice's currency.
:return: The amount differences both in company's currency & invoice's currency.
'''
difference = self.invoice_cash_rounding_id.compute_difference(self.currency_id, total_amount_currency)
if self.currency_id == self.company_id.currency_id:
diff_amount_currency = diff_balance = difference
else:
diff_amount_currency = difference
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.id,
'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_tag_ids': [(6, 0, biggest_tax_line.tax_tag_ids.ids)],
'tax_exigible': biggest_tax_line.tax_exigible,
'exclude_from_invoice_tab': True,
})
elif self.invoice_cash_rounding_id.strategy == 'add_invoice_line':
if diff_balance > 0.0 and self.invoice_cash_rounding_id.loss_account_id:
account_id = self.invoice_cash_rounding_id.loss_account_id.id
else:
account_id = self.invoice_cash_rounding_id.profit_account_id.id
rounding_line_vals.update({
'name': self.invoice_cash_rounding_id.name,
'account_id': account_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'],
'account_id': rounding_line_vals['account_id'],
})
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.update(cash_rounding_line._get_fields_onchange_balance(force_computation=True))
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_amount_currency = sum(others_lines.mapped('amount_currency'))
diff_balance, diff_amount_currency = _compute_cash_rounding(self, 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()
self = self.with_company(self.company_id)
in_draft_mode = self != self._origin
today = fields.Date.context_today(self)
self = self.with_company(self.journal_id.company_id)
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.move_type in ('out_invoice', 'out_refund', 'out_receipt') else 'payable'),
('deprecated', '=', False),
]
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.